Merge pull request #104 from gamosoft/features/i8n

Features/i8n
This commit is contained in:
Guillermo Villar 2025-12-16 14:41:13 +01:00 committed by GitHub
commit f54a9dd40f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1330 additions and 160 deletions

View File

@ -139,6 +139,65 @@ 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** - It uses `{{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"
## 📚 Contributing Documentation
Documentation improvements are always welcome! Please:

View File

@ -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

View File

@ -1,5 +1,12 @@
# 📝 NoteDiscovery
![GitHub Stars](https://img.shields.io/github/stars/gamosoft/notediscovery?style=flat)
![Build](https://img.shields.io/github/actions/workflow/status/gamosoft/notediscovery/docker-publish.yml)
![Latest Version](https://img.shields.io/github/v/tag/gamosoft/notediscovery)
![License](https://img.shields.io/github/license/gamosoft/notediscovery)
> Your Self-Hosted Knowledge Base
🌐 **[Visit the official website](https://www.notediscovery.com)**
@ -20,6 +27,14 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put
- **Teams** looking for a self-hosted alternative to commercial apps
- **Anyone** who values simplicity, speed, and ownership
## 💖 Thanks for using NoteDiscovery!
If this project has been useful to you, consider supporting its development, it truly makes a difference!
<p align="center">
<a href="https://ko-fi.com/gamosoft"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Buy Me a Coffee at ko-fi.com"></a>
</p>
## ✨ Why NoteDiscovery?
### vs. Commercial Apps (Notion, Evernote, Obsidian Sync)
@ -46,6 +61,7 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put
- 🧮 **Math Support** - LaTeX/MathJax for beautiful equations
- 📄 **HTML Export** - Share notes as standalone HTML files
- 🕸️ **Graph View** - Interactive visualization of connected notes
- ⭐ **Favorites** - Star your most-used notes for instant access
## 🚀 Quick Start
@ -127,6 +143,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 +157,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 +239,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
@ -232,10 +269,6 @@ volumes:
Then access them at `http://localhost:8000` - the docs will appear as a `docs/` folder in the file browser!
## 💖 Support Development
If you find NoteDiscovery useful, consider [☕ buying me a coffee](https://ko-fi.com/gamosoft) to help keep the project going. Every bit helps with new features, bug fixes, and improvements. Thank you!
## 🤝 Contributing
**Before submitting a pull request**, especially for major changes, please:

View File

@ -80,8 +80,9 @@ if 'AUTHENTICATION_SECRET_KEY' in os.environ:
# Initialize app
app = FastAPI(
title=config['app']['name'],
description=config['app']['tagline'],
version=config['app']['version']
version=config['app']['version'],
docs_url=None, # Disable Swagger UI at /docs
redoc_url=None # Disable ReDoc at /redoc
)
# CORS middleware configuration
@ -96,7 +97,8 @@ app.add_middleware(
)
print(f"🌐 CORS allowed origins: {allowed_origins}")
# ============================================================================
# ===========================================================
# =================
# Security Helpers
# ============================================================================
@ -252,14 +254,7 @@ async def login_page(request: Request, error: str = None):
async with aiofiles.open(login_path, 'r', encoding='utf-8') as f:
content = await f.read()
# Inject error message if present
if error:
content = content.replace('<!-- ERROR_CLASS_PLACEHOLDER -->', 'class="error"')
content = content.replace('<!-- ERROR_MESSAGE_PLACEHOLDER -->',
f'<div class="error-message">{error}</div>')
else:
content = content.replace('<!-- ERROR_CLASS_PLACEHOLDER -->', '')
content = content.replace('<!-- ERROR_MESSAGE_PLACEHOLDER -->', '')
# No server-side manipulation needed - frontend handles error display via URL params
return content
@ -280,8 +275,8 @@ async def login(request: Request, password: str = Form(...)):
request.session['authenticated'] = True
return RedirectResponse(url="/", status_code=303)
else:
# Redirect back to login with error message
return RedirectResponse(url="/login?error=Incorrect+password.+Please+try+again.", status_code=303)
# Redirect back to login with error code (frontend will translate)
return RedirectResponse(url="/login?error=incorrect_password", status_code=303)
@app.get("/logout")
@ -325,8 +320,7 @@ async def api_documentation():
return {
"app": {
"name": config['app']['name'],
"version": config['app']['version'],
"description": config['app']['tagline']
"version": config['app']['version']
},
"endpoints": [
{
@ -339,7 +333,7 @@ async def api_documentation():
"method": "GET",
"path": "/api/config",
"description": "Get application configuration",
"response": "{ name, tagline, version, searchEnabled }"
"response": "{ name, version, searchEnabled }"
},
{
"method": "GET",
@ -489,7 +483,6 @@ async def get_config():
"""Get app configuration for frontend"""
return {
"name": config['app']['name'],
"tagline": config['app']['tagline'],
"version": config['app']['version'],
"searchEnabled": config['search']['enabled'],
"demoMode": DEMO_MODE, # Expose demo mode flag to frontend
@ -519,6 +512,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):

View File

@ -1,9 +1,8 @@
# NoteDiscovery Configuration
# Easy to rebrand: just change these values!
app:
name: "NoteDiscovery"
tagline: "Your Self-Hosted Knowledge Base"
# Tagline is now localized - see locales/*.json files (app.tagline key)
server:
host: "0.0.0.0"
@ -28,7 +27,7 @@ search:
authentication:
# Authentication settings
# Set enabled to true to require login
enabled: false
enabled: true
# ⚠️ SECURITY WARNING: Change these values before exposing to the internet!
# Default values below are for LOCAL TESTING ONLY

View File

@ -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

View File

@ -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

View File

@ -844,6 +844,12 @@
<p>8 built-in themes with dark/light modes and full customization.</p>
</div>
<div class="feature animate-on-scroll">
<div class="feature-icon"></div>
<h3>Favorites</h3>
<p>Star your most-used notes for instant access from the sidebar.</p>
</div>
<div class="feature animate-on-scroll">
<div class="feature-icon">🔌</div>
<h3>Extensible</h3>

View File

@ -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.`);
}
}
@ -34,7 +36,6 @@ function noteApp() {
return {
// App state
appName: 'NoteDiscovery',
appTagline: 'Your Self-Hosted Knowledge Base',
appVersion: '0.0.0',
authEnabled: false,
notes: [],
@ -83,6 +84,12 @@ function noteApp() {
currentTheme: 'light',
availableThemes: [],
// Locale/i18n state
currentLocale: localStorage.getItem('locale') || 'en-US',
availableLocales: [],
// Translations loaded from backend (preloaded before Alpine init via window.__preloadedTranslations)
translations: window.__preloadedTranslations || {},
// Syntax highlighting
syntaxHighlightEnabled: false,
syntaxHighlightTimeout: null,
@ -229,7 +236,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 +264,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 +339,9 @@ function noteApp() {
await this.loadConfig();
await this.loadThemes();
await this.initTheme();
await this.loadAvailableLocales();
// Note: Translations are preloaded synchronously before Alpine init (see index.html)
// loadLocale() is only called when user changes language from settings
await this.loadNotes();
await this.loadTemplates();
await this.checkStatsPlugin();
@ -545,7 +567,6 @@ function noteApp() {
const response = await fetch('/api/config');
const config = await response.json();
this.appName = config.name;
this.appTagline = config.tagline;
this.appVersion = config.version || '0.0.0';
this.authEnabled = config.authentication?.enabled || false;
} catch (error) {
@ -761,6 +782,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 +990,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 +1006,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;
}
@ -1128,13 +1211,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)
@ -1356,7 +1439,7 @@ function noteApp() {
</button>
<span class="flex items-center gap-1 flex-1" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; pointer-events: none;">
<span>${folder.name}</span>
${folder.notes.length === 0 && (!folder.children || Object.keys(folder.children).length === 0) ? '<span class="text-xs" style="color: var(--text-tertiary); font-weight: 400;">(empty)</span>' : ''}
${folder.notes.length === 0 && (!folder.children || Object.keys(folder.children).length === 0) ? `<span class="text-xs" style="color: var(--text-tertiary); font-weight: 400;">(${this.t('folders.empty')})</span>` : ''}
</span>
</div>
<div class="hover-buttons flex gap-1 transition-opacity absolute right-2 top-1/2 transform -translate-y-1/2" style="opacity: 0; pointer-events: none; background: linear-gradient(to right, transparent, var(--bg-hover) 20%, var(--bg-hover)); padding-left: 20px;" onclick="event.stopPropagation()">
@ -1678,7 +1761,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;
}
@ -1690,7 +1773,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;
}
@ -1823,7 +1906,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)}`, {
@ -1896,7 +1979,7 @@ function noteApp() {
if (noteByPathCI) {
this.loadNote(noteByPathCI.path);
} else {
alert(`Note not found: ${notePath}`);
alert(this.t('notes.not_found', { path: notePath }));
}
}
}
@ -1996,11 +2079,11 @@ function noteApp() {
}
} else {
const errorData = await response.json().catch(() => ({}));
alert(errorData.detail || 'Failed to move note.');
alert(errorData.detail || 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'));
}
return;
@ -2011,7 +2094,7 @@ function noteApp() {
// Prevent dropping folder into itself or its subfolders
if (targetFolderPath === draggedFolderPath ||
targetFolderPath.startsWith(draggedFolderPath + '/')) {
alert('Cannot move folder into itself or its subfolder.');
alert(this.t('folders.cannot_move_into_self'));
return;
}
@ -2058,11 +2141,11 @@ function noteApp() {
}
} else {
const errorData = await response.json().catch(() => ({}));
alert(errorData.detail || 'Failed to move folder.');
alert(errorData.detail || 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.dropTarget = null;
}
@ -2459,15 +2542,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;
}
@ -2481,7 +2564,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;
}
@ -2520,15 +2603,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;
}
@ -2537,7 +2620,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;
}
@ -2567,12 +2650,12 @@ function noteApp() {
// Rename a folder
async renameFolder(folderPath, currentName) {
const newName = prompt(`Rename folder "${currentName}" to:`, currentName);
const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName);
if (!newName || newName === currentName) return;
const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9-_\s]/g, '');
if (!sanitizedName) {
alert('Invalid folder name.');
alert(this.t('folders.invalid_name'));
return;
}
@ -2630,14 +2713,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;
@ -2945,7 +3021,7 @@ function noteApp() {
const newName = this.currentNoteName.trim();
if (!newName) {
alert('Note name cannot be empty.');
alert(this.t('notes.empty_name'));
return;
}
@ -2957,7 +3033,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;
@ -3003,7 +3079,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}`, {
@ -3799,7 +3875,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'
@ -3809,7 +3885,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);
@ -4083,7 +4159,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;
}
@ -4322,7 +4398,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 }));
}
},
@ -4715,10 +4791,10 @@ function noteApp() {
</div>
<div class="graph-legend-item">
<span class="graph-legend-dot" style="background: ${mdColor};"></span>
<span style="color: ${textColor};">Markdown links</span>
<span style="color: ${textColor};">${this.t('graph.markdown_links')}</span>
</div>
<div style="margin-top: 8px; font-size: 10px; color: ${textColor}; opacity: 0.7;">
Click: select Double-click: open
${this.t('graph.click_hint')}
</div>
`;
container.appendChild(legend);

View File

@ -14,6 +14,35 @@
<!-- Tailwind CSS from CDN (v3.4.17) -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<!-- Preload translations before Alpine initializes -->
<script>
(function() {
// Load translations synchronously to ensure they're available before Alpine renders
const locale = localStorage.getItem('locale') || 'en-US';
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/locales/' + locale, false); // Synchronous request
try {
xhr.send();
if (xhr.status === 200) {
window.__preloadedTranslations = JSON.parse(xhr.responseText);
} else {
// Fallback to en-US if preferred locale fails
if (locale !== 'en-US') {
xhr.open('GET', '/api/locales/en-US', false);
xhr.send();
if (xhr.status === 200) {
window.__preloadedTranslations = JSON.parse(xhr.responseText);
localStorage.setItem('locale', 'en-US');
}
}
}
} catch (e) {
console.error('Failed to preload translations:', e);
window.__preloadedTranslations = {};
}
})();
</script>
<!-- Alpine.js (v3.14.1) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
@ -1087,7 +1116,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')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
@ -1119,7 +1148,7 @@
alt="NoteDiscovery"
class="icon-rail-logo"
@click="goHome()"
title="Go to homepage"
:title="t('sidebar.go_to_homepage')"
>
<!-- Files -->
@ -1127,7 +1156,7 @@
class="icon-rail-btn"
:class="{'active': activePanel === 'files'}"
@click="activePanel = 'files'"
title="Files"
:title="t('sidebar.files')"
aria-label="Files panel"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1140,7 +1169,7 @@
class="icon-rail-btn relative"
:class="{'active': activePanel === 'search'}"
@click="activePanel = 'search'"
title="Search"
:title="t('sidebar.search')"
aria-label="Search panel"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1161,7 +1190,7 @@
class="icon-rail-btn relative"
:class="{'active': activePanel === 'tags'}"
@click="activePanel = 'tags'"
title="Tags"
:title="t('tags.title')"
aria-label="Tags panel"
>
<svg fill="currentColor" viewBox="0 0 20 20">
@ -1184,7 +1213,7 @@
class="icon-rail-btn"
:class="{'active': showGraph}"
@click="showGraph = true; initGraph()"
title="Graph View"
:title="t('graph.title')"
aria-label="Graph view"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1197,7 +1226,7 @@
class="icon-rail-btn"
:class="{'active': activePanel === 'settings'}"
@click="activePanel = 'settings'"
title="Settings"
:title="t('sidebar.settings')"
aria-label="Settings panel"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1214,7 +1243,7 @@
<div x-show="activePanel === 'files'" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="flex flex-col h-full">
<!-- Header with + New -->
<div class="flex-shrink-0 px-2 py-2 border-b flex items-center justify-between" style="border-color: var(--border-primary);">
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);">Files</span>
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);" x-text="t('sidebar.files')"></span>
<button
@click="dropdownTargetFolder = ''; toggleNewDropdown($event)"
class="px-2 py-1 text-xs font-medium text-white rounded focus:outline-none flex items-center gap-1"
@ -1242,10 +1271,7 @@
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
<div class="flex items-center gap-1.5">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24" style="color: var(--warning, #eab308);">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
</svg>
<span class="text-xs font-semibold uppercase tracking-wide">Favorites</span>
<span class="text-xs font-semibold uppercase tracking-wide" x-text="t('sidebar.favorites_title')"></span>
</div>
<svg
class="w-3 h-3 transition-transform"
@ -1275,7 +1301,7 @@
<button
@click.stop="toggleFavorite(fav.path)"
class="opacity-0 group-hover:opacity-100 p-0.5 rounded transition-opacity"
title="Remove from favorites"
:title="t('toolbar.remove_favorite')"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
@ -1290,19 +1316,19 @@
<div class="text-xs px-2 py-1 mb-2" style="color: var(--text-tertiary);">
<div class="flex items-center justify-between mb-1">
<span>Folders & Notes</span>
<span x-text="t('sidebar.folders_and_notes')"></span>
<div class="flex gap-1">
<button
@click="expandAllFolders()"
class="text-xs px-2 py-0.5 rounded hover:bg-opacity-20"
style="color: var(--text-secondary);"
title="Expand all folders"
:title="t('sidebar.expand_all')"
>⊞</button>
<button
@click="collapseAllFolders()"
class="text-xs px-2 py-0.5 rounded hover:bg-opacity-20"
style="color: var(--text-secondary);"
title="Collapse all folders"
:title="t('sidebar.collapse_all')"
>⊟</button>
</div>
</div>
@ -1326,8 +1352,8 @@
@drop="if(draggedNote || draggedFolder) onFolderDrop('')"
style="height: 28px; line-height: 20px; display: flex; align-items: center; justify-content: center;"
>
<span x-show="!draggedNote && !draggedFolder && !draggedItem">💡 Drag=Move </span>
<span x-show="draggedNote || draggedFolder">📂 Root folder</span>
<span x-show="!draggedNote && !draggedFolder && !draggedItem" x-text="t('sidebar.drag_hint')"></span>
<span x-show="draggedNote || draggedFolder" x-text="t('sidebar.root_folder')"></span>
</div>
</div>
@ -1356,7 +1382,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')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -1368,7 +1394,8 @@
<!-- Empty state -->
<template x-if="notes.length === 0 && allFolders.length === 0">
<div class="px-3 py-4 text-sm text-center" style="color: var(--text-tertiary);">
No notes or folders yet.<br>Create your first note or folder!
<span x-text="t('sidebar.no_notes_yet')"></span><br>
<span x-text="t('sidebar.create_first')"></span>
</div>
</template>
</div>
@ -1379,7 +1406,7 @@
<div x-show="activePanel === 'search'" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="flex flex-col h-full">
<!-- Search Header -->
<div class="flex-shrink-0 px-3 py-2 border-b" style="border-color: var(--border-primary);">
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);">Search</span>
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);" x-text="t('sidebar.search_title')"></span>
</div>
<!-- Search Input -->
@ -1389,7 +1416,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);"
>
@ -1401,7 +1428,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')"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
@ -1417,10 +1444,10 @@
>
<span x-text="`Match ${currentMatchIndex + 1} of ${totalMatches}`"></span>
<div class="flex gap-1">
<button @click="previousMatch()" class="px-2 py-1 rounded hover:bg-opacity-80" style="background-color: var(--bg-primary); color: var(--text-primary);" title="Previous (Shift+F3)">
<button @click="previousMatch()" class="px-2 py-1 rounded hover:bg-opacity-80" style="background-color: var(--bg-primary); color: var(--text-primary);" :title="t('search.previous')">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
</button>
<button @click="nextMatch()" class="px-2 py-1 rounded hover:bg-opacity-80" style="background-color: var(--bg-primary); color: var(--text-primary);" title="Next (F3)">
<button @click="nextMatch()" class="px-2 py-1 rounded hover:bg-opacity-80" style="background-color: var(--bg-primary); color: var(--text-primary);" :title="t('search.next')">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</button>
</div>
@ -1461,7 +1488,7 @@
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" style="color: var(--text-tertiary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<p class="text-sm" style="color: var(--text-tertiary);">Type to search your notes</p>
<p class="text-sm" style="color: var(--text-tertiary);" x-text="t('sidebar.search_hint')"></p>
</div>
</template>
</div>
@ -1472,7 +1499,7 @@
<div x-show="activePanel === 'tags'" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="flex flex-col h-full">
<!-- Tags Header -->
<div class="flex-shrink-0 px-3 py-2 border-b flex items-center justify-between" style="border-color: var(--border-primary);">
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);">Tags</span>
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);" x-text="t('tags.title')"></span>
<span class="text-xs px-1.5 py-0.5 rounded" style="background-color: var(--bg-tertiary); color: var(--text-tertiary);" x-text="Object.keys(allTags).length"></span>
</div>
@ -1482,7 +1509,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')"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
@ -1504,7 +1531,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})"
>
<span x-text="tag"></span>
<span class="opacity-75" x-text="` (${count})`"></span>
@ -1527,7 +1554,7 @@
<template x-if="selectedTags.length > 0">
<div class="border-t" style="border-color: var(--border-primary);">
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);">
Filtered Notes (<span x-text="searchResults.length"></span>)
<span x-text="t('sidebar.filtered_notes')"></span> (<span x-text="searchResults.length"></span>)
</div>
<div>
<template x-for="note in searchResults" :key="note.path">
@ -1556,7 +1583,7 @@
<div class="flex flex-col h-full">
<!-- Settings Header -->
<div class="flex-shrink-0 px-3 py-2 border-b" style="border-color: var(--border-primary);">
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);">Settings</span>
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);" x-text="t('sidebar.settings_title')"></span>
</div>
<!-- Scrollable content -->
@ -1564,7 +1591,7 @@
<div class="p-3">
<!-- Theme -->
<div class="mb-4">
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);">Theme</label>
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);" x-text="t('theme.title')">Theme</label>
<select
x-model="currentTheme"
@change="setTheme(currentTheme)"
@ -1577,10 +1604,25 @@
</select>
</div>
<!-- Language -->
<div class="mb-4">
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);" x-text="t('language.title')">Language</label>
<select
x-model="currentLocale"
@change="changeLocale(currentLocale)"
class="w-full px-2 py-2 text-sm rounded focus:outline-none focus:ring-2 focus:ring-opacity-50"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); cursor: pointer;"
>
<template x-for="loc in availableLocales" :key="loc.code">
<option :value="loc.code" :selected="loc.code === currentLocale" x-text="loc.flag + ' ' + loc.name"></option>
</template>
</select>
</div>
<!-- Syntax Highlighting Toggle -->
<div class="mb-4">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs font-medium" style="color: var(--text-secondary);">Editor Syntax Highlight</span>
<span class="text-xs font-medium" style="color: var(--text-secondary);" x-text="t('syntax_highlight.title')"></span>
<div
@click="toggleSyntaxHighlight()"
class="relative w-10 h-5 rounded-full transition-colors cursor-pointer"
@ -1592,20 +1634,20 @@
></div>
</div>
</label>
<p class="text-xs mt-1" style="color: var(--text-tertiary);">Colorize markdown syntax in editor</p>
<p class="text-xs mt-1" style="color: var(--text-tertiary);" x-text="t('syntax_highlight.description')"></p>
</div>
<!-- Logout (if auth enabled) - uses authEnabled from main app state -->
<div x-show="authEnabled" class="mb-4">
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);">Account</label>
<label class="block text-xs font-medium mb-2" style="color: var(--text-secondary);" x-text="t('settings.account')"></label>
<a
href="/logout"
class="flex items-center justify-center gap-2 px-3 py-2 text-sm rounded transition-colors"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='var(--bg-primary)'"
x-text="'🔒 ' + t('settings.logout')"
>
🔒 Logout
</a>
</div>
</div>
@ -1613,7 +1655,7 @@
<!-- Footer Info (inside scroll area) -->
<div class="p-3 mt-4 border-t text-center" style="border-color: var(--border-primary);">
<div class="text-xs" style="color: var(--text-tertiary);">
<span x-text="notes.length"></span> notes
<span x-text="notes.length"></span> <span x-text="notes.length === 1 ? t('homepage.note_singular') : t('homepage.note_plural')"></span>
<span class="mx-1">·</span>
<span x-text="'v' + appVersion"></span>
</div>
@ -1688,7 +1730,7 @@
style="color: var(--text-primary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='transparent'"
title="Toggle sidebar"
:title="t('sidebar.toggle_sidebar')"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
@ -1702,8 +1744,8 @@
<svg class="mx-auto h-24 w-24 mb-4" style="color: var(--text-tertiary); opacity: 0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary);">No notes yet</h2>
<p class="mb-6" style="color: var(--text-secondary);">Create your first note or folder</p>
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary);" x-text="t('homepage.no_notes_title')"></h2>
<p class="mb-6" style="color: var(--text-secondary);" x-text="t('homepage.no_notes_desc')"></p>
<button
@click="dropdownTargetFolder = selectedHomepageFolder; toggleNewDropdown($event)"
class="px-6 py-3 text-sm font-medium text-white rounded-lg transition-colors"
@ -1711,7 +1753,7 @@
onmouseover="this.style.backgroundColor='var(--accent-hover)'"
onmouseout="this.style.backgroundColor='var(--accent-primary)'"
>
+ New
<span x-text="t('sidebar.new_button')"></span>
</button>
</div>
</template>
@ -1722,7 +1764,7 @@
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);" x-text="appName"></h1>
<p class="text-lg" style="color: var(--text-secondary);" x-text="appTagline"></p>
<p class="text-lg" style="color: var(--text-secondary);" x-text="t('app.tagline')"></p>
<!-- Demo Mode Warning Banner -->
<div x-data="{ showDemoBanner: false }" x-init="fetch('/api/config').then(r => r.json()).then(d => showDemoBanner = d.demoMode || false)" x-show="showDemoBanner" class="mt-4 flex items-center px-4 py-2.5 text-sm rounded-lg" style="background: linear-gradient(135deg, #ff6b6b 0%, #ff8e53 100%); color: white; font-weight: 500;">
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1759,11 +1801,11 @@
<div class="flex flex-wrap items-center gap-4 text-sm" style="color: var(--text-secondary);">
<span class="font-semibold" style="color: var(--text-primary);">
<span x-text="homepageNotes().length"></span>
<span x-text="homepageNotes().length === 1 ? 'note' : 'notes'"></span>
<span x-text="homepageNotes().length === 1 ? t('homepage.note_singular') : t('homepage.note_plural')"></span>
</span>
<span x-show="homepageFolders().length > 0">
<span x-text="homepageFolders().length"></span>
<span x-text="homepageFolders().length === 1 ? 'folder' : 'folders'"></span>
<span x-text="homepageFolders().length === 1 ? t('homepage.folder_singular') : t('homepage.folder_plural')"></span>
</span>
<button
@click="dropdownTargetFolder = selectedHomepageFolder; toggleNewDropdown($event)"
@ -1772,7 +1814,7 @@
onmouseover="this.style.backgroundColor='var(--accent-hover)'"
onmouseout="this.style.backgroundColor='var(--accent-primary)'"
>
+ New
<span x-text="t('sidebar.new_button')"></span>
</button>
</div>
</div>
@ -1784,8 +1826,8 @@
<svg class="mx-auto h-24 w-24 mb-4" style="color: var(--text-tertiary); opacity: 0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary);" x-text="selectedHomepageFolder ? 'This folder is empty' : 'No notes yet'"></h2>
<p class="mb-6" style="color: var(--text-secondary);" x-text="selectedHomepageFolder ? 'Create something to get started' : 'Create your first note or folder'"></p>
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary);" x-text="selectedHomepageFolder ? t('homepage.folder_empty') : t('homepage.no_notes_title')"></h2>
<p class="mb-6" style="color: var(--text-secondary);" x-text="selectedHomepageFolder ? t('homepage.get_started') : t('homepage.no_notes_desc')"></p>
<button
@click="dropdownTargetFolder = selectedHomepageFolder; toggleNewDropdown($event)"
class="px-6 py-3 text-sm font-medium text-white rounded-lg transition-colors"
@ -1793,7 +1835,7 @@
onmouseover="this.style.backgroundColor='var(--accent-hover)'"
onmouseout="this.style.backgroundColor='var(--accent-primary)'"
>
+ New
<span x-text="t('sidebar.new_button')"></span>
</button>
</div>
</template>
@ -1817,7 +1859,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')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -1834,7 +1876,7 @@
<!-- Folder Metadata -->
<div class="flex items-center justify-between text-xs mt-auto pt-2" style="color: var(--text-tertiary);">
<span x-text="folder.noteCount + ' ' + (folder.noteCount === 1 ? 'note' : 'notes')"></span>
<span x-text="folder.noteCount + ' ' + (folder.noteCount === 1 ? t('homepage.note_singular') : t('homepage.note_plural'))"></span>
<svg class="w-4 h-4" style="color: var(--accent-primary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
@ -1858,7 +1900,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')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -1870,7 +1912,7 @@
<!-- Metadata -->
<div class="flex items-center justify-between text-xs mt-auto pt-2" style="color: var(--text-tertiary);">
<span x-text="new Date(note.modified).toLocaleDateString()"></span>
<span x-text="formatDate(note.modified)"></span>
<span x-text="formatSize(note.size)"></span>
</div>
</div>
@ -1902,7 +1944,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')"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
@ -1925,7 +1967,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')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path>
@ -1940,7 +1982,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')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 10h-10a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6"></path>
@ -1954,15 +1996,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')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
<span x-show="isSaving" class="text-xs" style="color: var(--text-secondary);">Saving...</span>
<span x-show="!isSaving && lastSaved" class="text-xs" style="color: var(--success);">✓ Saved</span>
<span x-show="!isSaving && !lastSaved && lastEditedText" class="text-xs hidden md:inline" style="color: var(--text-tertiary);" x-text="'Edited ' + lastEditedText"></span>
<span x-show="isSaving" class="text-xs" style="color: var(--text-secondary);" x-text="t('common.saving')"></span>
<span x-show="!isSaving && lastSaved" class="text-xs" style="color: var(--success);" x-text="t('common.saved')"></span>
<span x-show="!isSaving && !lastSaved && lastEditedText" class="text-xs hidden md:inline" style="color: var(--text-tertiary);" x-text="t('editor.edited', {time: lastEditedText})"></span>
</div>
<!-- Demo Mode Warning Banner -->
@ -1982,8 +2024,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
</button>
<button
@click="viewMode = 'split'"
@ -1991,8 +2033,8 @@
:style="viewMode === 'split' ? 'background-color: var(--accent-primary); color: white;' : 'color: var(--text-secondary);'"
@mouseenter="if (viewMode !== 'split') $el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="if (viewMode !== 'split') $el.style.backgroundColor = 'transparent'"
x-text="t('editor.mode_split')"
>
Split
</button>
<button
@click="viewMode = 'preview'"
@ -2000,8 +2042,8 @@
:style="viewMode === 'preview' ? 'background-color: var(--accent-primary); color: white;' : 'color: var(--text-secondary);'"
@mouseenter="if (viewMode !== 'preview') $el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="if (viewMode !== 'preview') $el.style.backgroundColor = 'transparent'"
x-text="t('editor.mode_preview')"
>
Preview
</button>
</div>
@ -2014,7 +2056,7 @@
<button
@click="toggleFavorite()"
class="p-2 transition"
:title="isFavorite(currentNote) ? 'Remove from favorites' : 'Add to favorites'"
:title="isFavorite(currentNote) ? t('toolbar.remove_favorite') : t('toolbar.add_favorite')"
:style="'color: ' + (isFavorite(currentNote) ? 'var(--warning, #eab308)' : 'var(--text-secondary)') + ';'"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
@ -2033,7 +2075,7 @@
@click="exportToHTML()"
class="p-2 transition"
style="color: var(--text-secondary);"
title="Export as HTML"
:title="t('toolbar.export_html')"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
@ -2045,7 +2087,7 @@
<button
@click="copyNoteLink()"
class="p-2 transition hover:bg-opacity-80"
:title="linkCopied ? 'Copied!' : 'Copy link to clipboard'"
:title="linkCopied ? t('common.copied') : t('toolbar.copy_link')"
:style="'color: ' + (linkCopied ? 'var(--success, #22c55e)' : 'var(--text-secondary)') + ';'"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
@ -2064,7 +2106,7 @@
@click="toggleZenMode()"
class="p-2 transition"
style="color: var(--text-secondary);"
title="Zen Mode (Ctrl+Alt+Z)"
:title="t('zen_mode.tooltip')"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
@ -2108,7 +2150,7 @@
@paste="handlePaste($event)"
:class="'flex-1 w-full resize-none focus:outline-none editor-textarea' + (draggedItem && dropTarget === 'editor' ? ' link-drop-target' : '')"
:style="'background-color: var(--bg-primary); color: var(--text-primary);' + (draggedItem && dropTarget === 'editor' ? ' outline: 2px dashed var(--accent-primary); outline-offset: -2px; box-shadow: 0 0 20px var(--accent-light);' : '')"
:placeholder="draggedItem && dropTarget === 'editor' ? '💡 Drop here to insert at cursor position...' : 'Start writing in markdown...'"
:placeholder="draggedItem && dropTarget === 'editor' ? t('editor.drop_hint') : t('editor.placeholder')"
></textarea>
</div>
</div>
@ -2431,7 +2473,7 @@
onmouseout="this.style.backgroundColor='transparent'"
>
<span class="text-lg">📝</span>
<span>New Note</span>
<span x-text="t('sidebar.new_note')"></span>
</button>
<button
@click="createFolder()"
@ -2441,7 +2483,7 @@
onmouseout="this.style.backgroundColor='transparent'"
>
<span class="text-lg">📁</span>
<span>New Folder</span>
<span x-text="t('sidebar.new_folder')"></span>
</button>
<button
@click.stop="showTemplateModal = true; showNewDropdown = false; mobileSidebarOpen = false"
@ -2451,7 +2493,7 @@
onmouseout="this.style.backgroundColor='transparent'"
>
<span class="text-lg">📄</span>
<span>New from Template</span>
<span x-text="t('sidebar.new_from_template')"></span>
</button>
</div>
</div>
@ -2467,19 +2509,18 @@
style="background-color: var(--bg-secondary); color: var(--text-primary);"
@click.stop>
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary);">Create from Template</h2>
<h2 class="text-xl font-bold mb-4" style="color: var(--text-primary);" x-text="t('templates.create_from_template')"></h2>
<!-- Template Selection -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">
Select Template
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);" x-text="t('templates.select_template')">
</label>
<select
x-model="selectedTemplate"
class="w-full px-3 py-2 rounded border"
style="background-color: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-primary);"
>
<option value="">-- Choose a template --</option>
<option value="" x-text="'-- ' + t('templates.choose_template') + ' --'"></option>
<template x-for="template in availableTemplates" :key="template.name">
<option :value="template.name" x-text="template.name"></option>
</template>
@ -2488,13 +2529,12 @@
<!-- Note Name Input -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);">
Note Name
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary);" x-text="t('templates.note_name')">
</label>
<input
x-model="newTemplateNoteName"
type="text"
placeholder="Enter note name"
:placeholder="t('notes.prompt_name')"
class="w-full px-3 py-2 rounded border"
style="background-color: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-primary);"
@keydown.enter="createNoteFromTemplate()"
@ -2503,7 +2543,7 @@
<!-- Info about placeholders -->
<div class="mb-4 text-xs p-3 rounded" style="background-color: var(--bg-tertiary); color: var(--text-secondary);">
<strong>Available placeholders:</strong><br>
<strong x-text="t('templates.available_placeholders') + ':'"></strong><br>
<code style="font-family: monospace;">{{date}}</code>,
<code style="font-family: monospace;">{{time}}</code>,
<code style="font-family: monospace;">{{datetime}}</code>,
@ -2512,8 +2552,7 @@
</div>
<!-- Empty state when no templates available -->
<div x-show="availableTemplates.length === 0" class="mb-4 text-sm p-3 rounded" style="background-color: var(--bg-tertiary); color: var(--text-secondary);">
No templates found. Create templates in the <code style="font-family: monospace;">_templates</code> folder.
<div x-show="availableTemplates.length === 0" class="mb-4 text-sm p-3 rounded" style="background-color: var(--bg-tertiary); color: var(--text-secondary);" x-text="t('templates.no_templates')">
</div>
<!-- Actions -->
@ -2526,8 +2565,8 @@
:style="(!selectedTemplate || !newTemplateNoteName.trim() || availableTemplates.length === 0) ? 'opacity: 0.5; cursor: not-allowed;' : ''"
onmouseover="if(!this.disabled) this.style.backgroundColor='var(--accent-hover)'"
onmouseout="if(!this.disabled) this.style.backgroundColor='var(--accent-primary)'"
x-text="t('templates.create_note')"
>
Create Note
</button>
<button
@click="showTemplateModal = false; selectedTemplate = ''; newTemplateNoteName = ''"
@ -2535,8 +2574,8 @@
style="background-color: var(--bg-tertiary); color: var(--text-primary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='var(--bg-tertiary)'"
x-text="t('common.cancel')"
>
Cancel
</button>
</div>
</div>

View File

@ -6,6 +6,40 @@
<title>Login - NoteDiscovery</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<!-- Preload translations (same approach as index.html) -->
<script>
(function() {
const locale = localStorage.getItem('locale') || 'en-US';
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/locales/' + locale, false);
try {
xhr.send();
if (xhr.status === 200) {
window.__translations = JSON.parse(xhr.responseText);
} else if (locale !== 'en-US') {
xhr.open('GET', '/api/locales/en-US', false);
xhr.send();
if (xhr.status === 200) {
window.__translations = JSON.parse(xhr.responseText);
}
}
} catch (e) {
console.error('Failed to preload translations:', e);
window.__translations = {};
}
// Simple translation function
window.t = function(key) {
const keys = key.split('.');
let value = window.__translations;
for (const k of keys) {
value = value?.[k];
}
return typeof value === 'string' ? value : key;
};
})();
</script>
<!-- Theme styles will be loaded dynamically -->
<script>
// Load theme immediately (same approach as index.html)
@ -191,7 +225,7 @@
<div class="login-container">
<img src="/static/logo.svg" alt="NoteDiscovery" class="logo">
<h1>NoteDiscovery</h1>
<p class="tagline">Your Self-Hosted Knowledge Base</p>
<p class="tagline" id="tagline"></p>
<form method="post" action="/login">
<div>
@ -199,18 +233,16 @@
type="password"
name="password"
id="password"
placeholder="Enter your password"
required
autofocus
<!-- ERROR_CLASS_PLACEHOLDER -->
>
<!-- ERROR_MESSAGE_PLACEHOLDER -->
<div class="error-message" id="error-message" style="display: none;"></div>
</div>
<button type="submit">🔓 Unlock</button>
<button type="submit" id="submit-btn"></button>
</form>
<div class="footer">
🔒 Secure & Self-Hosted
<span id="footer-text"></span>
<div style="margin-top: 8px; font-size: 12px; opacity: 0.6;">
<a href="https://www.notediscovery.com" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
<svg style="width: 14px; height: 14px;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -221,6 +253,26 @@
</div>
</div>
</div>
<script>
// Apply translations after DOM is ready
document.getElementById('tagline').textContent = t('login.tagline');
document.getElementById('password').placeholder = t('login.password_placeholder');
document.getElementById('submit-btn').textContent = t('login.unlock_button');
document.getElementById('footer-text').textContent = t('login.footer');
document.title = t('login.title') + ' - NoteDiscovery';
// Handle error from URL params
var urlParams = new URLSearchParams(window.location.search);
var errorCode = urlParams.get('error');
if (errorCode) {
var errorEl = document.getElementById('error-message');
var passwordEl = document.getElementById('password');
errorEl.textContent = t('login.error_' + errorCode);
errorEl.style.display = 'flex';
passwordEl.classList.add('error');
}
</script>
</body>
</html>

216
locales/de-DE.json Normal file
View File

@ -0,0 +1,216 @@
{
"_meta": {
"code": "de-DE",
"name": "Deutsch",
"flag": "🇩🇪"
},
"app": {
"tagline": "Deine Selbstgehostete Wissensdatenbank"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"rename": "Umbenennen",
"create": "Erstellen",
"close": "Schließen",
"yes": "✓ Ja",
"no": "✗ Nein",
"ok": "OK",
"error": "Fehler",
"loading": "Laden...",
"saved": "✓ Gespeichert",
"saving": "Speichern...",
"copied": "✓ Kopiert!",
"failed": "{{action}} fehlgeschlagen. Bitte versuche es erneut."
},
"sidebar": {
"title": "DATEIEN",
"favorites_title": "Favoriten",
"folders_and_notes": "Ordner & Notizen",
"new_button": "+ Neu",
"new_note": "Neue Notiz",
"new_folder": "Neuer Ordner",
"new_from_template": "Neu aus Vorlage",
"search_placeholder": "Notizen durchsuchen...",
"search_hint": "Tippe, um deine Notizen zu durchsuchen",
"clear_search": "Suche löschen",
"drag_hint": "💡 Ziehen=Verschieben",
"root_folder": "📂 Hauptordner",
"no_notes": "Noch keine Notizen. Erstelle deine erste Notiz!",
"no_notes_yet": "Noch keine Notizen oder Ordner.",
"create_first": "Erstelle deine erste Notiz oder Ordner!",
"no_favorites": "Noch keine Favoriten",
"no_results": "Keine Ergebnisse gefunden",
"expand_all": "Alle Ordner aufklappen",
"collapse_all": "Alle Ordner zuklappen",
"toggle_sidebar": "Seitenleiste umschalten",
"go_to_homepage": "Zur Startseite",
"files": "Dateien",
"search": "Suchen",
"search_title": "SUCHE",
"settings": "Einstellungen",
"settings_title": "EINSTELLUNGEN",
"filtered_notes": "Gefilterte Notizen"
},
"editor": {
"placeholder": "Beginne in Markdown zu schreiben...",
"drop_hint": "💡 Hier ablegen, um an Cursorposition einzufügen...",
"mode_edit": "Bearbeiten",
"mode_split": "Teilen",
"mode_preview": "Vorschau",
"edited": "Bearbeitet {{time}}",
"just_now": "gerade eben",
"minutes_ago": "vor {{count}}m",
"hours_ago": "vor {{count}}h",
"days_ago": "vor {{count}}T"
},
"notes": {
"confirm_delete": "\"{{name}}\" löschen?",
"already_exists": "Eine Notiz mit dem Namen \"{{name}}\" existiert bereits an diesem Ort.\nBitte wähle einen anderen Namen.",
"prompt_name": "Notizname eingeben:",
"prompt_name_in_folder": "Notiz in \"{{folder}}\" erstellen.\nNotizname eingeben:",
"prompt_name_with_path": "Notizname eingeben (du kannst Ordner/Name verwenden):",
"prompt_rename": "Neuen Namen eingeben:",
"invalid_name": "Ungültiger Notizname.",
"empty_name": "Der Notizname darf nicht leer sein.",
"not_found": "Notiz nicht gefunden: {{path}}",
"no_content": "Kein Inhalt zum Exportieren",
"open_first": "Bitte öffne zuerst eine Notiz, bevor du Bilder hochlädst."
},
"folders": {
"confirm_delete": "⚠️ WARNUNG ⚠️\n\nBist du sicher, dass du den Ordner \"{{name}}\" löschen möchtest?\n\nDies wird DAUERHAFT löschen:\n• Alle Notizen in diesem Ordner\n• Alle Unterordner und deren Inhalte\n\nDiese Aktion kann NICHT rückgängig gemacht werden!",
"already_exists": "Ein Ordner mit dem Namen \"{{name}}\" existiert bereits an diesem Ort.\nBitte wähle einen anderen Namen.",
"prompt_name": "Ordnername eingeben:",
"prompt_name_in_folder": "Unterordner in \"{{folder}}\" erstellen.\nOrdnername eingeben:",
"prompt_name_with_path": "Neuen Ordner erstellen.\nOrdnerpfad eingeben (z.B. \"Projekte\" oder \"Arbeit/2025\"):",
"prompt_rename": "Ordner \"{{name}}\" umbenennen zu:",
"invalid_name": "Ungültiger Ordnername.",
"cannot_move_into_self": "Ordner kann nicht in sich selbst oder einen Unterordner verschoben werden.",
"empty": "leer"
},
"toolbar": {
"undo": "Rückgängig (Strg+Z)",
"redo": "Wiederholen (Strg+Y)",
"delete_note": "Notiz löschen",
"delete_image": "Bild löschen",
"export_html": "Als HTML exportieren",
"copy_link": "Link in Zwischenablage kopieren",
"add_favorite": "Zu Favoriten hinzufügen",
"remove_favorite": "Aus Favoriten entfernen"
},
"zen_mode": {
"title": "Zen-Modus",
"tooltip": "Zen-Modus (Strg+Alt+Z)",
"exit": "Zen-Modus beenden",
"exit_hint": "Zen-Modus beenden (Esc)"
},
"tags": {
"title": "Schlagwörter",
"no_tags": "Keine Schlagwörter gefunden",
"clear_all": "Schlagwort-Filter löschen",
"filter_by": "Nach {{tag}} filtern ({{count}} Notizen)"
},
"stats": {
"words": "Wörter",
"reading_time": "~{{minutes}}m Lesezeit",
"links": "{{count}} Links",
"click_details": "▼ Klicken für Details"
},
"graph": {
"title": "Graphansicht",
"empty": "Keine Verbindungen anzuzeigen",
"markdown_links": "Markdown-Links",
"click_hint": "Klick: auswählen • Doppelklick: öffnen"
},
"templates": {
"title": "Vorlagen",
"select": "Vorlage auswählen...",
"prompt_name": "Notizname eingeben:",
"no_templates": "Keine Vorlagen gefunden. Erstelle Vorlagen im _templates Ordner.",
"create_failed": "Erstellung der Notiz aus Vorlage fehlgeschlagen",
"create_from_template": "Aus Vorlage erstellen",
"select_template": "Vorlage auswählen",
"choose_template": "Vorlage wählen",
"note_name": "Notizname",
"available_placeholders": "Verfügbare Platzhalter",
"create_note": "Notiz erstellen"
},
"export": {
"failed": "HTML-Export fehlgeschlagen: {{error}}"
},
"login": {
"title": "Anmelden",
"tagline": "Deine Selbstgehostete Wissensdatenbank",
"password_placeholder": "Passwort eingeben",
"unlock_button": "🔓 Entsperren",
"footer": "🔒 Sicher & Selbstgehostet",
"error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut."
},
"images": {
"confirm_delete": "Bild \"{{name}}\" löschen?",
"upload_failed": "Bild-Upload fehlgeschlagen",
"no_valid_files": "Keine gültigen Bilddateien gefunden. Unterstützte Formate: JPG, PNG, GIF, WEBP",
"formats": "Unterstützt JPG, PNG, GIF, WebP (max 10MB)"
},
"move": {
"failed_note": "Verschieben der Notiz fehlgeschlagen.",
"failed_folder": "Verschieben des Ordners fehlgeschlagen."
},
"search": {
"previous": "Vorheriger (Umschalt+F3)",
"next": "Nächster (F3)",
"matches": "{{current}} von {{total}}"
},
"theme": {
"title": "Design"
},
"language": {
"title": "Sprache"
},
"syntax_highlight": {
"title": "Editor-Syntaxhervorhebung",
"description": "Markdown-Syntax im Editor einfärben",
"enable": "Syntaxhervorhebung aktivieren",
"disable": "Syntaxhervorhebung deaktivieren"
},
"settings": {
"account": "Konto",
"logout": "Abmelden"
},
"homepage": {
"title": "Startseite",
"welcome": "Willkommen bei NoteDiscovery",
"get_started": "Erstelle etwas, um loszulegen",
"no_notes_title": "Noch keine Notizen",
"no_notes_desc": "Erstelle deine erste Notiz oder Ordner",
"folder_empty": "Dieser Ordner ist leer",
"note_singular": "Notiz",
"note_plural": "Notizen",
"folder_singular": "Ordner",
"folder_plural": "Ordner"
}
}

216
locales/en-US.json Normal file
View File

@ -0,0 +1,216 @@
{
"_meta": {
"code": "en-US",
"name": "English",
"flag": "🇺🇸"
},
"app": {
"tagline": "Your Self-Hosted Knowledge Base"
},
"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!",
"failed": "Failed to {{action}}. Please try again."
},
"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. Create your first note!",
"no_notes_yet": "No notes or folders yet.",
"create_first": "Create your first note or folder!",
"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.",
"empty": "empty"
},
"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 found. Create templates in the _templates folder.",
"create_failed": "Failed to create note from template",
"create_from_template": "Create from Template",
"select_template": "Select Template",
"choose_template": "Choose a template",
"note_name": "Note Name",
"available_placeholders": "Available placeholders",
"create_note": "Create Note"
},
"export": {
"failed": "Failed to export HTML: {{error}}"
},
"login": {
"title": "Login",
"tagline": "Your Self-Hosted Knowledge Base",
"password_placeholder": "Enter your password",
"unlock_button": "🔓 Unlock",
"footer": "🔒 Secure & Self-Hosted",
"error_incorrect_password": "Incorrect password. Please try again."
},
"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",
"description": "Colorize markdown syntax in editor",
"enable": "Enable syntax highlighting",
"disable": "Disable syntax highlighting"
},
"settings": {
"account": "Account",
"logout": "Logout"
},
"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",
"note_singular": "note",
"note_plural": "notes",
"folder_singular": "folder",
"folder_plural": "folders"
}
}

216
locales/es-ES.json Normal file
View File

@ -0,0 +1,216 @@
{
"_meta": {
"code": "es-ES",
"name": "Español",
"flag": "🇪🇸"
},
"app": {
"tagline": "Tu Base de Conocimientos Autoalojada"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"rename": "Renombrar",
"create": "Crear",
"close": "Cerrar",
"yes": "✓ Sí",
"no": "✗ No",
"ok": "Aceptar",
"error": "Error",
"loading": "Cargando...",
"saved": "✓ Guardado",
"saving": "Guardando...",
"copied": "✓ ¡Copiado!",
"failed": "Error al {{action}}. Por favor, inténtalo de nuevo."
},
"sidebar": {
"title": "ARCHIVOS",
"favorites_title": "Favoritos",
"folders_and_notes": "Carpetas y Notas",
"new_button": "+ Nuevo",
"new_note": "Nueva Nota",
"new_folder": "Nueva Carpeta",
"new_from_template": "Nueva desde Plantilla",
"search_placeholder": "Buscar notas...",
"search_hint": "Escribe para buscar tus notas",
"clear_search": "Limpiar búsqueda",
"drag_hint": "💡 Arrastrar=Mover",
"root_folder": "📂 Carpeta raíz",
"no_notes": "Aún no hay notas. ¡Crea tu primera nota!",
"no_notes_yet": "Aún no hay notas ni carpetas.",
"create_first": "¡Crea tu primera nota o carpeta!",
"no_favorites": "Aún no hay favoritos",
"no_results": "No se encontraron resultados",
"expand_all": "Expandir todas las carpetas",
"collapse_all": "Contraer todas las carpetas",
"toggle_sidebar": "Alternar barra lateral",
"go_to_homepage": "Ir al inicio",
"files": "Archivos",
"search": "Buscar",
"search_title": "BÚSQUEDA",
"settings": "Configuración",
"settings_title": "CONFIGURACIÓN",
"filtered_notes": "Notas Filtradas"
},
"editor": {
"placeholder": "Empieza a escribir en markdown...",
"drop_hint": "💡 Suelta aquí para insertar en la posición del cursor...",
"mode_edit": "Editar",
"mode_split": "Dividir",
"mode_preview": "Vista previa",
"edited": "Editado {{time}}",
"just_now": "ahora mismo",
"minutes_ago": "hace {{count}}m",
"hours_ago": "hace {{count}}h",
"days_ago": "hace {{count}}d"
},
"notes": {
"confirm_delete": "¿Eliminar \"{{name}}\"?",
"already_exists": "Ya existe una nota llamada \"{{name}}\" en esta ubicación.\nPor favor, elige un nombre diferente.",
"prompt_name": "Introduce el nombre de la nota:",
"prompt_name_in_folder": "Crear nota en \"{{folder}}\".\nIntroduce el nombre de la nota:",
"prompt_name_with_path": "Introduce el nombre de la nota (puedes usar carpeta/nombre):",
"prompt_rename": "Introduce el nuevo nombre:",
"invalid_name": "Nombre de nota inválido.",
"empty_name": "El nombre de la nota no puede estar vacío.",
"not_found": "Nota no encontrada: {{path}}",
"no_content": "No hay contenido para exportar",
"open_first": "Por favor, abre una nota antes de subir imágenes."
},
"folders": {
"confirm_delete": "⚠️ ADVERTENCIA ⚠️\n\n¿Estás seguro de que quieres eliminar la carpeta \"{{name}}\"?\n\nEsto eliminará PERMANENTEMENTE:\n• Todas las notas dentro de esta carpeta\n• Todas las subcarpetas y su contenido\n\n¡Esta acción NO se puede deshacer!",
"already_exists": "Ya existe una carpeta llamada \"{{name}}\" en esta ubicación.\nPor favor, elige un nombre diferente.",
"prompt_name": "Introduce el nombre de la carpeta:",
"prompt_name_in_folder": "Crear subcarpeta en \"{{folder}}\".\nIntroduce el nombre de la carpeta:",
"prompt_name_with_path": "Crear nueva carpeta.\nIntroduce la ruta de la carpeta (ej., \"Proyectos\" o \"Trabajo/2025\"):",
"prompt_rename": "Renombrar carpeta \"{{name}}\" a:",
"invalid_name": "Nombre de carpeta inválido.",
"cannot_move_into_self": "No se puede mover una carpeta dentro de sí misma o de una subcarpeta.",
"empty": "vacía"
},
"toolbar": {
"undo": "Deshacer (Ctrl+Z)",
"redo": "Rehacer (Ctrl+Y)",
"delete_note": "Eliminar nota",
"delete_image": "Eliminar imagen",
"export_html": "Exportar como HTML",
"copy_link": "Copiar enlace al portapapeles",
"add_favorite": "Añadir a favoritos",
"remove_favorite": "Quitar de favoritos"
},
"zen_mode": {
"title": "Modo Zen",
"tooltip": "Modo Zen (Ctrl+Alt+Z)",
"exit": "Salir del Modo Zen",
"exit_hint": "Salir del Modo Zen (Esc)"
},
"tags": {
"title": "Etiquetas",
"no_tags": "No se encontraron etiquetas",
"clear_all": "Limpiar filtros de etiquetas",
"filter_by": "Filtrar por {{tag}} ({{count}} notas)"
},
"stats": {
"words": "palabras",
"reading_time": "~{{minutes}}m de lectura",
"links": "{{count}} enlaces",
"click_details": "▼ Clic para detalles"
},
"graph": {
"title": "Vista de Grafo",
"empty": "No hay conexiones para mostrar",
"markdown_links": "Enlaces Markdown",
"click_hint": "Clic: seleccionar • Doble clic: abrir"
},
"templates": {
"title": "Plantillas",
"select": "Selecciona una plantilla...",
"prompt_name": "Introduce el nombre de la nota:",
"no_templates": "No se encontraron plantillas. Crea plantillas en la carpeta _templates.",
"create_failed": "Error al crear nota desde plantilla",
"create_from_template": "Crear desde Plantilla",
"select_template": "Seleccionar Plantilla",
"choose_template": "Elige una plantilla",
"note_name": "Nombre de la Nota",
"available_placeholders": "Marcadores disponibles",
"create_note": "Crear Nota"
},
"export": {
"failed": "Error al exportar HTML: {{error}}"
},
"login": {
"title": "Iniciar sesión",
"tagline": "Tu Base de Conocimientos Autoalojada",
"password_placeholder": "Introduce tu contraseña",
"unlock_button": "🔓 Desbloquear",
"footer": "🔒 Seguro y Autoalojado",
"error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo."
},
"images": {
"confirm_delete": "¿Eliminar imagen \"{{name}}\"?",
"upload_failed": "Error al subir imagen",
"no_valid_files": "No se encontraron archivos de imagen válidos. Formatos soportados: JPG, PNG, GIF, WEBP",
"formats": "Soporta JPG, PNG, GIF, WebP (máx 10MB)"
},
"move": {
"failed_note": "Error al mover la nota.",
"failed_folder": "Error al mover la carpeta."
},
"search": {
"previous": "Anterior (Shift+F3)",
"next": "Siguiente (F3)",
"matches": "{{current}} de {{total}}"
},
"theme": {
"title": "Tema"
},
"language": {
"title": "Idioma"
},
"syntax_highlight": {
"title": "Resaltado de Sintaxis del Editor",
"description": "Colorear sintaxis markdown en el editor",
"enable": "Activar resaltado de sintaxis",
"disable": "Desactivar resaltado de sintaxis"
},
"settings": {
"account": "Cuenta",
"logout": "Cerrar sesión"
},
"homepage": {
"title": "Inicio",
"welcome": "Bienvenido a NoteDiscovery",
"get_started": "Crea algo para empezar",
"no_notes_title": "Aún no hay notas",
"no_notes_desc": "Crea tu primera nota o carpeta",
"folder_empty": "Esta carpeta está vacía",
"note_singular": "nota",
"note_plural": "notas",
"folder_singular": "carpeta",
"folder_plural": "carpetas"
}
}

216
locales/fr-FR.json Normal file
View File

@ -0,0 +1,216 @@
{
"_meta": {
"code": "fr-FR",
"name": "Français",
"flag": "🇫🇷"
},
"app": {
"tagline": "Votre Base de Connaissances Auto-Hébergée"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"rename": "Renommer",
"create": "Créer",
"close": "Fermer",
"yes": "✓ Oui",
"no": "✗ Non",
"ok": "OK",
"error": "Erreur",
"loading": "Chargement...",
"saved": "✓ Enregistré",
"saving": "Enregistrement...",
"copied": "✓ Copié !",
"failed": "Échec de {{action}}. Veuillez réessayer."
},
"sidebar": {
"title": "FICHIERS",
"favorites_title": "Favoris",
"folders_and_notes": "Dossiers et Notes",
"new_button": "+ Nouveau",
"new_note": "Nouvelle Note",
"new_folder": "Nouveau Dossier",
"new_from_template": "Nouveau depuis Modèle",
"search_placeholder": "Rechercher des notes...",
"search_hint": "Tapez pour rechercher vos notes",
"clear_search": "Effacer la recherche",
"drag_hint": "💡 Glisser=Déplacer",
"root_folder": "📂 Dossier racine",
"no_notes": "Pas encore de notes. Créez votre première note !",
"no_notes_yet": "Pas encore de notes ni de dossiers.",
"create_first": "Créez votre première note ou dossier !",
"no_favorites": "Pas encore de favoris",
"no_results": "Aucun résultat trouvé",
"expand_all": "Développer tous les dossiers",
"collapse_all": "Réduire tous les dossiers",
"toggle_sidebar": "Afficher/Masquer la barre latérale",
"go_to_homepage": "Aller à l'accueil",
"files": "Fichiers",
"search": "Rechercher",
"search_title": "RECHERCHE",
"settings": "Paramètres",
"settings_title": "PARAMÈTRES",
"filtered_notes": "Notes Filtrées"
},
"editor": {
"placeholder": "Commencez à écrire en markdown...",
"drop_hint": "💡 Déposez ici pour insérer à la position du curseur...",
"mode_edit": "Éditer",
"mode_split": "Diviser",
"mode_preview": "Aperçu",
"edited": "Modifié {{time}}",
"just_now": "à l'instant",
"minutes_ago": "il y a {{count}}m",
"hours_ago": "il y a {{count}}h",
"days_ago": "il y a {{count}}j"
},
"notes": {
"confirm_delete": "Supprimer \"{{name}}\" ?",
"already_exists": "Une note nommée \"{{name}}\" existe déjà à cet emplacement.\nVeuillez choisir un autre nom.",
"prompt_name": "Entrez le nom de la note :",
"prompt_name_in_folder": "Créer une note dans \"{{folder}}\".\nEntrez le nom de la note :",
"prompt_name_with_path": "Entrez le nom de la note (vous pouvez utiliser dossier/nom) :",
"prompt_rename": "Entrez le nouveau nom :",
"invalid_name": "Nom de note invalide.",
"empty_name": "Le nom de la note ne peut pas être vide.",
"not_found": "Note introuvable : {{path}}",
"no_content": "Aucun contenu à exporter",
"open_first": "Veuillez d'abord ouvrir une note avant de télécharger des images."
},
"folders": {
"confirm_delete": "⚠️ ATTENTION ⚠️\n\nÊtes-vous sûr de vouloir supprimer le dossier \"{{name}}\" ?\n\nCeci supprimera DÉFINITIVEMENT :\n• Toutes les notes dans ce dossier\n• Tous les sous-dossiers et leur contenu\n\nCette action est IRRÉVERSIBLE !",
"already_exists": "Un dossier nommé \"{{name}}\" existe déjà à cet emplacement.\nVeuillez choisir un autre nom.",
"prompt_name": "Entrez le nom du dossier :",
"prompt_name_in_folder": "Créer un sous-dossier dans \"{{folder}}\".\nEntrez le nom du dossier :",
"prompt_name_with_path": "Créer un nouveau dossier.\nEntrez le chemin du dossier (ex. \"Projets\" ou \"Travail/2025\") :",
"prompt_rename": "Renommer le dossier \"{{name}}\" en :",
"invalid_name": "Nom de dossier invalide.",
"cannot_move_into_self": "Impossible de déplacer un dossier dans lui-même ou dans un sous-dossier.",
"empty": "vide"
},
"toolbar": {
"undo": "Annuler (Ctrl+Z)",
"redo": "Rétablir (Ctrl+Y)",
"delete_note": "Supprimer la note",
"delete_image": "Supprimer l'image",
"export_html": "Exporter en HTML",
"copy_link": "Copier le lien dans le presse-papiers",
"add_favorite": "Ajouter aux favoris",
"remove_favorite": "Retirer des favoris"
},
"zen_mode": {
"title": "Mode Zen",
"tooltip": "Mode Zen (Ctrl+Alt+Z)",
"exit": "Quitter le Mode Zen",
"exit_hint": "Quitter le Mode Zen (Échap)"
},
"tags": {
"title": "Étiquettes",
"no_tags": "Aucune étiquette trouvée",
"clear_all": "Effacer les filtres d'étiquettes",
"filter_by": "Filtrer par {{tag}} ({{count}} notes)"
},
"stats": {
"words": "mots",
"reading_time": "~{{minutes}}m de lecture",
"links": "{{count}} liens",
"click_details": "▼ Cliquez pour les détails"
},
"graph": {
"title": "Vue Graphique",
"empty": "Aucune connexion à afficher",
"markdown_links": "Liens Markdown",
"click_hint": "Clic : sélectionner • Double-clic : ouvrir"
},
"templates": {
"title": "Modèles",
"select": "Sélectionner un modèle...",
"prompt_name": "Entrez le nom de la note :",
"no_templates": "Aucun modèle trouvé. Créez des modèles dans le dossier _templates.",
"create_failed": "Échec de la création de la note depuis le modèle",
"create_from_template": "Créer depuis un Modèle",
"select_template": "Sélectionner un Modèle",
"choose_template": "Choisir un modèle",
"note_name": "Nom de la Note",
"available_placeholders": "Variables disponibles",
"create_note": "Créer la Note"
},
"export": {
"failed": "Échec de l'export HTML : {{error}}"
},
"login": {
"title": "Connexion",
"tagline": "Votre Base de Connaissances Auto-Hébergée",
"password_placeholder": "Entrez votre mot de passe",
"unlock_button": "🔓 Déverrouiller",
"footer": "🔒 Sécurisé et Auto-Hébergé",
"error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer."
},
"images": {
"confirm_delete": "Supprimer l'image \"{{name}}\" ?",
"upload_failed": "Échec du téléchargement de l'image",
"no_valid_files": "Aucun fichier image valide trouvé. Formats supportés : JPG, PNG, GIF, WEBP",
"formats": "Supporte JPG, PNG, GIF, WebP (max 10 Mo)"
},
"move": {
"failed_note": "Échec du déplacement de la note.",
"failed_folder": "Échec du déplacement du dossier."
},
"search": {
"previous": "Précédent (Maj+F3)",
"next": "Suivant (F3)",
"matches": "{{current}} sur {{total}}"
},
"theme": {
"title": "Thème"
},
"language": {
"title": "Langue"
},
"syntax_highlight": {
"title": "Coloration Syntaxique de l'Éditeur",
"description": "Coloriser la syntaxe markdown dans l'éditeur",
"enable": "Activer la coloration syntaxique",
"disable": "Désactiver la coloration syntaxique"
},
"settings": {
"account": "Compte",
"logout": "Déconnexion"
},
"homepage": {
"title": "Accueil",
"welcome": "Bienvenue sur NoteDiscovery",
"get_started": "Créez quelque chose pour commencer",
"no_notes_title": "Pas encore de notes",
"no_notes_desc": "Créez votre première note ou dossier",
"folder_empty": "Ce dossier est vide",
"note_singular": "note",
"note_plural": "notes",
"folder_singular": "dossier",
"folder_plural": "dossiers"
}
}