Add backlink
This commit is contained in:
@ -170,6 +170,58 @@ User types in editor → CodeMirror EditorView.updateListener
|
|||||||
└─ HTMX updates file tree automatically
|
└─ 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
|
## Frontend Architecture
|
||||||
|
|
||||||
### Build Process (Vite)
|
### Build Process (Vite)
|
||||||
@ -224,6 +276,17 @@ Executed in browser → Initializes components
|
|||||||
- Result highlighting
|
- Result highlighting
|
||||||
- Uses HTMX search API
|
- 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**
|
**ui.js**
|
||||||
- Sidebar toggle (mobile/desktop)
|
- Sidebar toggle (mobile/desktop)
|
||||||
- Simple utility functions
|
- 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.
|
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.
|
**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
|
## Architecture
|
||||||
|
|
||||||
### Backend (Go)
|
### 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.
|
- **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.
|
- **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.
|
- **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:
|
The server (`cmd/server/main.go`) coordinates these components:
|
||||||
1. Loads initial index from notes directory
|
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/folders/create` (Folder management)
|
||||||
- `/api/files/move` (File/folder moving)
|
- `/api/files/move` (File/folder moving)
|
||||||
- `/api/home` (Home page)
|
- `/api/home` (Home page)
|
||||||
|
- `/api/daily-notes/*` (Daily note creation and calendar)
|
||||||
|
- `/api/favorites/*` (Favorites management)
|
||||||
5. Handles static files from `static/` directory
|
5. Handles static files from `static/` directory
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
@ -46,22 +61,33 @@ The frontend uses a modern build system with Vite and CodeMirror 6:
|
|||||||
#### Frontend Source Structure
|
#### Frontend Source Structure
|
||||||
```
|
```
|
||||||
frontend/src/
|
frontend/src/
|
||||||
├── main.js # Entry point - imports all modules
|
├── main.js # Entry point - imports all modules
|
||||||
├── editor.js # CodeMirror 6 editor implementation with slash commands
|
├── editor.js # CodeMirror 6 editor implementation with slash commands
|
||||||
├── search.js # Search modal with Ctrl/Cmd+K keyboard shortcut
|
├── vim-mode-manager.js # Vim mode integration for CodeMirror
|
||||||
├── file-tree.js # Drag-and-drop file organization
|
├── search.js # Search modal with Ctrl/Cmd+K keyboard shortcut
|
||||||
└── ui.js # Sidebar toggle functionality
|
├── 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
|
#### CodeMirror 6 Editor Features
|
||||||
- **Syntax Highlighting**: Full Markdown language support (`@codemirror/lang-markdown`)
|
- **Syntax Highlighting**: Full Markdown language support (`@codemirror/lang-markdown`)
|
||||||
- **Theme**: One Dark theme (`@codemirror/theme-one-dark`) - VS Code-inspired dark theme
|
- **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
|
- **Live Preview**: Debounced updates (150ms) synchronized with editor scroll position
|
||||||
- **Auto-Save**: Triggers after 2 seconds of inactivity
|
- **Auto-Save**: Triggers after 2 seconds of inactivity
|
||||||
- **Keyboard Shortcuts**:
|
- **Keyboard Shortcuts**:
|
||||||
- `Ctrl/Cmd+S` for manual save
|
- `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
|
- `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
|
- **View Modes**: Toggle between split view, editor-only, and preview-only
|
||||||
- **Slash Commands**: Type `/` to open command palette for quick Markdown insertion
|
- **Slash Commands**: Type `/` to open command palette for quick Markdown insertion
|
||||||
- **Front Matter Handling**: Automatically strips YAML front matter in preview
|
- **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
|
- **Drag & Drop**: Move files between folders with visual feedback
|
||||||
- **Folder Creation**: Modal-based creation supporting nested paths
|
- **Folder Creation**: Modal-based creation supporting nested paths
|
||||||
- **Safe Validation**: Prevents dangerous path operations
|
- **Safe Validation**: Prevents dangerous path operations
|
||||||
|
- **Favorites**: Star notes and folders for quick access (★ icon in sidebar)
|
||||||
|
|
||||||
#### Rendering Pipeline
|
#### Rendering Pipeline
|
||||||
- **marked.js**: Markdown to HTML conversion
|
- **marked.js**: Markdown to HTML conversion
|
||||||
- **DOMPurify**: HTML sanitization to prevent XSS attacks
|
- **DOMPurify**: HTML sanitization to prevent XSS attacks
|
||||||
- **Highlight.js**: Syntax highlighting for code blocks in preview
|
- **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)
|
### HTMX + JavaScript Coordination (Optimized Architecture)
|
||||||
|
|
||||||
@ -227,6 +262,33 @@ htmx.ajax('POST', '/api/files/move', {
|
|||||||
- Use MutationObserver when HTMX events are available
|
- Use MutationObserver when HTMX events are available
|
||||||
- Mix fetch() and htmx.ajax() for similar operations
|
- 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
|
### Note Format
|
||||||
|
|
||||||
Notes have YAML front matter with these fields:
|
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)
|
- `static/dist/project-notes-frontend.umd.js` (UMD format)
|
||||||
|
|
||||||
Frontend dependencies (from `frontend/package.json`):
|
Frontend dependencies (from `frontend/package.json`):
|
||||||
- `@codemirror/basic-setup` - Base editor functionality
|
- `@codemirror/basic-setup` (^0.20.0) - Base editor functionality
|
||||||
- `@codemirror/lang-markdown` - Markdown language support
|
- `@codemirror/lang-markdown` (^6.5.0) - Markdown language support
|
||||||
- `@codemirror/state` - Editor state management
|
- `@codemirror/state` (^6.5.2) - Editor state management
|
||||||
- `@codemirror/view` - Editor view layer
|
- `@codemirror/view` (^6.38.6) - Editor view layer
|
||||||
- `@codemirror/theme-one-dark` - Dark theme
|
- `@codemirror/theme-one-dark` (^6.1.3) - Dark theme
|
||||||
- `vite` - Build tool
|
- `@replit/codemirror-vim` (^6.2.2) - Vim mode integration
|
||||||
|
- `vite` (^7.2.2) - Build tool
|
||||||
|
|
||||||
### Running the Server
|
### 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.
|
**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
|
### Security Considerations
|
||||||
|
|
||||||
File path validation in `handler.go` and `rest_handler.go`:
|
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:
|
The editor includes a slash command system integrated with CodeMirror 6:
|
||||||
- Type `/` at the start of a line to trigger the command palette
|
- 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
|
- **Headings**: h1, h2, h3 - Insert Markdown headers
|
||||||
- **Formatting**: bold, italic, code - Text formatting
|
- **Formatting**: bold, italic, code - Text formatting
|
||||||
- **Blocks**: codeblock, quote, hr, table - Block-level elements
|
- **Blocks**: codeblock, quote, hr, table - Block-level elements
|
||||||
- **Lists**: list - Unordered list
|
- **Lists**: list - Unordered list
|
||||||
- **Dynamic**: date - Insert current date in French format (DD/MM/YYYY)
|
- **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
|
- Navigate with Arrow Up/Down, select with Enter/Tab, cancel with Escape
|
||||||
- Commands are filtered in real-time as you type after the `/`
|
- Commands are filtered in real-time as you type after the `/`
|
||||||
- The palette is positioned dynamically near the cursor using CodeMirror coordinates
|
- The palette is positioned dynamically near the cursor using CodeMirror coordinates
|
||||||
- Implementation in `frontend/src/editor.js` with the `SlashCommands` class
|
- Implementation in `frontend/src/editor.js` with the `SlashCommands` class
|
||||||
- Styled command palette with gradient selection indicator
|
- 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
|
## Frontend Libraries
|
||||||
|
|
||||||
The application uses a mix of npm packages (for the editor) and CDN-loaded libraries (for utilities):
|
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/state (^6.5.2)**: Editor state management
|
||||||
- **@codemirror/view (^6.38.6)**: Editor view layer and rendering
|
- **@codemirror/view (^6.38.6)**: Editor view layer and rendering
|
||||||
- **@codemirror/theme-one-dark (^6.1.3)**: Dark theme for CodeMirror
|
- **@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
|
### CDN Libraries
|
||||||
Loaded in `templates/index.html`:
|
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
|
- **Highlight.js (11.9.0)**: Syntax highlighting for code blocks in preview with Atom One Dark theme
|
||||||
|
|
||||||
### Styling
|
### 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
|
- **Color System**: CSS custom properties for consistent theming
|
||||||
- Background colors: `--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--bg-elevated`
|
- Background colors: `--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--bg-elevated`
|
||||||
- Text colors: `--text-primary`, `--text-secondary`, `--text-muted`
|
- Text colors: `--text-primary`, `--text-secondary`, `--text-muted`
|
||||||
- Accent colors: `--accent-blue`, `--accent-violet`
|
- 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
|
- **No CSS Framework**: All styles hand-crafted with CSS Grid and Flexbox
|
||||||
- **Responsive Design**: Adaptive layout for different screen sizes
|
- **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
|
### Build Output
|
||||||
The Vite build process produces:
|
The Vite build process produces:
|
||||||
@ -566,17 +749,28 @@ project-notes/
|
|||||||
│ └── main.go # Server entry point
|
│ └── main.go # Server entry point
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── api/
|
│ ├── 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/
|
||||||
│ │ └── indexer.go # Note indexing and search
|
│ │ ├── indexer.go # Note indexing and search
|
||||||
|
│ │ └── indexer_test.go # Indexer tests
|
||||||
│ └── watcher/
|
│ └── watcher/
|
||||||
│ └── watcher.go # Filesystem watcher with fsnotify
|
│ └── watcher.go # Filesystem watcher with fsnotify
|
||||||
├── frontend/ # Frontend build system (NEW)
|
├── frontend/ # Frontend build system
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── main.js # Entry point
|
│ │ ├── main.js # Entry point - imports all modules
|
||||||
│ │ ├── editor.js # CodeMirror 6 implementation (26 KB)
|
│ │ ├── editor.js # CodeMirror 6 editor with slash commands
|
||||||
│ │ ├── file-tree.js # Drag-and-drop file management (11 KB)
|
│ │ ├── vim-mode-manager.js # Vim mode integration
|
||||||
│ │ └── ui.js # Sidebar toggle (720 B)
|
│ │ ├── 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.json # NPM dependencies
|
||||||
│ ├── package-lock.json
|
│ ├── package-lock.json
|
||||||
│ └── vite.config.js # Vite build configuration
|
│ └── vite.config.js # Vite build configuration
|
||||||
@ -592,9 +786,18 @@ project-notes/
|
|||||||
│ ├── search-results.html # Search results
|
│ ├── search-results.html # Search results
|
||||||
│ └── new-note-prompt.html # New note modal
|
│ └── new-note-prompt.html # New note modal
|
||||||
├── notes/ # Note storage directory
|
├── 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.mod # Go dependencies
|
||||||
├── go.sum
|
├── go.sum
|
||||||
|
├── API.md # REST API documentation
|
||||||
└── CLAUDE.md # This file
|
└── CLAUDE.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -602,15 +805,26 @@ project-notes/
|
|||||||
|
|
||||||
**Backend Development**:
|
**Backend Development**:
|
||||||
- `cmd/server/main.go` - Server initialization and routing
|
- `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/indexer/indexer.go` - Search and indexing logic
|
||||||
- `internal/watcher/watcher.go` - Filesystem monitoring
|
- `internal/watcher/watcher.go` - Filesystem monitoring
|
||||||
|
|
||||||
**Frontend Development**:
|
**Frontend Development**:
|
||||||
- `frontend/src/editor.js` - CodeMirror editor, preview, slash commands
|
- `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/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)
|
- `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)
|
- `templates/*.html` - HTML templates (Go template syntax)
|
||||||
|
|
||||||
**Configuration**:
|
**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.
|
A lightweight, web-based Markdown note-taking application with a Go backend and a minimalist frontend built with htmx.
|
||||||
|
|
||||||
- 🚫 No database
|
- 🚫 No database
|
||||||
- 📝 Flat files : Markdown with frontmatters
|
- 📝 Flat files: Markdown with front matter
|
||||||
- 🔒 Your notes, your application, your server, your data
|
- 🔒 Your notes, your application, your server, your data
|
||||||
- ⌨️ Vim Mode
|
- ⌨️ Vim Mode
|
||||||
- 🎹 Keyboard driven with shortcut and "/" command
|
- 🎹 Keyboard driven with shortcuts and "/" commands
|
||||||
- 🔍 Powerful Search
|
- 🔍 Powerful Search
|
||||||
- 🌍 Run everywhere (Linux & FreeBSD)
|
- 🌍 Run everywhere (Linux & FreeBSD)
|
||||||
- 📱 Responsive with laptop and smartphone
|
- 📱 Responsive on laptop and smartphone
|
||||||
- 🛠️ Super Easy to build
|
- 🛠️ Super Easy to build
|
||||||
- 🚀 A powerful API
|
- 🚀 Powerful REST API
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## Features
|
## 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.
|
* **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.
|
* **Go Backend:** Fast and efficient Go server handles file operations, indexing, and serving the frontend.
|
||||||
|
|
||||||
## Road Map
|
## Roadmap
|
||||||
|
|
||||||
- Share notes in markdown / pdf
|
- Share notes as Markdown/PDF exports
|
||||||
- Publics notes.
|
- Public notes
|
||||||
- Secure by user/password (You can use Authelia/Authentik for now)
|
- User authentication (use Authelia/Authentik for now)
|
||||||
|
|
||||||
|
|
||||||
## Getting Started
|
## 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
|
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
|
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
|
4. **Search**: Press `Ctrl/Cmd+K` to find any note instantly
|
||||||
5. **Customize**: Click ⚙️ to choose themes, fonts, and enable Vim mode
|
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 { oneDark } from '@codemirror/theme-one-dark';
|
||||||
import { keymap } from '@codemirror/view';
|
import { keymap } from '@codemirror/view';
|
||||||
import { indentWithTab } from '@codemirror/commands';
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
|
import { LinkInserter } from './link-inserter.js';
|
||||||
|
|
||||||
// Import du mode Vim
|
// Import du mode Vim
|
||||||
let vimExtension = null;
|
let vimExtension = null;
|
||||||
@ -254,6 +255,9 @@ class MarkdownEditor {
|
|||||||
htmx.process(this.preview);
|
htmx.process(this.preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Intercepter les clics sur les liens internes (avec hx-get)
|
||||||
|
this.setupInternalLinkHandlers();
|
||||||
|
|
||||||
if (typeof hljs !== 'undefined') {
|
if (typeof hljs !== 'undefined') {
|
||||||
this.preview.querySelectorAll('pre code').forEach(block => {
|
this.preview.querySelectorAll('pre code').forEach(block => {
|
||||||
hljs.highlightElement(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() {
|
syncToTextarea() {
|
||||||
if (this.editorView && this.textarea) {
|
if (this.editorView && this.textarea) {
|
||||||
this.textarea.value = this.editorView.state.doc.toString();
|
this.textarea.value = this.editorView.state.doc.toString();
|
||||||
@ -332,6 +371,7 @@ class SlashCommands {
|
|||||||
{ name: 'list', snippet: '- ' },
|
{ name: 'list', snippet: '- ' },
|
||||||
{ name: 'date', snippet: () => new Date().toLocaleDateString('fr-FR') },
|
{ name: 'date', snippet: () => new Date().toLocaleDateString('fr-FR') },
|
||||||
{ name: 'link', snippet: '[texte](url)' },
|
{ name: 'link', snippet: '[texte](url)' },
|
||||||
|
{ name: 'ilink', isModal: true, handler: () => this.openLinkInserter() },
|
||||||
{ name: 'bold', snippet: '**texte**' },
|
{ name: 'bold', snippet: '**texte**' },
|
||||||
{ name: 'italic', snippet: '*texte*' },
|
{ name: 'italic', snippet: '*texte*' },
|
||||||
{ name: 'code', snippet: '`code`' },
|
{ name: 'code', snippet: '`code`' },
|
||||||
@ -612,6 +652,15 @@ class SlashCommands {
|
|||||||
return;
|
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;
|
let snippet = command.snippet;
|
||||||
if (typeof snippet === 'function') {
|
if (typeof snippet === 'function') {
|
||||||
snippet = snippet();
|
snippet = snippet();
|
||||||
@ -632,6 +681,59 @@ class SlashCommands {
|
|||||||
this.hidePalette();
|
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() {
|
destroy() {
|
||||||
// Retirer tous les listeners d'événements
|
// Retirer tous les listeners d'événements
|
||||||
if (this.editorView) {
|
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 './ui.js';
|
||||||
import './search.js';
|
import './search.js';
|
||||||
import './daily-notes.js';
|
import './daily-notes.js';
|
||||||
|
import './link-inserter.js';
|
||||||
|
|||||||
@ -27,6 +27,12 @@ type TreeNode struct {
|
|||||||
Children []*TreeNode `json:"children,omitempty"`
|
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.
|
// Handler gère toutes les routes de l'API.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
notesDir string
|
notesDir string
|
||||||
@ -696,14 +702,20 @@ func (h *Handler) handleGetNote(w http.ResponseWriter, r *http.Request, filename
|
|||||||
content = []byte(initialContent)
|
content = []byte(initialContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Récupérer les backlinks pour cette note
|
||||||
|
backlinks := h.idx.GetBacklinks(filename)
|
||||||
|
backlinkData := h.buildBacklinkData(backlinks)
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
Filename string
|
Filename string
|
||||||
Content string
|
Content string
|
||||||
IsHome bool
|
IsHome bool
|
||||||
|
Backlinks []BacklinkInfo
|
||||||
}{
|
}{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
Content: string(content),
|
Content: string(content),
|
||||||
IsHome: false,
|
IsHome: false,
|
||||||
|
Backlinks: backlinkData,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.templates.ExecuteTemplate(w, "editor.html", data)
|
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"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@ -17,9 +18,10 @@ import (
|
|||||||
|
|
||||||
// Indexer maintient un index en memoire des tags associes aux fichiers Markdown.
|
// Indexer maintient un index en memoire des tags associes aux fichiers Markdown.
|
||||||
type Indexer struct {
|
type Indexer struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
tags map[string][]string
|
tags map[string][]string
|
||||||
docs map[string]*Document
|
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.
|
// Document représente une note indexée pour la recherche.
|
||||||
@ -51,8 +53,9 @@ type SearchResult struct {
|
|||||||
// New cree une nouvelle instance d Indexer.
|
// New cree une nouvelle instance d Indexer.
|
||||||
func New() *Indexer {
|
func New() *Indexer {
|
||||||
return &Indexer{
|
return &Indexer{
|
||||||
tags: make(map[string][]string),
|
tags: make(map[string][]string),
|
||||||
docs: make(map[string]*Document),
|
docs: make(map[string]*Document),
|
||||||
|
backlinks: make(map[string][]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,9 +115,31 @@ func (i *Indexer) Load(root string) error {
|
|||||||
indexed[tag] = list
|
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.mu.Lock()
|
||||||
i.tags = indexed
|
i.tags = indexed
|
||||||
i.docs = documents
|
i.docs = documents
|
||||||
|
i.backlinks = backlinksMap
|
||||||
i.mu.Unlock()
|
i.mu.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -668,3 +693,56 @@ func (i *Indexer) GetAllTagsWithCount() []TagCount {
|
|||||||
|
|
||||||
return result
|
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
|
title: Book Notes
|
||||||
date: 10-11-2025
|
date: 10-11-2025
|
||||||
last_modified: 11-11-2025:15:23
|
last_modified: 11-11-2025:18:07
|
||||||
tags:
|
tags:
|
||||||
- personal
|
- personal
|
||||||
- notes
|
- notes
|
||||||
@ -25,5 +25,4 @@ Key takeaways:
|
|||||||
- The Mom Test - Rob Fitzpatrick
|
- The Mom Test - Rob Fitzpatrick
|
||||||
- Shape Up - Basecamp
|
- Shape Up - Basecamp
|
||||||
|
|
||||||
|
[texte](/notes/)
|
||||||
/""
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: AI Writing Assistant
|
title: AI Writing Assistant
|
||||||
date: 10-11-2025
|
date: 10-11-2025
|
||||||
last_modified: 11-11-2025:11:13
|
last_modified: 11-11-2025:17:56
|
||||||
tags:
|
tags:
|
||||||
- idea
|
- idea
|
||||||
- ai
|
- ai
|
||||||
@ -28,3 +28,5 @@ Intégrer un assistant IA pour:
|
|||||||
Données restent locales, API optionnelle.
|
Données restent locales, API optionnelle.
|
||||||
|
|
||||||
Test test
|
Test test
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
---
|
---
|
||||||
title: "Authentication Guide"
|
title: Authentication Guide
|
||||||
date: "10-11-2025"
|
date: 10-11-2025
|
||||||
last_modified: "10-11-2025:19:21"
|
last_modified: 11-11-2025:18:30
|
||||||
tags: ["documentation", "api", "security"]
|
tags:
|
||||||
|
- documentation
|
||||||
|
- api
|
||||||
|
- security
|
||||||
---
|
---
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
@ -39,3 +42,5 @@ Authorization: Bearer eyJhbGc...
|
|||||||
- HTTPS only in production
|
- HTTPS only in production
|
||||||
- Reverse proxy with nginx
|
- Reverse proxy with nginx
|
||||||
- Rate limiting
|
- 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"
|
title: Archived Ideas
|
||||||
date: "10-11-2025"
|
date: 10-11-2025
|
||||||
last_modified: "10-11-2025:19:21"
|
last_modified: 11-11-2025:18:24
|
||||||
tags: ["archive", "ideas"]
|
tags:
|
||||||
|
- archive
|
||||||
|
- ideas
|
||||||
---
|
---
|
||||||
|
|
||||||
# Archived Ideas
|
# Archived Ideas
|
||||||
@ -20,3 +22,5 @@ No real use case.
|
|||||||
|
|
||||||
## Gamification
|
## Gamification
|
||||||
Not aligned with minimalist approach.
|
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"
|
title: Real-time Collaboration
|
||||||
date: "10-11-2025"
|
date: 10-11-2025
|
||||||
last_modified: "10-11-2025:19:21"
|
last_modified: 11-11-2025:17:25
|
||||||
tags: ["idea", "collaboration"]
|
tags:
|
||||||
|
- idea
|
||||||
|
- collaboration
|
||||||
---
|
---
|
||||||
|
|
||||||
# Real-time Collaboration
|
# Real-time Collaboration
|
||||||
@ -13,6 +15,7 @@ Plusieurs utilisateurs éditent la même note simultanément.
|
|||||||
|
|
||||||
## Technology
|
## Technology
|
||||||
|
|
||||||
|
|
||||||
- WebSockets
|
- WebSockets
|
||||||
- Operational Transforms ou CRDT
|
- Operational Transforms ou CRDT
|
||||||
- Presence indicators
|
- Presence indicators
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Automatic Tagging
|
title: Automatic Tagging
|
||||||
date: 10-11-2025
|
date: 10-11-2025
|
||||||
last_modified: 11-11-2025:15:41
|
last_modified: 11-11-2025:17:56
|
||||||
tags:
|
tags:
|
||||||
- research
|
- research
|
||||||
- ai
|
- ai
|
||||||
@ -30,4 +30,9 @@ Suggest tags based on note content.
|
|||||||
|
|
||||||
## Training Data
|
## 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
|
title: Typography Research
|
||||||
date: 10-11-2025
|
date: 10-11-2025
|
||||||
last_modified: 11-11-2025:13:52
|
last_modified: 11-11-2025:18:18
|
||||||
tags:
|
tags:
|
||||||
- research
|
- research
|
||||||
- design
|
- design
|
||||||
@ -34,3 +34,5 @@ tags:
|
|||||||
- Line height: 1.6
|
- Line height: 1.6
|
||||||
- Max width: 65ch
|
- Max width: 65ch
|
||||||
- Font size: 16px base
|
- Font size: 16px base
|
||||||
|
|
||||||
|
/ili
|
||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: UI Design Inspiration
|
title: UI Design Inspiration
|
||||||
date: 10-11-2025
|
date: 10-11-2025
|
||||||
last_modified: 11-11-2025:15:25
|
last_modified: 11-11-2025:18:19
|
||||||
tags:
|
tags:
|
||||||
- research
|
- research
|
||||||
- design
|
- design
|
||||||
@ -32,3 +32,5 @@ Consider:
|
|||||||
- Catppuccin
|
- Catppuccin
|
||||||
|
|
||||||
dldkfdddddd
|
dldkfdddddd
|
||||||
|
|
||||||
|
[Poppy Test](un-dossier/test/Poppy-test.md)
|
||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Go Performance Optimization
|
title: Go Performance Optimization
|
||||||
date: 10-11-2025
|
date: 10-11-2025
|
||||||
last_modified: 11-11-2025:15:16
|
last_modified: 11-11-2025:18:28
|
||||||
tags:
|
tags:
|
||||||
- research
|
- research
|
||||||
- tech
|
- tech
|
||||||
@ -33,3 +33,4 @@ type Cache struct {
|
|||||||
go test -cpuprofile=cpu.prof
|
go test -cpuprofile=cpu.prof
|
||||||
go tool pprof 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
|
title: WebSockets for Live Updates
|
||||||
date: 10-11-2025
|
date: 10-11-2025
|
||||||
last_modified: 11-11-2025:15:27
|
last_modified: 11-11-2025:18:14
|
||||||
tags:
|
tags:
|
||||||
- research
|
- research
|
||||||
- tech
|
- tech
|
||||||
@ -37,3 +37,6 @@ type Hub struct {
|
|||||||
```
|
```
|
||||||
|
|
||||||
lfkfdkfd dd
|
lfkfdkfd dd
|
||||||
|
|
||||||
|
|
||||||
|
/il
|
||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Test Delete 1
|
title: Test Delete 1
|
||||||
date: 11-11-2025
|
date: 11-11-2025
|
||||||
last_modified: 11-11-2025:15:40
|
last_modified: 11-11-2025:18:31
|
||||||
---
|
---
|
||||||
test file 1
|
test file 1
|
||||||
|
|
||||||
@ -9,3 +9,6 @@ test file 1
|
|||||||
ddddddddlffdfdddddddddddddd
|
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
|
title: Poppy Test
|
||||||
date: 10-11-2025
|
date: 10-11-2025
|
||||||
last_modified: 10-11-2025:18:08
|
last_modified: 11-11-2025:18:41
|
||||||
---
|
---
|
||||||
|
|
||||||
# Poppy Test
|
# Poppy Test
|
||||||
|
|
||||||
Commencez à écrire votre note ici...
|
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;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 600px;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
transition: transform 0.25s ease;
|
transition: transform 0.25s ease;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@ -144,6 +146,51 @@ aside {
|
|||||||
gap: var(--spacing-md);
|
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 {
|
aside::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
@ -1483,7 +1530,9 @@ body, html {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: -280px;
|
left: -280px;
|
||||||
width: 280px;
|
width: 280px !important; /* Force width on mobile, ignore resize */
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 280px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@ -1493,6 +1542,11 @@ body, html {
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide resize handle on mobile */
|
||||||
|
.sidebar-resize-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
aside.sidebar-visible {
|
aside.sidebar-visible {
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
@ -2923,3 +2977,382 @@ body, html {
|
|||||||
max-height: 180px;
|
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}}">
|
<div class="editor-panel {{if .IsHome}}hidden{{end}}">
|
||||||
<textarea id="editor" name="content">{{.Content}}</textarea>
|
<textarea id="editor" name="content">{{.Content}}</textarea>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{{if not .IsHome}}
|
{{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/marked/marked.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.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="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/theme-manager.js"></script>
|
||||||
<script src="/frontend/src/font-manager.js"></script>
|
<script src="/frontend/src/font-manager.js"></script>
|
||||||
<script src="/frontend/src/vim-mode-manager.js"></script>
|
<script src="/frontend/src/vim-mode-manager.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user