diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f3fc1d..d43f5e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,6 +139,85 @@ Plugins should: - Be optional and not break core functionality if disabled - Follow the plugin configuration format +## 🌍 Contributing Translations + +NoteDiscovery supports multiple languages through JSON locale files. Adding a new language is easy! + +### How to Add a New Language + +1. **Copy the English locale file:** + ```bash + cp locales/en-US.json locales/xx-XX.json + ``` + Use the appropriate [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) (e.g., `pt-BR`, `ja-JP`, `zh-CN`). + +2. **Update the `_meta` section:** + ```json + { + "_meta": { + "code": "xx-XX", + "name": "Language Name", + "flag": "🇽🇽" + }, + ... + } + ``` + +3. **Translate all string values:** + - Keep the keys exactly as they are (don't translate keys!) + - Translate only the values + - Preserve `{{placeholders}}` - they get replaced with dynamic values + - Keep emoji prefixes like `✓`, `💡`, `📂` as they are universal + +4. **Test your translation:** + - Restart the application + - Go to Settings → Language dropdown + - Your new language should appear automatically + - Click through the UI to verify translations + +### Translation Guidelines + +- **Be consistent** - Use the same terminology throughout +- **Match the tone** - NoteDiscovery uses friendly, concise language +- **Preserve formatting** - Keep `\n` for newlines in multi-line strings +- **Handle plurals simply** - We use `{{count}}` placeholders (e.g., "hace {{count}}m") +- **Test date formats** - Dates are formatted using the browser's `Intl` API with your locale code + +### What Gets Translated + +| Category | Examples | +|----------|----------| +| UI Labels | Button text, panel titles, tooltips | +| Messages | Alerts, confirmations, prompts | +| Placeholders | Search box, editor hints | +| Relative times | "just now", "5m ago", "2d ago" | + +### What Stays in English + +- Technical terms: "Wikilinks", "Markdown", "HTML" +- Keyboard shortcuts in tooltips: "Ctrl+Z", "Esc" +- File extensions: ".md", ".json" + +### Locale File Structure + +Translations are grouped by functionality: + +``` +common.* - Generic buttons (Save, Cancel, Delete) +sidebar.* - Sidebar panel labels and hints +editor.* - Editor placeholder, mode buttons, time strings +notes.* - Note-related prompts and errors +folders.* - Folder-related prompts and errors +toolbar.* - Toolbar button tooltips +zen_mode.* - Zen mode labels +tags.* - Tags panel +graph.* - Graph view labels +templates.* - Template modal +images.* - Image upload messages +search.* - Search navigation +settings.* - Settings panel labels (Theme, Language) +``` + ## 📚 Contributing Documentation Documentation improvements are always welcome! Please: diff --git a/Dockerfile b/Dockerfile index 6a81c1f..0ae4ac4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ COPY config.yaml . COPY VERSION . COPY plugins ./plugins COPY themes ./themes +COPY locales ./locales COPY generate_password.py . # Create data directory diff --git a/README.md b/README.md index 53500fd..f40bb9a 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ docker run -d \ -v $(pwd)/data:/app/data \ -v $(pwd)/plugins:/app/plugins \ -v $(pwd)/themes:/app/themes \ + -v $(pwd)/locales:/app/locales \ -v $(pwd)/config.yaml:/app/config.yaml \ --restart unless-stopped \ ghcr.io/gamosoft/notediscovery:latest @@ -140,6 +141,7 @@ docker run -d ` -v ${PWD}/data:/app/data ` -v ${PWD}/plugins:/app/plugins ` -v ${PWD}/themes:/app/themes ` + -v ${PWD}/locales:/app/locales ` -v ${PWD}/config.yaml:/app/config.yaml ` --restart unless-stopped ` ghcr.io/gamosoft/notediscovery:latest @@ -221,6 +223,25 @@ Want to learn more? - 🔐 **[AUTHENTICATION.md](documentation/AUTHENTICATION.md)** - Enable password protection for your instance - 🔧 **[ENVIRONMENT_VARIABLES.md](documentation/ENVIRONMENT_VARIABLES.md)** - Configure settings via environment variables +## 🌍 Multiple Languages + +NoteDiscovery supports multiple languages! Currently available: +- 🇺🇸 English (en-US) - Default +- 🇪🇸 Español (es-ES) +- 🇩🇪 Deutsch (de-DE) +- 🇫🇷 Français (fr-FR) + +**To change language:** Go to Settings (gear icon) → Language dropdown. + +**To add your own language:** See the [Contributing Guidelines](CONTRIBUTING.md#-contributing-translations) for instructions on creating translation files. + +**Docker users:** Mount your custom locales folder to add or override translations: + +```yaml +volumes: + - ./locales:/app/locales # Custom translations +``` + 💡 **Pro Tip:** If you clone this repository, you can mount the `documentation/` folder to view these docs inside the app: ```yaml diff --git a/backend/main.py b/backend/main.py index de8ecdd..456aedb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -519,6 +519,50 @@ async def get_theme(theme_id: str): return {"css": css, "theme_id": theme_id} +# Locales endpoints (unauthenticated - needed for login page and initial load) +@app.get("/api/locales") +async def get_available_locales(): + """Get list of available locales""" + import json + locales_dir = Path(__file__).parent.parent / "locales" + locales = [] + + if locales_dir.exists(): + for file in sorted(locales_dir.glob("*.json")): + try: + with open(file, 'r', encoding='utf-8') as f: + data = json.load(f) + meta = data.get('_meta', {}) + locales.append({ + "code": meta.get('code', file.stem), + "name": meta.get('name', file.stem), + "flag": meta.get('flag', '🌐') + }) + except (json.JSONDecodeError, IOError): + # Skip invalid locale files + continue + + return {"locales": locales} + + +@app.get("/api/locales/{locale_code}") +async def get_locale(locale_code: str): + """Get translations for a specific locale""" + import json + locales_dir = Path(__file__).parent.parent / "locales" + locale_file = locales_dir / f"{locale_code}.json" + + if not locale_file.exists(): + raise HTTPException(status_code=404, detail="Locale not found") + + try: + with open(locale_file, 'r', encoding='utf-8') as f: + translations = json.load(f) + return translations + except (json.JSONDecodeError, IOError) as e: + raise HTTPException(status_code=500, detail=f"Failed to load locale: {str(e)}") + + @api_router.post("/folders") @limiter.limit("30/minute") async def create_new_folder(request: Request, data: dict): diff --git a/docker-compose.ghcr.yml b/docker-compose.ghcr.yml index 6101c02..f948465 100644 --- a/docker-compose.ghcr.yml +++ b/docker-compose.ghcr.yml @@ -11,6 +11,8 @@ services: - ./plugins:/app/plugins # Custom themes (REQUIRED - must contain theme .css files) - ./themes:/app/themes + # Custom locales/translations (optional - add your own language files) + - ./locales:/app/locales # Config file (REQUIRED - must exist as a file, not directory!) - ./config.yaml:/app/config.yaml restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 843eb96..1819ffd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,8 @@ services: - ./plugins:/app/plugins # Custom themes (REQUIRED - must contain theme .css files) - ./themes:/app/themes + # Custom locales/translations (optional - add your own language files) + - ./locales:/app/locales # Config file (REQUIRED - must exist as a file, not directory!) - ./config.yaml:/app/config.yaml restart: unless-stopped diff --git a/frontend/app.js b/frontend/app.js index d9ced52..bf24e59 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -25,6 +25,8 @@ const ErrorHandler = { // Show user-friendly alert if requested if (showAlert) { + // Note: ErrorHandler doesn't have access to Alpine's t() function + // This message remains in English as a fallback alert(`Failed to ${operation}. Please try again.`); } } @@ -83,6 +85,32 @@ function noteApp() { currentTheme: 'light', availableThemes: [], + // Locale/i18n state + currentLocale: 'en-US', + availableLocales: [], + // Default translations (loaded inline for immediate availability before API fetch) + translations: { + common: { save: "Save", cancel: "Cancel", delete: "Delete", rename: "Rename", create: "Create", close: "Close", yes: "✓ Yes", no: "✗ No", ok: "OK", error: "Error", loading: "Loading...", saved: "✓ Saved", saving: "Saving...", copied: "✓ Copied!" }, + sidebar: { title: "FILES", favorites_title: "Favorites", folders_and_notes: "Folders & Notes", new_button: "+ New", new_note: "New Note", new_folder: "New Folder", new_from_template: "New from Template", search_placeholder: "Search notes...", search_hint: "Type to search your notes", clear_search: "Clear search", drag_hint: "💡 Drag=Move", root_folder: "📂 Root folder", no_notes: "No notes yet", no_favorites: "No favorites yet", no_results: "No results found", expand_all: "Expand all folders", collapse_all: "Collapse all folders", toggle_sidebar: "Toggle sidebar", go_to_homepage: "Go to homepage", files: "Files", search: "Search", search_title: "SEARCH", settings: "Settings", settings_title: "SETTINGS", filtered_notes: "Filtered Notes" }, + editor: { placeholder: "Start writing in markdown...", drop_hint: "💡 Drop here to insert at cursor position...", mode_edit: "Edit", mode_split: "Split", mode_preview: "Preview", edited: "Edited {{time}}", just_now: "just now", minutes_ago: "{{count}}m ago", hours_ago: "{{count}}h ago", days_ago: "{{count}}d ago" }, + notes: { confirm_delete: "Delete \"{{name}}\"?", already_exists: "A note named \"{{name}}\" already exists in this location.\nPlease choose a different name.", prompt_name: "Enter note name:", prompt_name_in_folder: "Create note in \"{{folder}}\".\nEnter note name:", prompt_name_with_path: "Enter note name (you can use folder/name):", prompt_rename: "Enter new name:", invalid_name: "Invalid note name.", empty_name: "Note name cannot be empty.", not_found: "Note not found: {{path}}", no_content: "No note content to export", open_first: "Please open a note first before uploading images." }, + folders: { confirm_delete: "⚠️ WARNING ⚠️\n\nAre you sure you want to delete the folder \"{{name}}\"?\n\nThis will PERMANENTLY delete:\n• All notes inside this folder\n• All subfolders and their contents\n\nThis action CANNOT be undone!", already_exists: "A folder named \"{{name}}\" already exists in this location.\nPlease choose a different name.", prompt_name: "Enter folder name:", prompt_name_in_folder: "Create subfolder in \"{{folder}}\".\nEnter folder name:", prompt_name_with_path: "Create new folder.\nEnter folder path (e.g., \"Projects\" or \"Work/2025\"):", prompt_rename: "Rename folder \"{{name}}\" to:", invalid_name: "Invalid folder name.", cannot_move_into_self: "Cannot move folder into itself or its subfolder." }, + toolbar: { undo: "Undo (Ctrl+Z)", redo: "Redo (Ctrl+Y)", delete_note: "Delete note", delete_image: "Delete image", export_html: "Export as HTML", copy_link: "Copy link to clipboard", add_favorite: "Add to favorites", remove_favorite: "Remove from favorites" }, + zen_mode: { title: "Zen Mode", tooltip: "Zen Mode (Ctrl+Alt+Z)", exit: "Exit Zen Mode", exit_hint: "Exit Zen Mode (Esc)" }, + tags: { title: "Tags", no_tags: "No tags found", clear_all: "Clear tag filters", filter_by: "Filter by {{tag}} ({{count}} notes)" }, + stats: { words: "words", reading_time: "~{{minutes}}m read", links: "{{count}} links", click_details: "▼ Click for details" }, + graph: { title: "Graph View", empty: "No connections to display", markdown_links: "Markdown links", click_hint: "Click: select • Double-click: open" }, + templates: { title: "Templates", select: "Select a template...", prompt_name: "Enter note name:", no_templates: "No templates available", create_failed: "Failed to create note from template" }, + images: { confirm_delete: "Delete image \"{{name}}\"?", upload_failed: "Failed to upload image", no_valid_files: "No valid image files found. Supported formats: JPG, PNG, GIF, WEBP", formats: "Supports JPG, PNG, GIF, WebP (max 10MB)" }, + move: { failed_note: "Failed to move note.", failed_folder: "Failed to move folder." }, + search: { previous: "Previous (Shift+F3)", next: "Next (F3)", matches: "{{current}} of {{total}}" }, + theme: { title: "Theme" }, + language: { title: "Language" }, + syntax_highlight: { title: "Editor Syntax Highlight", enable: "Enable", disable: "Disable" }, + homepage: { title: "Home", welcome: "Welcome to NoteDiscovery", get_started: "Create something to get started", no_notes_title: "No notes yet", no_notes_desc: "Create your first note or folder", folder_empty: "This folder is empty" }, + export: { failed: "Failed to export HTML: {{error}}" } + }, + // Syntax highlighting syntaxHighlightEnabled: false, syntaxHighlightTimeout: null, @@ -229,7 +257,7 @@ function noteApp() { return this._homepageCache.breadcrumb; } - const breadcrumb = [{ name: 'Home', path: '' }]; + const breadcrumb = [{ name: this.t('homepage.title'), path: '' }]; if (this.selectedHomepageFolder) { const parts = this.selectedHomepageFolder.split('/').filter(Boolean); @@ -257,6 +285,18 @@ function noteApp() { return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }, + // Helper: Format date using current locale + formatDate(dateStr) { + if (!dateStr) return ''; + const date = new Date(dateStr); + if (isNaN(date.getTime())) return ''; + return date.toLocaleDateString(this.currentLocale, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }, + getFolderNode(folderPath = '') { if (!this.folderTree || typeof this.folderTree !== 'object') { return null; @@ -320,6 +360,8 @@ function noteApp() { await this.loadConfig(); await this.loadThemes(); await this.initTheme(); + await this.loadAvailableLocales(); + await this.loadLocale(); await this.loadNotes(); await this.loadTemplates(); await this.checkStatsPlugin(); @@ -761,6 +803,68 @@ function noteApp() { } }, + // ==================== INTERNATIONALIZATION ==================== + + // Translation function - get translated string by key + t(key, params = {}) { + const keys = key.split('.'); + let value = this.translations; + + for (const k of keys) { + value = value?.[k]; + } + + // Fallback to key if translation not found (silently - default translations are inline) + if (typeof value !== 'string') { + return key; + } + + // Replace {{param}} placeholders + return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`); + }, + + // Load available locales from backend + async loadAvailableLocales() { + try { + const response = await fetch('/api/locales'); + const data = await response.json(); + this.availableLocales = data.locales || []; + } catch (error) { + console.error('Failed to load available locales:', error); + this.availableLocales = [{ code: 'en-US', name: 'English', flag: '🇺🇸' }]; + } + }, + + // Load translations for a specific locale + async loadLocale(localeCode = null) { + const targetLocale = localeCode || localStorage.getItem('locale') || 'en-US'; + + try { + const response = await fetch(`/api/locales/${targetLocale}`); + if (response.ok) { + this.translations = await response.json(); + this.currentLocale = targetLocale; + localStorage.setItem('locale', targetLocale); + } else if (targetLocale !== 'en-US') { + // Fallback to en-US if requested locale not found + await this.loadLocale('en-US'); + } + } catch (error) { + console.error('Failed to load locale:', error); + // If en-US also fails, translations will be empty and t() will return keys + if (targetLocale !== 'en-US') { + await this.loadLocale('en-US'); + } + } + }, + + // Change locale and reload translations + async changeLocale(localeCode) { + await this.loadLocale(localeCode); + }, + + // ==================== END INTERNATIONALIZATION ==================== + // Load all notes async loadNotes() { try { @@ -907,7 +1011,7 @@ function noteApp() { // CRITICAL: Check if note already exists const existingNote = this.notes.find(note => note.path === notePath); if (existingNote) { - alert(`A note named "${this.newTemplateNoteName.trim()}" already exists in this location.\nPlease choose a different name.`); + alert(this.t('notes.already_exists', { name: this.newTemplateNoteName.trim() })); return; } @@ -923,7 +1027,7 @@ function noteApp() { if (!response.ok) { const error = await response.json(); - alert(error.detail || 'Failed to create note from template'); + alert(error.detail || this.t('templates.create_failed')); return; } @@ -1125,13 +1229,13 @@ function noteApp() { const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); - if (diffSecs < 60) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; + if (diffSecs < 60) return this.t('editor.just_now'); + if (diffMins < 60) return this.t('editor.minutes_ago', { count: diffMins }); + if (diffHours < 24) return this.t('editor.hours_ago', { count: diffHours }); + if (diffDays < 7) return this.t('editor.days_ago', { count: diffDays }); - // For older dates, show the date - return modified.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + // For older dates, show the date in selected locale + return modified.toLocaleDateString(this.currentLocale, { month: 'short', day: 'numeric' }); }, // Parse tags from markdown content (matches backend logic) @@ -1675,7 +1779,7 @@ function noteApp() { // Handle image files dropped into editor async handleImageDrop(event) { if (!this.currentNote) { - alert('Please open a note first before uploading images.'); + alert(this.t('notes.open_first')); return; } @@ -1687,7 +1791,7 @@ function noteApp() { }); if (imageFiles.length === 0) { - alert('No valid image files found. Supported formats: JPG, PNG, GIF, WEBP'); + alert(this.t('images.no_valid_files')); return; } @@ -1820,7 +1924,7 @@ function noteApp() { // Delete an image async deleteImage(imagePath) { const filename = imagePath.split('/').pop(); - if (!confirm(`Delete image "${filename}"?`)) return; + if (!confirm(this.t('images.confirm_delete', { name: filename }))) return; try { const response = await fetch(`/api/notes/${encodeURIComponent(imagePath)}`, { @@ -1893,7 +1997,7 @@ function noteApp() { if (noteByPathCI) { this.loadNote(noteByPathCI.path); } else { - alert(`Note not found: ${notePath}`); + alert(this.t('notes.not_found', { path: notePath })); } } } @@ -1974,11 +2078,11 @@ function noteApp() { this.currentNote = newPath; } } else { - alert('Failed to move note.'); + alert(this.t('move.failed_note')); } } catch (error) { console.error('Failed to move note:', error); - alert('Failed to move note.'); + alert(this.t('move.failed_note')); } this.draggedNote = null; @@ -1990,7 +2094,7 @@ function noteApp() { // Prevent dropping folder into itself or its subfolders if (targetFolderPath === this.draggedFolder || targetFolderPath.startsWith(this.draggedFolder + '/')) { - alert('Cannot move folder into itself or its subfolder.'); + alert(this.t('folders.cannot_move_into_self')); this.draggedFolder = null; return; } @@ -2020,11 +2124,11 @@ function noteApp() { this.currentNote = this.currentNote.replace(this.draggedFolder, newPath); } } else { - alert('Failed to move folder.'); + alert(this.t('move.failed_folder')); } } catch (error) { console.error('Failed to move folder:', error); - alert('Failed to move folder.'); + alert(this.t('move.failed_folder')); } this.draggedFolder = null; @@ -2423,15 +2527,15 @@ function noteApp() { this.closeDropdown(); const promptText = targetFolder - ? `Create note in "${targetFolder}".\nEnter note name:` - : 'Enter note name (you can use folder/name):'; + ? this.t('notes.prompt_name_in_folder', { folder: targetFolder }) + : this.t('notes.prompt_name_with_path'); const noteName = prompt(promptText); if (!noteName) return; const sanitizedName = noteName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); if (!sanitizedName) { - alert('Invalid note name.'); + alert(this.t('notes.invalid_name')); return; } @@ -2445,7 +2549,7 @@ function noteApp() { // CRITICAL: Check if note already exists const existingNote = this.notes.find(note => note.path === notePath); if (existingNote) { - alert(`A note named "${sanitizedName}" already exists in this location.\nPlease choose a different name.`); + alert(this.t('notes.already_exists', { name: sanitizedName })); return; } @@ -2484,15 +2588,15 @@ function noteApp() { this.closeDropdown(); const promptText = targetFolder - ? `Create subfolder in "${targetFolder}".\nEnter folder name:` - : 'Create new folder.\nEnter folder path (e.g., "Projects" or "Work/2025"):'; + ? this.t('folders.prompt_name_in_folder', { folder: targetFolder }) + : this.t('folders.prompt_name_with_path'); const folderName = prompt(promptText); if (!folderName) return; const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); if (!sanitizedName) { - alert('Invalid folder name.'); + alert(this.t('folders.invalid_name')); return; } @@ -2501,7 +2605,7 @@ function noteApp() { // Check if folder already exists const existingFolder = this.allFolders.find(folder => folder === folderPath); if (existingFolder) { - alert(`A folder named "${sanitizedName}" already exists in this location.\nPlease choose a different name.`); + alert(this.t('folders.already_exists', { name: sanitizedName })); return; } @@ -2536,7 +2640,7 @@ function noteApp() { const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9-_\s]/g, ''); if (!sanitizedName) { - alert('Invalid folder name.'); + alert(this.t('folders.invalid_name')); return; } @@ -2578,14 +2682,7 @@ function noteApp() { // Delete folder async deleteFolder(folderPath, folderName) { - const confirmation = confirm( - `⚠️ WARNING ⚠️\n\n` + - `Are you sure you want to delete the folder "${folderName}"?\n\n` + - `This will PERMANENTLY delete:\n` + - `• All notes inside this folder\n` + - `• All subfolders and their contents\n\n` + - `This action CANNOT be undone!` - ); + const confirmation = confirm(this.t('folders.confirm_delete', { name: folderName })); if (!confirmation) return; @@ -2884,7 +2981,7 @@ function noteApp() { const newName = this.currentNoteName.trim(); if (!newName) { - alert('Note name cannot be empty.'); + alert(this.t('notes.empty_name')); return; } @@ -2896,7 +2993,7 @@ function noteApp() { // Check if a note with the new name already exists const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase()); if (existingNote) { - alert(`A note named "${newName}" already exists in this folder.`); + alert(this.t('notes.already_exists', { name: newName })); // Reset the name in the UI this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); return; @@ -2934,7 +3031,7 @@ function noteApp() { // Delete any note from sidebar async deleteNote(notePath, noteName) { - if (!confirm(`Delete "${noteName}"?`)) return; + if (!confirm(this.t('notes.confirm_delete', { name: noteName }))) return; try { const response = await fetch(`/api/notes/${notePath}`, { @@ -3722,7 +3819,7 @@ function noteApp() { date = new Date(value); } if (!isNaN(date.getTime())) { - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.currentLocale, { year: 'numeric', month: 'short', day: 'numeric' @@ -3732,7 +3829,7 @@ function noteApp() { // Booleans if (typeof value === 'boolean') { - return value ? '✓ Yes' : '✗ No'; + return value ? this.t('common.yes') : this.t('common.no'); } return String(value); @@ -4006,7 +4103,7 @@ function noteApp() { // Export current note as HTML async exportToHTML() { if (!this.currentNote || !this.noteContent) { - alert('No note content to export'); + alert(this.t('notes.no_content')); return; } @@ -4245,7 +4342,7 @@ function noteApp() { } catch (error) { console.error('HTML export failed:', error); - alert(`Failed to export HTML: ${error.message}`); + alert(this.t('export.failed', { error: error.message })); } }, @@ -4638,10 +4735,10 @@ function noteApp() {
- Markdown links + ${this.t('graph.markdown_links')}
- Click: select • Double-click: open + ${this.t('graph.click_hint')}
`; container.appendChild(legend); diff --git a/frontend/index.html b/frontend/index.html index 4419d41..a4ee40c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1087,7 +1087,7 @@ x-transition:enter-end="opacity-60 translate-y-0" @click="toggleZenMode()" class="zen-exit-button" - title="Exit Zen Mode (Esc)" + :title="t('zen_mode.exit_hint')" > @@ -1119,7 +1119,7 @@ alt="NoteDiscovery" class="icon-rail-logo" @click="goHome()" - title="Go to homepage" + :title="t('sidebar.go_to_homepage')" > @@ -1127,7 +1127,7 @@ class="icon-rail-btn" :class="{'active': activePanel === 'files'}" @click="activePanel = 'files'" - title="Files" + :title="t('sidebar.files')" aria-label="Files panel" > @@ -1140,7 +1140,7 @@ class="icon-rail-btn relative" :class="{'active': activePanel === 'search'}" @click="activePanel = 'search'" - title="Search" + :title="t('sidebar.search')" aria-label="Search panel" > @@ -1161,7 +1161,7 @@ class="icon-rail-btn relative" :class="{'active': activePanel === 'tags'}" @click="activePanel = 'tags'" - title="Tags" + :title="t('tags.title')" aria-label="Tags panel" > @@ -1184,7 +1184,7 @@ class="icon-rail-btn" :class="{'active': showGraph}" @click="showGraph = true; initGraph()" - title="Graph View" + :title="t('graph.title')" aria-label="Graph view" > @@ -1197,7 +1197,7 @@ class="icon-rail-btn" :class="{'active': activePanel === 'settings'}" @click="activePanel = 'settings'" - title="Settings" + :title="t('sidebar.settings')" aria-label="Settings panel" > @@ -1214,7 +1214,7 @@
- Files +
@@ -1289,19 +1289,19 @@
- Folders & Notes +
@@ -1325,8 +1325,8 @@ @drop="if(draggedNote || draggedFolder) onFolderDrop('')" style="height: 28px; line-height: 20px; display: flex; align-items: center; justify-content: center;" > - 💡 Drag=Move - 📂 Root folder + +
@@ -1355,7 +1355,7 @@ @click.stop="note.type === 'image' ? deleteImage(note.path) : deleteNote(note.path, note.name)" class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity" style="opacity: 0; color: var(--error);" - :title="note.type === 'image' ? 'Delete image' : 'Delete note'" + :title="note.type === 'image' ? t('toolbar.delete_image') : t('toolbar.delete_note')" > @@ -1378,7 +1378,7 @@
- Search +
@@ -1388,7 +1388,7 @@ type="text" x-model="searchQuery" @input="searchNotes()" - placeholder="Search notes..." + :placeholder="t('sidebar.search_placeholder')" class="w-full px-2 py-1.5 pl-7 pr-7 text-xs rounded focus:outline-none focus:ring-1" style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary);" > @@ -1400,7 +1400,7 @@ @click="searchQuery = ''; searchResults = []; currentSearchHighlight = ''; clearSearchHighlights();" class="absolute right-1.5 top-1.5 w-4 h-4 flex items-center justify-center rounded-full hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" style="color: var(--text-tertiary);" - title="Clear search" + :title="t('sidebar.clear_search')" > @@ -1416,10 +1416,10 @@ >
- -
@@ -1460,7 +1460,7 @@ -

Type to search your notes

+

@@ -1471,7 +1471,7 @@
- Tags +
@@ -1481,7 +1481,7 @@ @click="clearTagFilters()" class="w-full px-2 py-1.5 text-xs rounded flex items-center justify-center gap-1" style="background-color: var(--accent-primary); color: white;" - title="Clear tag filters" + :title="t('tags.clear_all')" > @@ -1503,7 +1503,7 @@ :style="selectedTags.includes(tag) ? 'background-color: var(--accent-primary); color: white; font-weight: 600;' : 'background-color: var(--bg-tertiary); color: var(--text-primary);'" - :title="`Filter by ${tag} (${count} notes)`" + :title="t('tags.filter_by', {tag: tag, count: count})" > @@ -1526,7 +1526,7 @@ @@ -1816,7 +1831,7 @@ style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);" onmouseover="this.style.backgroundColor='var(--bg-hover)'" onmouseout="this.style.backgroundColor='var(--bg-secondary)'" - title="Delete folder" + :title="t('common.delete')" > @@ -1857,7 +1872,7 @@ style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);" onmouseover="this.style.backgroundColor='var(--bg-hover)'" onmouseout="this.style.backgroundColor='var(--bg-secondary)'" - :title="note.type === 'image' ? 'Delete image' : 'Delete note'" + :title="note.type === 'image' ? t('toolbar.delete_image') : t('toolbar.delete_note')" > @@ -1869,7 +1884,7 @@
- +
@@ -1901,7 +1916,7 @@ style="color: var(--text-primary); display: none;" onmouseover="this.style.backgroundColor='var(--bg-hover)'" onmouseout="this.style.backgroundColor='transparent'" - title="Toggle sidebar" + :title="t('sidebar.toggle_sidebar')" > @@ -1924,7 +1939,7 @@ :style="undoHistory.length <= 1 ? 'color: var(--text-tertiary); opacity: 0.4; cursor: not-allowed;' : 'color: var(--text-secondary);'" onmouseover="if(!this.disabled) this.style.backgroundColor='var(--bg-hover)'" onmouseout="this.style.backgroundColor='transparent'" - title="Undo (Ctrl+Z)" + :title="t('toolbar.undo')" > @@ -1939,7 +1954,7 @@ :style="redoHistory.length === 0 ? 'color: var(--text-tertiary); opacity: 0.4; cursor: not-allowed;' : 'color: var(--text-secondary);'" onmouseover="if(!this.disabled) this.style.backgroundColor='var(--bg-hover)'" onmouseout="this.style.backgroundColor='transparent'" - title="Redo (Ctrl+Y)" + :title="t('toolbar.redo')" > @@ -1953,15 +1968,15 @@ style="color: var(--error);" onmouseover="this.style.backgroundColor='var(--bg-hover)'" onmouseout="this.style.backgroundColor='transparent'" - :title="currentImage ? 'Delete image' : 'Delete note'" + :title="currentImage ? t('toolbar.delete_image') : t('toolbar.delete_note')" > - Saving... - ✓ Saved - + + + @@ -1981,8 +1996,8 @@ :style="viewMode === 'edit' ? 'background-color: var(--accent-primary); color: white;' : 'color: var(--text-secondary);'" @mouseenter="if (viewMode !== 'edit') $el.style.backgroundColor = 'var(--bg-secondary)'" @mouseleave="if (viewMode !== 'edit') $el.style.backgroundColor = 'transparent'" + x-text="t('editor.mode_edit')" > - Edit @@ -2013,7 +2028,7 @@ @@ -2493,7 +2508,7 @@