Add backlink
This commit is contained in:
@ -170,6 +170,58 @@ User types in editor → CodeMirror EditorView.updateListener
|
||||
└─ HTMX updates file tree automatically
|
||||
```
|
||||
|
||||
### 6. Creating Links Between Notes (Internal Links)
|
||||
|
||||
```
|
||||
User types /ilink → SlashCommands detects slash + query
|
||||
│
|
||||
├─ Filters commands by query
|
||||
└─ Shows command palette
|
||||
│
|
||||
└─ User selects "ilink" → SlashCommands.openLinkInserter()
|
||||
│
|
||||
├─ Saves current cursor position
|
||||
└─ Opens LinkInserter modal
|
||||
│
|
||||
User types query → LinkInserter searches
|
||||
│
|
||||
├─ Debounce 200ms
|
||||
└─ fetch('/api/search?query=...')
|
||||
│
|
||||
├─ Go Server queries indexer
|
||||
├─ Returns HTML results
|
||||
└─ LinkInserter parses HTML
|
||||
│
|
||||
├─ Extracts title, path, tags
|
||||
├─ Renders in modal
|
||||
└─ Updates keyboard selection
|
||||
│
|
||||
User selects note → LinkInserter.selectResult()
|
||||
(Enter/click) │
|
||||
├─ Calls callback with {title, path}
|
||||
└─ SlashCommands.openLinkInserter callback
|
||||
│
|
||||
├─ Builds HTML with HTMX: <a href="#" hx-get="/api/notes/path">title</a>
|
||||
├─ Uses CodeMirror transaction
|
||||
├─ Replaces /ilink with HTML link
|
||||
├─ Positions cursor after link
|
||||
└─ Closes modal
|
||||
│
|
||||
Preview Rendering → marked.js parses Markdown (including inline HTML)
|
||||
│
|
||||
├─ DOMPurify sanitizes (allows hx-* attributes)
|
||||
├─ htmx.process() activates HTMX on links
|
||||
└─ Links become clickable → load note via HTMX
|
||||
```
|
||||
|
||||
**Key Design Decisions**:
|
||||
- **No new backend code**: Reuses existing `/api/search` endpoint for search, `/api/notes/` for navigation
|
||||
- **Database-free**: Leverages in-memory indexer for speed
|
||||
- **Consistent UX**: Modal design matches SearchModal styling
|
||||
- **Clickable links**: HTML with HTMX attributes, rendered directly by marked.js
|
||||
- **HTMX integration**: Links use `hx-get` to load notes without page reload
|
||||
- **Keyboard-first**: Full keyboard navigation without mouse
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
### Build Process (Vite)
|
||||
@ -224,6 +276,17 @@ Executed in browser → Initializes components
|
||||
- Result highlighting
|
||||
- Uses HTMX search API
|
||||
|
||||
**link-inserter.js**
|
||||
- LinkInserter class for internal note linking
|
||||
- Modal search interface for `/ilink` command
|
||||
- Fuzzy search across notes
|
||||
- Keyboard navigation (↑/↓/Enter/Esc)
|
||||
- Integration with SlashCommands
|
||||
- Uses HTMX search API for consistency
|
||||
- Inserts Markdown links into editor
|
||||
|
||||
**Note**: `/link` command inserts standard Markdown template `[texte](url)` for external links
|
||||
|
||||
**ui.js**
|
||||
- Sidebar toggle (mobile/desktop)
|
||||
- Simple utility functions
|
||||
|
||||
272
CLAUDE.md
272
CLAUDE.md
@ -6,16 +6,29 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
A lightweight web-based Markdown note-taking application with a Go backend and modern JavaScript frontend. Notes are stored as plain Markdown files with YAML front matter containing metadata (title, date, last_modified, tags). The system provides a sophisticated CodeMirror 6 editor with live preview, rich search capabilities, hierarchical organization, and automatic front matter management.
|
||||
|
||||
**Key Features**:
|
||||
- **Daily Notes**: Quick daily journaling with interactive calendar, keyboard shortcuts (Ctrl/Cmd+D), and structured templates
|
||||
- **Favorites System**: Star important notes and folders for quick access from the sidebar
|
||||
- **Note Linking**: Create links between notes with `/link` command and fuzzy search modal
|
||||
- **Vim Mode**: Full Vim keybindings support (hjkl navigation, modes, commands) for power users
|
||||
- **Multiple Themes**: 8 dark themes (Material Dark, Monokai, Dracula, One Dark, Solarized, Nord, Catppuccin, Everforest)
|
||||
- **Font Customization**: 8 fonts (JetBrains Mono, Fira Code, Inter, etc.) with 4 size options
|
||||
- **Keyboard Shortcuts**: 10+ global shortcuts for navigation, editing, and productivity
|
||||
|
||||
**Recent Modernization**: The project has been migrated from a simple textarea editor to CodeMirror 6, with a Vite build system for frontend modules. The backend remains unchanged, maintaining the same Go architecture with htmx for dynamic interactions.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend (Go)
|
||||
|
||||
Three main packages under `internal/`:
|
||||
Four main packages under `internal/`:
|
||||
- **indexer**: Maintains an in-memory index mapping tags to note files. Parses YAML front matter from `.md` files to build the index. Thread-safe with RWMutex.
|
||||
- **watcher**: Uses `fsnotify` to monitor filesystem changes and trigger re-indexing with 200ms debounce. Recursively watches all subdirectories.
|
||||
- **api**: HTTP handlers that serve templates and handle CRUD operations on notes. Updates front matter automatically on save.
|
||||
- `handler.go` - Main HTML endpoints for the web interface
|
||||
- `rest_handler.go` - REST API endpoints (v1)
|
||||
- `daily_notes.go` - Daily note creation and calendar functionality
|
||||
- `favorites.go` - Favorites management (star/unstar notes and folders)
|
||||
|
||||
The server (`cmd/server/main.go`) coordinates these components:
|
||||
1. Loads initial index from notes directory
|
||||
@ -30,6 +43,8 @@ The server (`cmd/server/main.go`) coordinates these components:
|
||||
- `/api/folders/create` (Folder management)
|
||||
- `/api/files/move` (File/folder moving)
|
||||
- `/api/home` (Home page)
|
||||
- `/api/daily-notes/*` (Daily note creation and calendar)
|
||||
- `/api/favorites/*` (Favorites management)
|
||||
5. Handles static files from `static/` directory
|
||||
|
||||
### Frontend
|
||||
@ -46,22 +61,33 @@ The frontend uses a modern build system with Vite and CodeMirror 6:
|
||||
#### Frontend Source Structure
|
||||
```
|
||||
frontend/src/
|
||||
├── main.js # Entry point - imports all modules
|
||||
├── editor.js # CodeMirror 6 editor implementation with slash commands
|
||||
├── search.js # Search modal with Ctrl/Cmd+K keyboard shortcut
|
||||
├── file-tree.js # Drag-and-drop file organization
|
||||
└── ui.js # Sidebar toggle functionality
|
||||
├── main.js # Entry point - imports all modules
|
||||
├── editor.js # CodeMirror 6 editor implementation with slash commands
|
||||
├── vim-mode-manager.js # Vim mode integration for CodeMirror
|
||||
├── search.js # Search modal with Ctrl/Cmd+K keyboard shortcut
|
||||
├── link-inserter.js # Note linking modal for /link command
|
||||
├── file-tree.js # Drag-and-drop file organization
|
||||
├── favorites.js # Favorites system (star/unstar functionality)
|
||||
├── daily-notes.js # Daily notes creation and calendar widget
|
||||
├── keyboard-shortcuts.js # Global keyboard shortcuts management
|
||||
├── theme-manager.js # Theme switching and persistence
|
||||
├── font-manager.js # Font selection and size management
|
||||
└── ui.js # Sidebar toggle functionality
|
||||
```
|
||||
|
||||
#### CodeMirror 6 Editor Features
|
||||
- **Syntax Highlighting**: Full Markdown language support (`@codemirror/lang-markdown`)
|
||||
- **Theme**: One Dark theme (`@codemirror/theme-one-dark`) - VS Code-inspired dark theme
|
||||
- **Vim Mode**: Optional full Vim keybindings (`@replit/codemirror-vim`) with hjkl navigation, modes, and commands
|
||||
- **Live Preview**: Debounced updates (150ms) synchronized with editor scroll position
|
||||
- **Auto-Save**: Triggers after 2 seconds of inactivity
|
||||
- **Keyboard Shortcuts**:
|
||||
- `Ctrl/Cmd+S` for manual save
|
||||
- `Ctrl/Cmd+D` for daily notes
|
||||
- `Ctrl/Cmd+K` for search
|
||||
- `Ctrl/Cmd+B` for sidebar toggle
|
||||
- `Tab` for proper indentation
|
||||
- Full keyboard navigation
|
||||
- Full keyboard navigation (see docs/KEYBOARD_SHORTCUTS.md)
|
||||
- **View Modes**: Toggle between split view, editor-only, and preview-only
|
||||
- **Slash Commands**: Type `/` to open command palette for quick Markdown insertion
|
||||
- **Front Matter Handling**: Automatically strips YAML front matter in preview
|
||||
@ -71,12 +97,21 @@ frontend/src/
|
||||
- **Drag & Drop**: Move files between folders with visual feedback
|
||||
- **Folder Creation**: Modal-based creation supporting nested paths
|
||||
- **Safe Validation**: Prevents dangerous path operations
|
||||
- **Favorites**: Star notes and folders for quick access (★ icon in sidebar)
|
||||
|
||||
#### Rendering Pipeline
|
||||
- **marked.js**: Markdown to HTML conversion
|
||||
- **DOMPurify**: HTML sanitization to prevent XSS attacks
|
||||
- **Highlight.js**: Syntax highlighting for code blocks in preview
|
||||
- **Custom Theme**: Material Darker theme in `static/theme.css` with CSS custom properties
|
||||
- **Custom Themes**: 8 dark themes in `static/theme.css` with CSS custom properties
|
||||
- Material Dark (default)
|
||||
- Monokai
|
||||
- Dracula
|
||||
- One Dark
|
||||
- Solarized Dark
|
||||
- Nord
|
||||
- Catppuccin
|
||||
- Everforest
|
||||
|
||||
### HTMX + JavaScript Coordination (Optimized Architecture)
|
||||
|
||||
@ -227,6 +262,33 @@ htmx.ajax('POST', '/api/files/move', {
|
||||
- Use MutationObserver when HTMX events are available
|
||||
- Mix fetch() and htmx.ajax() for similar operations
|
||||
|
||||
### Daily Notes
|
||||
|
||||
**Implementation**: `internal/api/daily_notes.go` and `frontend/src/daily-notes.js`
|
||||
|
||||
Daily notes provide a quick journaling feature:
|
||||
- **Keyboard Shortcut**: `Ctrl/Cmd+D` creates or opens today's note
|
||||
- **Calendar Widget**: Interactive monthly calendar showing all daily notes
|
||||
- **Template System**: Uses `daily-note-template.md` if present in notes directory
|
||||
- **Auto-naming**: Creates notes as `daily/YYYY-MM-DD.md` by default
|
||||
- **Visual Indicators**: Calendar highlights days with existing notes
|
||||
- **One-click Access**: Click any calendar date to open or create that day's note
|
||||
|
||||
The calendar is implemented using htmx for dynamic month navigation and rendering.
|
||||
|
||||
### Favorites System
|
||||
|
||||
**Implementation**: `internal/api/favorites.go` and `frontend/src/favorites.js`
|
||||
|
||||
The favorites system allows quick access to frequently used notes and folders:
|
||||
- **Star Icon**: Click ★ next to any note or folder in the file tree
|
||||
- **Persistence**: Favorites stored in `.favorites.json` in the notes directory
|
||||
- **Quick Access**: Starred items appear at the top of the sidebar
|
||||
- **Folder Support**: Star entire folders to quickly access project areas
|
||||
- **Visual Feedback**: Filled star (★) for favorites, empty star (☆) for non-favorites
|
||||
|
||||
Favorites are loaded on server startup and updated in real-time via htmx.
|
||||
|
||||
### Note Format
|
||||
|
||||
Notes have YAML front matter with these fields:
|
||||
@ -258,12 +320,13 @@ Output files (loaded by templates):
|
||||
- `static/dist/project-notes-frontend.umd.js` (UMD format)
|
||||
|
||||
Frontend dependencies (from `frontend/package.json`):
|
||||
- `@codemirror/basic-setup` - Base editor functionality
|
||||
- `@codemirror/lang-markdown` - Markdown language support
|
||||
- `@codemirror/state` - Editor state management
|
||||
- `@codemirror/view` - Editor view layer
|
||||
- `@codemirror/theme-one-dark` - Dark theme
|
||||
- `vite` - Build tool
|
||||
- `@codemirror/basic-setup` (^0.20.0) - Base editor functionality
|
||||
- `@codemirror/lang-markdown` (^6.5.0) - Markdown language support
|
||||
- `@codemirror/state` (^6.5.2) - Editor state management
|
||||
- `@codemirror/view` (^6.38.6) - Editor view layer
|
||||
- `@codemirror/theme-one-dark` (^6.1.3) - Dark theme
|
||||
- `@replit/codemirror-vim` (^6.2.2) - Vim mode integration
|
||||
- `vite` (^7.2.2) - Build tool
|
||||
|
||||
### Running the Server
|
||||
|
||||
@ -415,6 +478,78 @@ A modern command-palette style search modal is available:
|
||||
|
||||
**Styling**: Custom styles in `static/theme.css` with Material Darker theme integration.
|
||||
|
||||
### Theme and Font Customization
|
||||
|
||||
**Implementation**: `frontend/src/theme-manager.js` and `frontend/src/font-manager.js`
|
||||
|
||||
The application supports extensive UI customization:
|
||||
|
||||
#### Themes
|
||||
8 dark themes available via Settings (⚙️ icon):
|
||||
- **Material Dark** (default) - Material Design inspired
|
||||
- **Monokai** - Classic Monokai colors
|
||||
- **Dracula** - Popular purple-tinted theme
|
||||
- **One Dark** - Atom/VS Code inspired
|
||||
- **Solarized Dark** - Precision colors by Ethan Schoonover
|
||||
- **Nord** - Arctic, north-bluish color palette
|
||||
- **Catppuccin** - Soothing pastel theme
|
||||
- **Everforest** - Comfortable greenish theme
|
||||
|
||||
Themes are applied via CSS custom properties and persist in localStorage.
|
||||
|
||||
#### Fonts
|
||||
8 font options with 4 size presets (small, medium, large, extra-large):
|
||||
- JetBrains Mono (default)
|
||||
- Fira Code
|
||||
- Inter
|
||||
- IBM Plex Mono
|
||||
- Source Code Pro
|
||||
- Cascadia Code
|
||||
- Roboto Mono
|
||||
- Ubuntu Mono
|
||||
|
||||
Font settings apply to both the editor and preview pane.
|
||||
|
||||
### Vim Mode
|
||||
|
||||
**Implementation**: `frontend/src/vim-mode-manager.js` using `@replit/codemirror-vim`
|
||||
|
||||
Optional Vim keybindings for power users:
|
||||
- **Enable/Disable**: Toggle via Settings (⚙️ icon)
|
||||
- **Full Vim Support**: hjkl navigation, visual mode, operators, text objects
|
||||
- **Mode Indicator**: Shows current Vim mode (Normal/Insert/Visual) in editor
|
||||
- **Persistence**: Vim mode preference saved to localStorage
|
||||
- **CodeMirror Integration**: Native Vim extension with excellent compatibility
|
||||
|
||||
Vim mode users get full modal editing while maintaining CodeMirror features like syntax highlighting and auto-save.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
**Implementation**: `frontend/src/keyboard-shortcuts.js`
|
||||
|
||||
The application provides 10+ global keyboard shortcuts for efficient navigation:
|
||||
|
||||
**Essential Shortcuts**:
|
||||
- `Ctrl/Cmd+D` - Create or open today's daily note
|
||||
- `Ctrl/Cmd+K` - Open search modal
|
||||
- `Ctrl/Cmd+S` - Save current note (also triggers auto-save)
|
||||
- `Ctrl/Cmd+B` - Toggle sidebar visibility
|
||||
- `Ctrl/Cmd+/` - Show keyboard shortcuts help modal
|
||||
|
||||
**Editor Shortcuts**:
|
||||
- `Tab` - Indent (when in editor)
|
||||
- `Shift+Tab` - Outdent (when in editor)
|
||||
- `Ctrl/Cmd+Enter` - Save note (alternative to Cmd+S)
|
||||
|
||||
**Navigation**:
|
||||
- `↑`/`↓` - Navigate search results or command palette
|
||||
- `Enter` - Select/confirm action
|
||||
- `Esc` - Close modals, cancel actions, clear search
|
||||
|
||||
All shortcuts are non-blocking and work across the application. The shortcuts help modal (triggered by `Ctrl/Cmd+/`) provides a quick reference guide.
|
||||
|
||||
For complete documentation, see `docs/KEYBOARD_SHORTCUTS.md`.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
File path validation in `handler.go` and `rest_handler.go`:
|
||||
@ -509,19 +644,64 @@ Provides command palette for quick Markdown insertion:
|
||||
|
||||
The editor includes a slash command system integrated with CodeMirror 6:
|
||||
- Type `/` at the start of a line to trigger the command palette
|
||||
- Available commands (13 total):
|
||||
- Available commands (14 total):
|
||||
- **Headings**: h1, h2, h3 - Insert Markdown headers
|
||||
- **Formatting**: bold, italic, code - Text formatting
|
||||
- **Blocks**: codeblock, quote, hr, table - Block-level elements
|
||||
- **Lists**: list - Unordered list
|
||||
- **Dynamic**: date - Insert current date in French format (DD/MM/YYYY)
|
||||
- **Links**: link - Insert link template `[text](url)`
|
||||
- **Links**:
|
||||
- `link` - Insert standard Markdown link `[texte](url)`
|
||||
- `ilink` - Open internal note linking modal (see Note Linking below)
|
||||
- Navigate with Arrow Up/Down, select with Enter/Tab, cancel with Escape
|
||||
- Commands are filtered in real-time as you type after the `/`
|
||||
- The palette is positioned dynamically near the cursor using CodeMirror coordinates
|
||||
- Implementation in `frontend/src/editor.js` with the `SlashCommands` class
|
||||
- Styled command palette with gradient selection indicator
|
||||
|
||||
### Note Linking
|
||||
|
||||
**Implementation**: `frontend/src/link-inserter.js`
|
||||
|
||||
The note linking system allows users to create Markdown links between notes without leaving the editor:
|
||||
|
||||
**Activation**: Type `/ilink` (internal link) in the editor and select the command from the slash palette
|
||||
|
||||
**Features**:
|
||||
- **Fuzzy Search**: Real-time search across all notes with 200ms debounce
|
||||
- **Keyboard Navigation**: Navigate with ↑/↓, select with Enter, cancel with Esc
|
||||
- **Search Integration**: Reuses existing `/api/search` endpoint (no new backend code)
|
||||
- **Rich Results**: Shows note title, path, tags, and metadata
|
||||
- **Instant Insertion**: Inserts `[Note Title](path/to/note.md)` format at cursor position
|
||||
|
||||
**Architecture**:
|
||||
- `LinkInserter` class manages the search modal and selection
|
||||
- Opens via `SlashCommands.openLinkInserter()` when `/ilink` is triggered
|
||||
- Uses HTMX search API for consistency
|
||||
- Modal styled to match `SearchModal` design language
|
||||
|
||||
**Workflow**:
|
||||
1. User types `/ilink` → slash palette appears
|
||||
2. User selects "ilink" → modal opens with search input
|
||||
3. User types search query → fuzzy search filters notes
|
||||
4. User selects note (Enter/click) → Markdown link inserted
|
||||
5. Modal closes → editor regains focus at end of inserted link
|
||||
|
||||
**Standard Links**: For external URLs, use `/link` to insert the standard Markdown template `[texte](url)`
|
||||
|
||||
**Link Format**: Links are inserted as HTML with HTMX attributes:
|
||||
```html
|
||||
<a href="#" hx-get="/api/notes/path/to/note.md" hx-target="#editor-container" hx-swap="innerHTML">Note Title</a>
|
||||
```
|
||||
|
||||
This format:
|
||||
- **Clickable in preview**: Links open the note directly in the editor when clicked
|
||||
- **HTMX-powered**: Uses existing HTMX infrastructure (no new backend code)
|
||||
- **Inline HTML**: Marked.js renders the HTML as-is, DOMPurify sanitizes it, HTMX processes it
|
||||
- **Editable**: Plain HTML in the source, can be manually edited if needed
|
||||
|
||||
**Note**: This format is specific to this application. For compatibility with other Markdown tools, use standard Markdown links with `/link` command.
|
||||
|
||||
## Frontend Libraries
|
||||
|
||||
The application uses a mix of npm packages (for the editor) and CDN-loaded libraries (for utilities):
|
||||
@ -533,7 +713,8 @@ Managed in `frontend/package.json`:
|
||||
- **@codemirror/state (^6.5.2)**: Editor state management
|
||||
- **@codemirror/view (^6.38.6)**: Editor view layer and rendering
|
||||
- **@codemirror/theme-one-dark (^6.1.3)**: Dark theme for CodeMirror
|
||||
- **vite (^5.0.0)**: Build tool for bundling frontend modules
|
||||
- **@replit/codemirror-vim (^6.2.2)**: Vim mode integration for CodeMirror
|
||||
- **vite (^7.2.2)**: Build tool for bundling frontend modules
|
||||
|
||||
### CDN Libraries
|
||||
Loaded in `templates/index.html`:
|
||||
@ -543,14 +724,16 @@ Loaded in `templates/index.html`:
|
||||
- **Highlight.js (11.9.0)**: Syntax highlighting for code blocks in preview with Atom One Dark theme
|
||||
|
||||
### Styling
|
||||
- **Material Darker Theme**: Custom dark theme in `static/theme.css`
|
||||
- **8 Dark Themes**: Switchable themes in `static/theme.css`
|
||||
- Material Dark, Monokai, Dracula, One Dark, Solarized, Nord, Catppuccin, Everforest
|
||||
- **Color System**: CSS custom properties for consistent theming
|
||||
- Background colors: `--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--bg-elevated`
|
||||
- Text colors: `--text-primary`, `--text-secondary`, `--text-muted`
|
||||
- Accent colors: `--accent-blue`, `--accent-violet`
|
||||
- **Font Customization**: 8 font families with 4 size presets
|
||||
- **No CSS Framework**: All styles hand-crafted with CSS Grid and Flexbox
|
||||
- **Responsive Design**: Adaptive layout for different screen sizes
|
||||
- **Custom Scrollbars**: Styled scrollbars matching the dark theme
|
||||
- **Custom Scrollbars**: Styled scrollbars matching the current theme
|
||||
|
||||
### Build Output
|
||||
The Vite build process produces:
|
||||
@ -566,17 +749,28 @@ project-notes/
|
||||
│ └── main.go # Server entry point
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ └── handler.go # HTTP handlers for CRUD operations
|
||||
│ │ ├── handler.go # HTTP handlers for CRUD operations
|
||||
│ │ ├── rest_handler.go # REST API v1 endpoints
|
||||
│ │ ├── daily_notes.go # Daily notes functionality
|
||||
│ │ └── favorites.go # Favorites management
|
||||
│ ├── indexer/
|
||||
│ │ └── indexer.go # Note indexing and search
|
||||
│ │ ├── indexer.go # Note indexing and search
|
||||
│ │ └── indexer_test.go # Indexer tests
|
||||
│ └── watcher/
|
||||
│ └── watcher.go # Filesystem watcher with fsnotify
|
||||
├── frontend/ # Frontend build system (NEW)
|
||||
├── frontend/ # Frontend build system
|
||||
│ ├── src/
|
||||
│ │ ├── main.js # Entry point
|
||||
│ │ ├── editor.js # CodeMirror 6 implementation (26 KB)
|
||||
│ │ ├── file-tree.js # Drag-and-drop file management (11 KB)
|
||||
│ │ └── ui.js # Sidebar toggle (720 B)
|
||||
│ │ ├── main.js # Entry point - imports all modules
|
||||
│ │ ├── editor.js # CodeMirror 6 editor with slash commands
|
||||
│ │ ├── vim-mode-manager.js # Vim mode integration
|
||||
│ │ ├── search.js # Search modal (Ctrl/Cmd+K)
|
||||
│ │ ├── file-tree.js # Drag-and-drop file tree
|
||||
│ │ ├── favorites.js # Favorites system
|
||||
│ │ ├── daily-notes.js # Daily notes and calendar widget
|
||||
│ │ ├── keyboard-shortcuts.js # Global keyboard shortcuts
|
||||
│ │ ├── theme-manager.js # Theme switching
|
||||
│ │ ├── font-manager.js # Font customization
|
||||
│ │ └── ui.js # Sidebar toggle
|
||||
│ ├── package.json # NPM dependencies
|
||||
│ ├── package-lock.json
|
||||
│ └── vite.config.js # Vite build configuration
|
||||
@ -592,9 +786,18 @@ project-notes/
|
||||
│ ├── search-results.html # Search results
|
||||
│ └── new-note-prompt.html # New note modal
|
||||
├── notes/ # Note storage directory
|
||||
│ └── *.md # Markdown files with YAML front matter
|
||||
│ ├── *.md # Markdown files with YAML front matter
|
||||
│ ├── daily/ # Daily notes (YYYY-MM-DD.md)
|
||||
│ ├── .favorites.json # Favorites list (auto-generated)
|
||||
│ └── daily-note-template.md # Optional daily note template
|
||||
├── docs/ # Documentation
|
||||
│ ├── KEYBOARD_SHORTCUTS.md # Keyboard shortcuts reference
|
||||
│ ├── DAILY_NOTES.md # Daily notes guide
|
||||
│ ├── USAGE_GUIDE.md # Complete usage guide
|
||||
│ └── FREEBSD_BUILD.md # FreeBSD build guide
|
||||
├── go.mod # Go dependencies
|
||||
├── go.sum
|
||||
├── API.md # REST API documentation
|
||||
└── CLAUDE.md # This file
|
||||
```
|
||||
|
||||
@ -602,15 +805,26 @@ project-notes/
|
||||
|
||||
**Backend Development**:
|
||||
- `cmd/server/main.go` - Server initialization and routing
|
||||
- `internal/api/handler.go` - API endpoints and request handling
|
||||
- `internal/api/handler.go` - Main HTML endpoints and request handling
|
||||
- `internal/api/rest_handler.go` - REST API v1 endpoints
|
||||
- `internal/api/daily_notes.go` - Daily notes and calendar functionality
|
||||
- `internal/api/favorites.go` - Favorites management
|
||||
- `internal/indexer/indexer.go` - Search and indexing logic
|
||||
- `internal/watcher/watcher.go` - Filesystem monitoring
|
||||
|
||||
**Frontend Development**:
|
||||
- `frontend/src/editor.js` - CodeMirror editor, preview, slash commands
|
||||
- `frontend/src/vim-mode-manager.js` - Vim mode integration
|
||||
- `frontend/src/search.js` - Search modal functionality
|
||||
- `frontend/src/link-inserter.js` - Note linking modal for `/link` command
|
||||
- `frontend/src/file-tree.js` - File tree interactions and drag-and-drop
|
||||
- `frontend/src/favorites.js` - Favorites system
|
||||
- `frontend/src/daily-notes.js` - Daily notes creation and calendar widget
|
||||
- `frontend/src/keyboard-shortcuts.js` - Global keyboard shortcuts
|
||||
- `frontend/src/theme-manager.js` - Theme switching logic
|
||||
- `frontend/src/font-manager.js` - Font customization logic
|
||||
- `frontend/src/ui.js` - UI utilities (sidebar toggle)
|
||||
- `static/theme.css` - Styling and theming
|
||||
- `static/theme.css` - Styling and theming (8 themes)
|
||||
- `templates/*.html` - HTML templates (Go template syntax)
|
||||
|
||||
**Configuration**:
|
||||
|
||||
20
README.md
20
README.md
@ -3,17 +3,17 @@
|
||||
A lightweight, web-based Markdown note-taking application with a Go backend and a minimalist frontend built with htmx.
|
||||
|
||||
- 🚫 No database
|
||||
- 📝 Flat files : Markdown with frontmatters
|
||||
- 📝 Flat files: Markdown with front matter
|
||||
- 🔒 Your notes, your application, your server, your data
|
||||
- ⌨️ Vim Mode
|
||||
- 🎹 Keyboard driven with shortcut and "/" command
|
||||
- 🎹 Keyboard driven with shortcuts and "/" commands
|
||||
- 🔍 Powerful Search
|
||||
- 🌍 Run everywhere (Linux & FreeBSD)
|
||||
- 📱 Responsive with laptop and smartphone
|
||||
- 📱 Responsive on laptop and smartphone
|
||||
- 🛠️ Super Easy to build
|
||||
- 🚀 A powerful API
|
||||
- 🚀 Powerful REST API
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
## Features
|
||||
@ -39,11 +39,11 @@ A lightweight, web-based Markdown note-taking application with a Go backend and
|
||||
* **Lightweight Frontend:** Built with htmx for dynamic interactions, minimizing JavaScript complexity.
|
||||
* **Go Backend:** Fast and efficient Go server handles file operations, indexing, and serving the frontend.
|
||||
|
||||
## Road Map
|
||||
## Roadmap
|
||||
|
||||
- Share notes in markdown / pdf
|
||||
- Publics notes.
|
||||
- Secure by user/password (You can use Authelia/Authentik for now)
|
||||
- Share notes as Markdown/PDF exports
|
||||
- Public notes
|
||||
- User authentication (use Authelia/Authentik for now)
|
||||
|
||||
|
||||
## Getting Started
|
||||
@ -158,7 +158,7 @@ go run ./cmd/server -addr :3000 -notes-dir ~/my-notes
|
||||
|
||||
1. **Create your first note**: Press `Ctrl/Cmd+D` to open today's daily note
|
||||
2. **Start writing**: Use the Markdown editor with live preview
|
||||
3. **Save**: Press `Ctrl/Cmd+S` or click "Enregistrer"
|
||||
3. **Save**: Press `Ctrl/Cmd+S` or click "Save"
|
||||
4. **Search**: Press `Ctrl/Cmd+K` to find any note instantly
|
||||
5. **Customize**: Click ⚙️ to choose themes, fonts, and enable Vim mode
|
||||
|
||||
|
||||
127
docs/SIDEBAR_RESIZE_TEST.md
Normal file
127
docs/SIDEBAR_RESIZE_TEST.md
Normal file
@ -0,0 +1,127 @@
|
||||
# Test de la Sidebar Redimensionnable
|
||||
|
||||
## Étapes pour tester
|
||||
|
||||
### 1. Redémarrer le serveur
|
||||
|
||||
```bash
|
||||
# Arrêter le serveur actuel (Ctrl+C)
|
||||
# Puis relancer :
|
||||
cd /home/mathieu/git/project-notes
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
### 2. Ouvrir l'application
|
||||
|
||||
```
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
### 3. Tester le redimensionnement
|
||||
|
||||
1. **Ouvrez la console développeur** (F12) pour voir les logs
|
||||
2. Vous devriez voir : `Sidebar resize initialized`
|
||||
3. **Survolez le bord droit de la sidebar** (zone de 4px)
|
||||
- Le curseur devrait devenir `↔` (resize cursor)
|
||||
- Une fine ligne bleue devrait apparaître
|
||||
4. **Cliquez et glissez** vers la droite ou la gauche
|
||||
5. **Relâchez** pour sauvegarder la largeur
|
||||
6. **Rechargez la page** (F5) - la largeur devrait être restaurée
|
||||
|
||||
### 4. Tests de limites
|
||||
|
||||
- **Minimum** : Essayez de réduire en dessous de 200px (bloqué)
|
||||
- **Maximum** : Essayez d'agrandir au-delà de 600px (bloqué)
|
||||
|
||||
### 5. Reset (si nécessaire)
|
||||
|
||||
Dans la console développeur :
|
||||
```javascript
|
||||
resetSidebarWidth()
|
||||
```
|
||||
|
||||
## Vérifications si ça ne fonctionne pas
|
||||
|
||||
### 1. Le script se charge-t-il ?
|
||||
|
||||
Dans la console développeur (F12), onglet Network :
|
||||
- Cherchez `sidebar-resize.js`
|
||||
- Status devrait être `200 OK`
|
||||
- Si `404`, le serveur ne sert pas le fichier
|
||||
|
||||
### 2. Y a-t-il des erreurs JavaScript ?
|
||||
|
||||
Dans la console développeur (F12), onglet Console :
|
||||
- Cherchez des erreurs en rouge
|
||||
- Vous devriez voir : `Sidebar resize initialized`
|
||||
- Si vous voyez `Sidebar not found`, le sélecteur `#sidebar` ne trouve pas l'élément
|
||||
|
||||
### 3. La poignée est-elle créée ?
|
||||
|
||||
Dans la console développeur (F12), onglet Elements/Inspecteur :
|
||||
- Sélectionnez `<aside id="sidebar">`
|
||||
- À l'intérieur, en bas, il devrait y avoir : `<div class="sidebar-resize-handle" title="Drag to resize sidebar"></div>`
|
||||
|
||||
### 4. Les styles CSS sont-ils appliqués ?
|
||||
|
||||
Dans la console développeur, inspectez `.sidebar-resize-handle` :
|
||||
- `width: 4px`
|
||||
- `cursor: ew-resize`
|
||||
- `position: absolute`
|
||||
|
||||
## Débogage avancé
|
||||
|
||||
### Test manuel du script
|
||||
|
||||
Dans la console développeur :
|
||||
```javascript
|
||||
// Vérifier que la sidebar existe
|
||||
document.querySelector('#sidebar')
|
||||
|
||||
// Vérifier que la poignée existe
|
||||
document.querySelector('.sidebar-resize-handle')
|
||||
|
||||
// Tester le redimensionnement manuel
|
||||
const sidebar = document.querySelector('#sidebar');
|
||||
sidebar.style.width = '400px';
|
||||
document.querySelector('main').style.marginLeft = '400px';
|
||||
```
|
||||
|
||||
### Test de sauvegarde localStorage
|
||||
|
||||
```javascript
|
||||
// Sauvegarder une largeur
|
||||
localStorage.setItem('sidebar-width', '400');
|
||||
|
||||
// Lire la largeur sauvegardée
|
||||
localStorage.getItem('sidebar-width');
|
||||
|
||||
// Effacer
|
||||
localStorage.removeItem('sidebar-width');
|
||||
```
|
||||
|
||||
## Problèmes connus et solutions
|
||||
|
||||
### La poignée n'apparaît pas
|
||||
- **Cause** : Styles CSS non chargés
|
||||
- **Solution** : Vider le cache (Ctrl+Shift+R) et recharger
|
||||
|
||||
### Le curseur ne change pas
|
||||
- **Cause** : Z-index trop bas
|
||||
- **Solution** : Vérifier que `.sidebar-resize-handle` a `z-index: 11`
|
||||
|
||||
### Le resize ne fonctionne pas sur mobile
|
||||
- **Normal** : Désactivé volontairement sur mobile (< 768px)
|
||||
- La sidebar est fixe à 280px sur mobile
|
||||
|
||||
### La sidebar saute au resize
|
||||
- **Cause** : Transition CSS qui interfère
|
||||
- **Solution** : Désactiver temporairement `transition` pendant le resize (déjà fait dans le CSS)
|
||||
|
||||
## Support
|
||||
|
||||
Si ça ne fonctionne toujours pas après ces tests, fournir :
|
||||
1. Screenshot de la console (onglet Console)
|
||||
2. Screenshot de la console (onglet Network, fichier sidebar-resize.js)
|
||||
3. Version du navigateur
|
||||
4. Si mobile ou desktop
|
||||
@ -5,6 +5,7 @@ import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { LinkInserter } from './link-inserter.js';
|
||||
|
||||
// Import du mode Vim
|
||||
let vimExtension = null;
|
||||
@ -254,6 +255,9 @@ class MarkdownEditor {
|
||||
htmx.process(this.preview);
|
||||
}
|
||||
|
||||
// Intercepter les clics sur les liens internes (avec hx-get)
|
||||
this.setupInternalLinkHandlers();
|
||||
|
||||
if (typeof hljs !== 'undefined') {
|
||||
this.preview.querySelectorAll('pre code').forEach(block => {
|
||||
hljs.highlightElement(block);
|
||||
@ -264,6 +268,41 @@ class MarkdownEditor {
|
||||
}
|
||||
}
|
||||
|
||||
setupInternalLinkHandlers() {
|
||||
// Trouver tous les liens avec hx-get (liens internes)
|
||||
const internalLinks = this.preview.querySelectorAll('a[hx-get]');
|
||||
|
||||
internalLinks.forEach(link => {
|
||||
// Retirer les anciens listeners pour éviter les doublons
|
||||
link.replaceWith(link.cloneNode(true));
|
||||
});
|
||||
|
||||
// Ré-sélectionner après clonage
|
||||
const freshLinks = this.preview.querySelectorAll('a[hx-get]');
|
||||
|
||||
freshLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const target = link.getAttribute('hx-get');
|
||||
const targetElement = link.getAttribute('hx-target') || '#editor-container';
|
||||
const swapMethod = link.getAttribute('hx-swap') || 'innerHTML';
|
||||
|
||||
console.log('[InternalLink] Clicked:', target);
|
||||
|
||||
if (target && typeof htmx !== 'undefined') {
|
||||
htmx.ajax('GET', target, {
|
||||
target: targetElement,
|
||||
swap: swapMethod
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('[Preview] Setup', freshLinks.length, 'internal link handlers');
|
||||
}
|
||||
|
||||
syncToTextarea() {
|
||||
if (this.editorView && this.textarea) {
|
||||
this.textarea.value = this.editorView.state.doc.toString();
|
||||
@ -332,6 +371,7 @@ class SlashCommands {
|
||||
{ name: 'list', snippet: '- ' },
|
||||
{ name: 'date', snippet: () => new Date().toLocaleDateString('fr-FR') },
|
||||
{ name: 'link', snippet: '[texte](url)' },
|
||||
{ name: 'ilink', isModal: true, handler: () => this.openLinkInserter() },
|
||||
{ name: 'bold', snippet: '**texte**' },
|
||||
{ name: 'italic', snippet: '*texte*' },
|
||||
{ name: 'code', snippet: '`code`' },
|
||||
@ -612,6 +652,15 @@ class SlashCommands {
|
||||
return;
|
||||
}
|
||||
|
||||
// Commande spéciale avec modal (comme /ilink)
|
||||
if (command.isModal && command.handler) {
|
||||
console.log('Executing modal command:', command.name);
|
||||
// NE PAS cacher la palette tout de suite car le handler a besoin de slashPos
|
||||
// La palette sera cachée par le handler lui-même
|
||||
command.handler();
|
||||
return;
|
||||
}
|
||||
|
||||
let snippet = command.snippet;
|
||||
if (typeof snippet === 'function') {
|
||||
snippet = snippet();
|
||||
@ -632,6 +681,59 @@ class SlashCommands {
|
||||
this.hidePalette();
|
||||
}
|
||||
|
||||
openLinkInserter() {
|
||||
// Sauvegarder la position du slash IMMÉDIATEMENT avant toute autre opération
|
||||
const savedSlashPos = this.slashPos;
|
||||
|
||||
console.log('[SlashCommands] openLinkInserter - savedSlashPos:', savedSlashPos);
|
||||
|
||||
if (!savedSlashPos) {
|
||||
console.error('[SlashCommands] No slash position available!');
|
||||
this.hidePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
// Maintenant on peut cacher la palette en toute sécurité
|
||||
this.hidePalette();
|
||||
|
||||
// S'assurer que le LinkInserter global existe, le créer si nécessaire
|
||||
if (!window.linkInserter) {
|
||||
console.log('Initializing LinkInserter...');
|
||||
window.linkInserter = new LinkInserter();
|
||||
}
|
||||
|
||||
// Ouvrir le modal de sélection de lien
|
||||
window.linkInserter.open({
|
||||
editorView: this.editorView,
|
||||
onSelect: ({ title, path }) => {
|
||||
console.log('[SlashCommands] onSelect callback received:', { title, path });
|
||||
console.log('[SlashCommands] savedSlashPos:', savedSlashPos);
|
||||
|
||||
// Créer un lien HTMX cliquable dans le preview
|
||||
// Format : <a href="#" onclick="return false;" hx-get="/api/notes/path" hx-target="#editor-container" hx-swap="innerHTML">Title</a>
|
||||
// Le onclick="return false;" empêche le comportement par défaut du # qui pourrait rediriger
|
||||
const linkHtml = `<a href="#" onclick="return false;" hx-get="/api/notes/${path}" hx-target="#editor-container" hx-swap="innerHTML">${title}</a>`;
|
||||
console.log('[SlashCommands] Inserting:', linkHtml);
|
||||
|
||||
const { state, dispatch } = this.editorView;
|
||||
const { from } = state.selection.main;
|
||||
|
||||
// Remplacer depuis le "/" jusqu'au curseur actuel
|
||||
const replaceFrom = savedSlashPos.absolutePos;
|
||||
|
||||
console.log('[SlashCommands] Replacing from', replaceFrom, 'to', from);
|
||||
|
||||
dispatch(state.update({
|
||||
changes: { from: replaceFrom, to: from, insert: linkHtml },
|
||||
selection: { anchor: replaceFrom + linkHtml.length }
|
||||
}));
|
||||
|
||||
this.editorView.focus();
|
||||
console.log('[SlashCommands] Link inserted successfully');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Retirer tous les listeners d'événements
|
||||
if (this.editorView) {
|
||||
|
||||
398
frontend/src/link-inserter.js
Normal file
398
frontend/src/link-inserter.js
Normal file
@ -0,0 +1,398 @@
|
||||
/**
|
||||
* LinkInserter - Modal de recherche pour insérer des liens vers d'autres notes
|
||||
* Intégré dans l'éditeur CodeMirror 6
|
||||
*/
|
||||
class LinkInserter {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
this.input = null;
|
||||
this.resultsContainer = null;
|
||||
this.isOpen = false;
|
||||
this.searchTimeout = null;
|
||||
this.selectedIndex = 0;
|
||||
this.results = [];
|
||||
this.callback = null; // Fonction appelée quand un lien est sélectionné
|
||||
this.editorView = null; // Référence à l'instance CodeMirror
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createModal();
|
||||
}
|
||||
|
||||
createModal() {
|
||||
// Créer la modale (plus compacte que SearchModal)
|
||||
this.modal = document.createElement('div');
|
||||
this.modal.id = 'link-inserter-modal';
|
||||
this.modal.className = 'link-inserter-modal';
|
||||
this.modal.style.display = 'none';
|
||||
|
||||
this.modal.innerHTML = `
|
||||
<div class="link-inserter-overlay"></div>
|
||||
<div class="link-inserter-container">
|
||||
<div class="link-inserter-header">
|
||||
<div class="link-inserter-input-wrapper">
|
||||
<svg class="link-inserter-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="link-inserter-input"
|
||||
placeholder="Rechercher une note à lier..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<kbd class="link-inserter-kbd">ESC</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-inserter-body">
|
||||
<div class="link-inserter-results">
|
||||
<div class="link-inserter-help">
|
||||
<div class="link-inserter-help-text">
|
||||
🔗 Tapez pour rechercher une note
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-inserter-footer">
|
||||
<div class="link-inserter-footer-hint">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> Navigation
|
||||
<kbd>↵</kbd> Insérer
|
||||
<kbd>ESC</kbd> Annuler
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(this.modal);
|
||||
|
||||
// Références aux éléments
|
||||
this.input = this.modal.querySelector('.link-inserter-input');
|
||||
this.resultsContainer = this.modal.querySelector('.link-inserter-results');
|
||||
|
||||
// Event listeners
|
||||
this.modal.querySelector('.link-inserter-overlay').addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
this.input.addEventListener('input', (e) => {
|
||||
this.handleSearch(e.target.value);
|
||||
});
|
||||
|
||||
this.input.addEventListener('keydown', (e) => {
|
||||
this.handleKeyNavigation(e);
|
||||
});
|
||||
}
|
||||
|
||||
handleSearch(query) {
|
||||
// Debounce de 200ms (plus rapide que SearchModal)
|
||||
clearTimeout(this.searchTimeout);
|
||||
|
||||
if (!query.trim()) {
|
||||
this.showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading();
|
||||
|
||||
this.searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
// Utiliser l'API de recherche existante
|
||||
const response = await fetch(`/api/search?query=${encodeURIComponent(query)}`);
|
||||
const html = await response.text();
|
||||
|
||||
// Parser le HTML pour extraire les résultats
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// Extraire les liens de résultats
|
||||
const resultLinks = doc.querySelectorAll('.search-result-link');
|
||||
this.results = Array.from(resultLinks).map((link, index) => {
|
||||
const title = link.querySelector('.search-result-title')?.textContent || 'Sans titre';
|
||||
const path = link.getAttribute('hx-get')?.replace('/api/notes/', '') || '';
|
||||
const tags = Array.from(link.querySelectorAll('.tag-pill')).map(t => t.textContent);
|
||||
const pathDisplay = link.querySelector('.search-result-path')?.textContent || '';
|
||||
|
||||
return {
|
||||
index,
|
||||
title: title.trim(),
|
||||
path: path.trim(),
|
||||
pathDisplay: pathDisplay.trim(),
|
||||
tags
|
||||
};
|
||||
});
|
||||
|
||||
if (this.results.length > 0) {
|
||||
this.renderResults(query);
|
||||
} else {
|
||||
this.showNoResults(query);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[LinkInserter] Erreur de recherche:', error);
|
||||
this.showError();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
handleKeyNavigation(event) {
|
||||
console.log('[LinkInserter] Key pressed:', event.key, 'Results:', this.results.length);
|
||||
|
||||
if (this.results.length === 0) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
console.log('[LinkInserter] Arrow Down - moving to index:', this.selectedIndex + 1);
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
|
||||
this.updateSelection();
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
console.log('[LinkInserter] Arrow Up - moving to index:', this.selectedIndex - 1);
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.updateSelection();
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
console.log('[LinkInserter] Enter pressed - calling selectResult()');
|
||||
event.preventDefault();
|
||||
this.selectResult();
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
console.log('[LinkInserter] Escape pressed - closing modal');
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateSelection() {
|
||||
const resultItems = this.resultsContainer.querySelectorAll('.link-inserter-result-item');
|
||||
resultItems.forEach((item, index) => {
|
||||
if (index === this.selectedIndex) {
|
||||
item.classList.add('selected');
|
||||
item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
} else {
|
||||
item.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectResult() {
|
||||
console.log('[LinkInserter] selectResult called, results:', this.results.length);
|
||||
|
||||
if (this.results.length === 0) {
|
||||
console.warn('[LinkInserter] No results to select');
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = this.results[this.selectedIndex];
|
||||
console.log('[LinkInserter] Selected:', selected);
|
||||
console.log('[LinkInserter] Callback exists:', !!this.callback);
|
||||
|
||||
if (selected && this.callback) {
|
||||
console.log('[LinkInserter] Calling callback with:', { title: selected.title, path: selected.path });
|
||||
|
||||
// Sauvegarder le callback localement avant de fermer
|
||||
const callback = this.callback;
|
||||
|
||||
// Fermer le modal d'abord
|
||||
this.close();
|
||||
|
||||
// Puis appeler le callback après un petit délai pour que le modal se ferme proprement
|
||||
setTimeout(() => {
|
||||
console.log('[LinkInserter] Executing callback now...');
|
||||
callback({
|
||||
title: selected.title,
|
||||
path: selected.path
|
||||
});
|
||||
}, 50);
|
||||
} else {
|
||||
console.error('[LinkInserter] Cannot select: no callback or no selected item');
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
renderResults(query) {
|
||||
this.resultsContainer.innerHTML = '';
|
||||
this.selectedIndex = 0;
|
||||
|
||||
const resultsHeader = document.createElement('div');
|
||||
resultsHeader.className = 'link-inserter-results-header';
|
||||
resultsHeader.innerHTML = `
|
||||
<span class="link-inserter-results-count">${this.results.length} note${this.results.length > 1 ? 's' : ''}</span>
|
||||
`;
|
||||
this.resultsContainer.appendChild(resultsHeader);
|
||||
|
||||
this.results.forEach((result, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'link-inserter-result-item';
|
||||
if (index === 0) item.classList.add('selected');
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="link-inserter-result-icon">📄</div>
|
||||
<div class="link-inserter-result-content">
|
||||
<div class="link-inserter-result-title">${this.highlightQuery(result.title, query)}</div>
|
||||
<div class="link-inserter-result-path">${result.pathDisplay}</div>
|
||||
${result.tags.length > 0 ? `
|
||||
<div class="link-inserter-result-tags">
|
||||
${result.tags.map(tag => `<span class="tag-pill-small">${tag}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Click handler
|
||||
item.addEventListener('click', (e) => {
|
||||
console.log('[LinkInserter] Item clicked, index:', index);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.selectedIndex = index;
|
||||
this.selectResult();
|
||||
});
|
||||
|
||||
// Hover handler
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectedIndex = index;
|
||||
this.updateSelection();
|
||||
});
|
||||
|
||||
this.resultsContainer.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
highlightQuery(text, query) {
|
||||
if (!query || !text) return text;
|
||||
|
||||
const terms = query.split(/\s+/)
|
||||
.filter(term => !term.includes(':'))
|
||||
.map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
|
||||
if (terms.length === 0) return text;
|
||||
|
||||
const regex = new RegExp(`(${terms.join('|')})`, 'gi');
|
||||
return text.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
showHelp() {
|
||||
this.results = [];
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="link-inserter-help">
|
||||
<div class="link-inserter-help-text">
|
||||
🔗 Tapez pour rechercher une note
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="link-inserter-loading">
|
||||
<div class="link-inserter-spinner"></div>
|
||||
<p>Recherche...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showNoResults(query) {
|
||||
this.results = [];
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="link-inserter-no-results">
|
||||
<div class="link-inserter-no-results-icon">🔍</div>
|
||||
<p>Aucune note trouvée pour « <strong>${this.escapeHtml(query)}</strong> »</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showError() {
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="link-inserter-error">
|
||||
<div class="link-inserter-error-icon">⚠️</div>
|
||||
<p>Erreur lors de la recherche</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvrir le modal de sélection de lien
|
||||
* @param {Object} options - Options d'ouverture
|
||||
* @param {EditorView} options.editorView - Instance CodeMirror
|
||||
* @param {Function} options.onSelect - Callback appelé avec {title, path}
|
||||
*/
|
||||
open({ editorView, onSelect }) {
|
||||
console.log('[LinkInserter] open() called with callback:', !!onSelect);
|
||||
|
||||
if (this.isOpen) return;
|
||||
|
||||
this.editorView = editorView;
|
||||
this.callback = onSelect;
|
||||
this.isOpen = true;
|
||||
this.modal.style.display = 'flex';
|
||||
|
||||
// Animation
|
||||
requestAnimationFrame(() => {
|
||||
this.modal.classList.add('active');
|
||||
});
|
||||
|
||||
// Focus sur l'input
|
||||
setTimeout(() => {
|
||||
this.input.focus();
|
||||
this.input.select();
|
||||
}, 100);
|
||||
|
||||
// Reset
|
||||
this.selectedIndex = 0;
|
||||
this.showHelp();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
this.isOpen = false;
|
||||
this.modal.classList.remove('active');
|
||||
|
||||
setTimeout(() => {
|
||||
this.modal.style.display = 'none';
|
||||
this.input.value = '';
|
||||
this.results = [];
|
||||
this.callback = null;
|
||||
this.editorView = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.modal && this.modal.parentNode) {
|
||||
this.modal.parentNode.removeChild(this.modal);
|
||||
}
|
||||
|
||||
this.modal = null;
|
||||
this.input = null;
|
||||
this.resultsContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance globale
|
||||
window.linkInserter = null;
|
||||
|
||||
// Initialisation automatique
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.linkInserter = new LinkInserter();
|
||||
});
|
||||
|
||||
export { LinkInserter };
|
||||
@ -3,3 +3,4 @@ import './file-tree.js';
|
||||
import './ui.js';
|
||||
import './search.js';
|
||||
import './daily-notes.js';
|
||||
import './link-inserter.js';
|
||||
|
||||
@ -27,6 +27,12 @@ type TreeNode struct {
|
||||
Children []*TreeNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// BacklinkInfo représente une note qui référence la note courante
|
||||
type BacklinkInfo struct {
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// Handler gère toutes les routes de l'API.
|
||||
type Handler struct {
|
||||
notesDir string
|
||||
@ -696,14 +702,20 @@ func (h *Handler) handleGetNote(w http.ResponseWriter, r *http.Request, filename
|
||||
content = []byte(initialContent)
|
||||
}
|
||||
|
||||
// Récupérer les backlinks pour cette note
|
||||
backlinks := h.idx.GetBacklinks(filename)
|
||||
backlinkData := h.buildBacklinkData(backlinks)
|
||||
|
||||
data := struct {
|
||||
Filename string
|
||||
Content string
|
||||
IsHome bool
|
||||
Filename string
|
||||
Content string
|
||||
IsHome bool
|
||||
Backlinks []BacklinkInfo
|
||||
}{
|
||||
Filename: filename,
|
||||
Content: string(content),
|
||||
IsHome: false,
|
||||
Filename: filename,
|
||||
Content: string(content),
|
||||
IsHome: false,
|
||||
Backlinks: backlinkData,
|
||||
}
|
||||
|
||||
err = h.templates.ExecuteTemplate(w, "editor.html", data)
|
||||
@ -1135,3 +1147,35 @@ func (h *Handler) removeEmptyDirRecursive(relPath string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildBacklinkData transforme une liste de chemins de notes en BacklinkInfo avec titres
|
||||
func (h *Handler) buildBacklinkData(paths []string) []BacklinkInfo {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]BacklinkInfo, 0, len(paths))
|
||||
|
||||
for _, path := range paths {
|
||||
// Lire le fichier pour extraire le titre du front matter
|
||||
fullPath := filepath.Join(h.notesDir, path)
|
||||
fm, _, err := indexer.ExtractFrontMatterAndBody(fullPath)
|
||||
|
||||
title := ""
|
||||
if err == nil && fm.Title != "" {
|
||||
title = fm.Title
|
||||
} else {
|
||||
// Fallback: dériver le titre du nom de fichier
|
||||
title = strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
title = strings.ReplaceAll(title, "-", " ")
|
||||
title = strings.Title(title)
|
||||
}
|
||||
|
||||
result = append(result, BacklinkInfo{
|
||||
Path: path,
|
||||
Title: title,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -17,9 +18,10 @@ import (
|
||||
|
||||
// Indexer maintient un index en memoire des tags associes aux fichiers Markdown.
|
||||
type Indexer struct {
|
||||
mu sync.RWMutex
|
||||
tags map[string][]string
|
||||
docs map[string]*Document
|
||||
mu sync.RWMutex
|
||||
tags map[string][]string
|
||||
docs map[string]*Document
|
||||
backlinks map[string][]string // note path -> list of notes that reference it
|
||||
}
|
||||
|
||||
// Document représente une note indexée pour la recherche.
|
||||
@ -51,8 +53,9 @@ type SearchResult struct {
|
||||
// New cree une nouvelle instance d Indexer.
|
||||
func New() *Indexer {
|
||||
return &Indexer{
|
||||
tags: make(map[string][]string),
|
||||
docs: make(map[string]*Document),
|
||||
tags: make(map[string][]string),
|
||||
docs: make(map[string]*Document),
|
||||
backlinks: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,9 +115,31 @@ func (i *Indexer) Load(root string) error {
|
||||
indexed[tag] = list
|
||||
}
|
||||
|
||||
// Build backlinks index
|
||||
backlinksMap := make(map[string][]string)
|
||||
for sourcePath, doc := range documents {
|
||||
links := extractInternalLinks(doc.Body)
|
||||
for _, targetPath := range links {
|
||||
// Add sourcePath to the backlinks of targetPath
|
||||
if _, ok := backlinksMap[targetPath]; !ok {
|
||||
backlinksMap[targetPath] = make([]string, 0)
|
||||
}
|
||||
// Avoid duplicates
|
||||
if !containsString(backlinksMap[targetPath], sourcePath) {
|
||||
backlinksMap[targetPath] = append(backlinksMap[targetPath], sourcePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort backlinks for consistency
|
||||
for _, links := range backlinksMap {
|
||||
sort.Strings(links)
|
||||
}
|
||||
|
||||
i.mu.Lock()
|
||||
i.tags = indexed
|
||||
i.docs = documents
|
||||
i.backlinks = backlinksMap
|
||||
i.mu.Unlock()
|
||||
|
||||
return nil
|
||||
@ -668,3 +693,56 @@ func (i *Indexer) GetAllTagsWithCount() []TagCount {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetBacklinks retourne la liste des notes qui référencent la note spécifiée
|
||||
func (i *Indexer) GetBacklinks(path string) []string {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
|
||||
links, ok := i.backlinks[path]
|
||||
if !ok || len(links) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retourner une copie pour éviter les modifications externes
|
||||
result := make([]string, len(links))
|
||||
copy(result, links)
|
||||
return result
|
||||
}
|
||||
|
||||
// extractInternalLinks extrait tous les liens internes d'un texte Markdown/HTML
|
||||
// Format: <a ... hx-get="/api/notes/path/to/note.md" ...>
|
||||
func extractInternalLinks(body string) []string {
|
||||
// Pattern pour capturer le chemin dans hx-get="/api/notes/..."
|
||||
// On cherche: hx-get="/api/notes/ suivi de n'importe quoi jusqu'au prochain guillemet
|
||||
pattern := `hx-get="/api/notes/([^"]+)"`
|
||||
|
||||
// Compiler la regex
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trouver tous les matches
|
||||
matches := re.FindAllStringSubmatch(body, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extraire les chemins (groupe de capture 1)
|
||||
links := make([]string, 0, len(matches))
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
path := match[1]
|
||||
// Éviter les doublons
|
||||
if _, ok := seen[path]; !ok {
|
||||
seen[path] = struct{}{}
|
||||
links = append(links, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Book Notes
|
||||
date: 10-11-2025
|
||||
last_modified: 11-11-2025:15:23
|
||||
last_modified: 11-11-2025:18:07
|
||||
tags:
|
||||
- personal
|
||||
- notes
|
||||
@ -25,5 +25,4 @@ Key takeaways:
|
||||
- The Mom Test - Rob Fitzpatrick
|
||||
- Shape Up - Basecamp
|
||||
|
||||
|
||||
/""
|
||||
[texte](/notes/)
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: AI Writing Assistant
|
||||
date: 10-11-2025
|
||||
last_modified: 11-11-2025:11:13
|
||||
last_modified: 11-11-2025:17:56
|
||||
tags:
|
||||
- idea
|
||||
- ai
|
||||
@ -28,3 +28,5 @@ Intégrer un assistant IA pour:
|
||||
Données restent locales, API optionnelle.
|
||||
|
||||
Test test
|
||||
|
||||
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
---
|
||||
title: "Authentication Guide"
|
||||
date: "10-11-2025"
|
||||
last_modified: "10-11-2025:19:21"
|
||||
tags: ["documentation", "api", "security"]
|
||||
title: Authentication Guide
|
||||
date: 10-11-2025
|
||||
last_modified: 11-11-2025:18:30
|
||||
tags:
|
||||
- documentation
|
||||
- api
|
||||
- security
|
||||
---
|
||||
|
||||
# Authentication
|
||||
@ -39,3 +42,5 @@ Authorization: Bearer eyJhbGc...
|
||||
- HTTPS only in production
|
||||
- Reverse proxy with nginx
|
||||
- Rate limiting
|
||||
|
||||
<a href="#" onclick="return false;" hx-get="/api/notes/test-delete-1.md" hx-target="#editor-container" hx-swap="innerHTML">Test Delete 1</a>
|
||||
@ -1,8 +1,10 @@
|
||||
---
|
||||
title: "Archived Ideas"
|
||||
date: "10-11-2025"
|
||||
last_modified: "10-11-2025:19:21"
|
||||
tags: ["archive", "ideas"]
|
||||
title: Archived Ideas
|
||||
date: 10-11-2025
|
||||
last_modified: 11-11-2025:18:24
|
||||
tags:
|
||||
- archive
|
||||
- ideas
|
||||
---
|
||||
|
||||
# Archived Ideas
|
||||
@ -20,3 +22,5 @@ No real use case.
|
||||
|
||||
## Gamification
|
||||
Not aligned with minimalist approach.
|
||||
|
||||
<a href="#" hx-get="/api/notes/un-dossier/test/Poppy-test.md" hx-target="#editor-container" hx-swap="innerHTML">Poppy Test</a>
|
||||
@ -1,8 +1,10 @@
|
||||
---
|
||||
title: "Real-time Collaboration"
|
||||
date: "10-11-2025"
|
||||
last_modified: "10-11-2025:19:21"
|
||||
tags: ["idea", "collaboration"]
|
||||
title: Real-time Collaboration
|
||||
date: 10-11-2025
|
||||
last_modified: 11-11-2025:17:25
|
||||
tags:
|
||||
- idea
|
||||
- collaboration
|
||||
---
|
||||
|
||||
# Real-time Collaboration
|
||||
@ -13,6 +15,7 @@ Plusieurs utilisateurs éditent la même note simultanément.
|
||||
|
||||
## Technology
|
||||
|
||||
|
||||
- WebSockets
|
||||
- Operational Transforms ou CRDT
|
||||
- Presence indicators
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Automatic Tagging
|
||||
date: 10-11-2025
|
||||
last_modified: 11-11-2025:15:41
|
||||
last_modified: 11-11-2025:17:56
|
||||
tags:
|
||||
- research
|
||||
- ai
|
||||
@ -30,4 +30,9 @@ Suggest tags based on note content.
|
||||
|
||||
## Training Data
|
||||
|
||||
Use existing notes with tags as training set.
|
||||
Use existing notes with tags as
|
||||
training set.
|
||||
|
||||
|
||||
|
||||
[texte](url)
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Typography Research
|
||||
date: 10-11-2025
|
||||
last_modified: 11-11-2025:13:52
|
||||
last_modified: 11-11-2025:18:18
|
||||
tags:
|
||||
- research
|
||||
- design
|
||||
@ -34,3 +34,5 @@ tags:
|
||||
- Line height: 1.6
|
||||
- Max width: 65ch
|
||||
- Font size: 16px base
|
||||
|
||||
/ili
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: UI Design Inspiration
|
||||
date: 10-11-2025
|
||||
last_modified: 11-11-2025:15:25
|
||||
last_modified: 11-11-2025:18:19
|
||||
tags:
|
||||
- research
|
||||
- design
|
||||
@ -32,3 +32,5 @@ Consider:
|
||||
- Catppuccin
|
||||
|
||||
dldkfdddddd
|
||||
|
||||
[Poppy Test](un-dossier/test/Poppy-test.md)
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Go Performance Optimization
|
||||
date: 10-11-2025
|
||||
last_modified: 11-11-2025:15:16
|
||||
last_modified: 11-11-2025:18:28
|
||||
tags:
|
||||
- research
|
||||
- tech
|
||||
@ -33,3 +33,4 @@ type Cache struct {
|
||||
go test -cpuprofile=cpu.prof
|
||||
go tool pprof cpu.prof
|
||||
```
|
||||
<a href="#" onclick="return false;" hx-get="/api/notes/un-dossier/test/Poppy-test.md" hx-target="#editor-container" hx-swap="innerHTML">Poppy Test</a>
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: WebSockets for Live Updates
|
||||
date: 10-11-2025
|
||||
last_modified: 11-11-2025:15:27
|
||||
last_modified: 11-11-2025:18:14
|
||||
tags:
|
||||
- research
|
||||
- tech
|
||||
@ -37,3 +37,6 @@ type Hub struct {
|
||||
```
|
||||
|
||||
lfkfdkfd dd
|
||||
|
||||
|
||||
/il
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Test Delete 1
|
||||
date: 11-11-2025
|
||||
last_modified: 11-11-2025:15:40
|
||||
last_modified: 11-11-2025:18:31
|
||||
---
|
||||
test file 1
|
||||
|
||||
@ -9,3 +9,6 @@ test file 1
|
||||
ddddddddlffdfdddddddddddddd
|
||||
|
||||
|
||||
[texte](url)
|
||||
|
||||
<a href="#" onclick="return false;" hx-get="/api/notes/documentation/bienvenue.md" hx-target="#editor-container" hx-swap="innerHTML">Bienvenue dans Project Notes</a>
|
||||
@ -1,9 +1,17 @@
|
||||
---
|
||||
title: Poppy Test
|
||||
date: 10-11-2025
|
||||
last_modified: 10-11-2025:18:08
|
||||
last_modified: 11-11-2025:18:41
|
||||
---
|
||||
|
||||
# Poppy Test
|
||||
|
||||
Commencez à écrire votre note ici...
|
||||
|
||||
Logiquement cette page à des backlinks.
|
||||
|
||||
On verra bien à la fin.
|
||||
|
||||
<a href="#" onclick="return false;" hx-get="/api/notes/un-dossier/test/Poppy-test.md" hx-target="#editor-container" hx-swap="innerHTML">Poppy Test</a>
|
||||
|
||||
<a href="#" onclick="return false;" hx-get="/api/notes/research/design/typography.md" hx-target="#editor-container" hx-swap="innerHTML">Typography Research</a>
|
||||
176
static/sidebar-resize.js
Normal file
176
static/sidebar-resize.js
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Sidebar Resizable
|
||||
* Allows users to resize the sidebar by dragging the right edge
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'sidebar-width';
|
||||
const DEFAULT_WIDTH = 300;
|
||||
const MIN_WIDTH = 200;
|
||||
const MAX_WIDTH = 600;
|
||||
|
||||
let sidebar = null;
|
||||
let resizeHandle = null;
|
||||
let isResizing = false;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
/**
|
||||
* Initialize sidebar resize functionality
|
||||
*/
|
||||
function init() {
|
||||
sidebar = document.querySelector('#sidebar');
|
||||
if (!sidebar) {
|
||||
console.warn('Sidebar not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Sidebar resize initialized');
|
||||
|
||||
// Create resize handle
|
||||
createResizeHandle();
|
||||
|
||||
// Restore saved width
|
||||
restoreSidebarWidth();
|
||||
|
||||
// Add event listeners
|
||||
setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the resize handle element
|
||||
*/
|
||||
function createResizeHandle() {
|
||||
resizeHandle = document.createElement('div');
|
||||
resizeHandle.className = 'sidebar-resize-handle';
|
||||
resizeHandle.title = 'Drag to resize sidebar / Glisser pour redimensionner';
|
||||
|
||||
sidebar.appendChild(resizeHandle);
|
||||
console.log('Resize handle created and appended');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for resizing
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
resizeHandle.addEventListener('mousedown', startResize);
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
|
||||
// Prevent text selection while resizing
|
||||
resizeHandle.addEventListener('selectstart', (e) => e.preventDefault());
|
||||
}
|
||||
|
||||
/**
|
||||
* Start resizing
|
||||
*/
|
||||
function startResize(e) {
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
startWidth = sidebar.offsetWidth;
|
||||
|
||||
resizeHandle.classList.add('resizing');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
console.log('Started resizing from:', startX, 'width:', startWidth);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resize dragging
|
||||
*/
|
||||
function handleResize(e) {
|
||||
if (!isResizing) return;
|
||||
|
||||
const delta = e.clientX - startX;
|
||||
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
|
||||
|
||||
applySidebarWidth(newWidth);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop resizing
|
||||
*/
|
||||
function stopResize(e) {
|
||||
if (!isResizing) return;
|
||||
|
||||
isResizing = false;
|
||||
resizeHandle.classList.remove('resizing');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
console.log('Stopped resizing at width:', sidebar.offsetWidth);
|
||||
|
||||
// Save the new width
|
||||
saveSidebarWidth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sidebar width to localStorage
|
||||
*/
|
||||
function saveSidebarWidth() {
|
||||
const width = sidebar.offsetWidth;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, width.toString());
|
||||
} catch (e) {
|
||||
console.warn('Failed to save sidebar width:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore sidebar width from localStorage
|
||||
*/
|
||||
function restoreSidebarWidth() {
|
||||
try {
|
||||
const savedWidth = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedWidth) {
|
||||
const width = parseInt(savedWidth, 10);
|
||||
if (width >= MIN_WIDTH && width <= MAX_WIDTH) {
|
||||
applySidebarWidth(width);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to restore sidebar width:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sidebar width to both sidebar and main content
|
||||
*/
|
||||
function applySidebarWidth(width) {
|
||||
sidebar.style.width = `${width}px`;
|
||||
|
||||
// Update main content margin
|
||||
const main = document.querySelector('main');
|
||||
if (main) {
|
||||
main.style.marginLeft = `${width}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset sidebar to default width
|
||||
*/
|
||||
function resetSidebarWidth() {
|
||||
applySidebarWidth(DEFAULT_WIDTH);
|
||||
saveSidebarWidth();
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
// DOM already loaded
|
||||
init();
|
||||
}
|
||||
|
||||
// Expose reset function globally for debugging
|
||||
window.resetSidebarWidth = resetSidebarWidth;
|
||||
})();
|
||||
435
static/theme.css
435
static/theme.css
@ -132,6 +132,8 @@ aside {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
min-width: 200px;
|
||||
max-width: 600px;
|
||||
transform: translateX(0);
|
||||
transition: transform 0.25s ease;
|
||||
z-index: 10;
|
||||
@ -144,6 +146,51 @@ aside {
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Resize handle for sidebar */
|
||||
.sidebar-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -6px; /* Overlap for easier grab */
|
||||
bottom: 0;
|
||||
width: 16px; /* Even wider for Firefox */
|
||||
cursor: col-resize;
|
||||
background: rgba(66, 165, 245, 0.05);
|
||||
border-left: 2px solid transparent;
|
||||
border-right: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 11;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle:hover {
|
||||
background: rgba(66, 165, 245, 0.15);
|
||||
border-left-color: var(--accent-primary);
|
||||
border-right-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.sidebar-resize-handle.resizing {
|
||||
background: rgba(66, 165, 245, 0.25);
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
border-right: 3px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Visual indicator - always visible */
|
||||
.sidebar-resize-handle::before {
|
||||
content: '⋮';
|
||||
font-size: 18px;
|
||||
color: var(--accent-primary);
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle:hover::before {
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 4px var(--accent-primary);
|
||||
}
|
||||
|
||||
aside::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
@ -1483,7 +1530,9 @@ body, html {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -280px;
|
||||
width: 280px;
|
||||
width: 280px !important; /* Force width on mobile, ignore resize */
|
||||
min-width: 280px;
|
||||
max-width: 280px;
|
||||
height: 100vh;
|
||||
z-index: 1000;
|
||||
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@ -1493,6 +1542,11 @@ body, html {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Hide resize handle on mobile */
|
||||
.sidebar-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
aside.sidebar-visible {
|
||||
left: 0;
|
||||
}
|
||||
@ -2923,3 +2977,382 @@ body, html {
|
||||
max-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Link Inserter Modal Styles
|
||||
========================================================================== */
|
||||
|
||||
.link-inserter-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10000;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.link-inserter-modal.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.link-inserter-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.link-inserter-container {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 560px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg), var(--shadow-glow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 60vh;
|
||||
transform: translateY(-20px);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.link-inserter-modal.active .link-inserter-container {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.link-inserter-header {
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.link-inserter-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.link-inserter-icon {
|
||||
color: var(--accent-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-inserter-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.link-inserter-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-inserter-kbd {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.link-inserter-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.link-inserter-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Results Header */
|
||||
.link-inserter-results-header {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.link-inserter-results-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Result Item */
|
||||
.link-inserter-result-item {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
margin: var(--spacing-xs) 0;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.link-inserter-result-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.link-inserter-result-item.selected {
|
||||
background: linear-gradient(135deg, rgba(130, 170, 255, 0.15), rgba(199, 146, 234, 0.15));
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.link-inserter-result-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-inserter-result-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.link-inserter-result-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 3px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.link-inserter-result-title mark {
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
color: white;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.link-inserter-result-path {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.link-inserter-result-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tag-pill-small {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--accent-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Help */
|
||||
.link-inserter-help {
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link-inserter-help-text {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.link-inserter-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.link-inserter-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--bg-tertiary);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.link-inserter-loading p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* No Results */
|
||||
.link-inserter-no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.link-inserter-no-results-icon {
|
||||
font-size: 2.5rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.link-inserter-no-results p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.link-inserter-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.link-inserter-error-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.link-inserter-error p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.link-inserter-footer {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-top: 1px solid var(--border-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.link-inserter-footer-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-inserter-footer-hint kbd {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 5px;
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BACKLINKS SECTION
|
||||
============================================ */
|
||||
|
||||
/* Preview wrapper to contain both preview and backlinks */
|
||||
.preview-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Backlinks section styling */
|
||||
.backlinks-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.backlinks-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.backlinks-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.backlink-item {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.backlink-link {
|
||||
display: block;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.backlink-link:hover {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
transform: translateX(4px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.backlink-link:active {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* Mobile Adaptation */
|
||||
@media screen and (max-width: 768px) {
|
||||
.link-inserter-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.link-inserter-input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.link-inserter-results {
|
||||
max-height: calc(100vh - 200px);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,23 @@
|
||||
<div class="editor-panel {{if .IsHome}}hidden{{end}}">
|
||||
<textarea id="editor" name="content">{{.Content}}</textarea>
|
||||
</div>
|
||||
<div id="preview" class="preview markdown-preview">
|
||||
<div class="preview-wrapper">
|
||||
<div id="preview" class="preview markdown-preview">
|
||||
</div>
|
||||
{{if .Backlinks}}
|
||||
<div id="backlinks-section" class="backlinks-section">
|
||||
<h3 class="backlinks-title">🔗 Référencé par</h3>
|
||||
<ul class="backlinks-list">
|
||||
{{range .Backlinks}}
|
||||
<li class="backlink-item">
|
||||
<a href="#" onclick="return false;" hx-get="/api/notes/{{.Path}}" hx-target="#editor-container" hx-swap="innerHTML" class="backlink-link">
|
||||
📄 {{.Title}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if not .IsHome}}
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
|
||||
<script src="/static/sidebar-resize.js"></script>
|
||||
<script src="/frontend/src/theme-manager.js"></script>
|
||||
<script src="/frontend/src/font-manager.js"></script>
|
||||
<script src="/frontend/src/vim-mode-manager.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user