Merge pull request #207 from gamosoft/features/qol

Features/qol
This commit is contained in:
Guillermo Villar 2026-04-20 09:01:27 +02:00 committed by GitHub
commit 47c6668c99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 626 additions and 145 deletions

View File

@ -10,6 +10,11 @@ const CONFIG = {
SCROLL_SYNC_RETRY_INTERVAL: 100, // ms - Time between setupScrollSync retries SCROLL_SYNC_RETRY_INTERVAL: 100, // ms - Time between setupScrollSync retries
MAX_UNDO_HISTORY: 50, // Maximum number of undo steps to keep MAX_UNDO_HISTORY: 50, // Maximum number of undo steps to keep
DEFAULT_SIDEBAR_WIDTH: 256, // px - Default sidebar width (w-64 in Tailwind) DEFAULT_SIDEBAR_WIDTH: 256, // px - Default sidebar width (w-64 in Tailwind)
TOAST_MAX_VISIBLE: 4, // Max stacked toasts; oldest dropped when exceeded
TOAST_DURATION_ERROR_MS: 7000,
TOAST_DURATION_WARNING_MS: 5500,
TOAST_DURATION_INFO_MS: 4500,
TOAST_DURATION_SUCCESS_MS: 3500,
}; };
// localStorage settings configuration - centralized definition of all persisted settings // localStorage settings configuration - centralized definition of all persisted settings
@ -38,17 +43,15 @@ const ErrorHandler = {
* Handle errors consistently across the app * Handle errors consistently across the app
* @param {string} operation - The operation that failed (e.g., "load notes", "save note") * @param {string} operation - The operation that failed (e.g., "load notes", "save note")
* @param {Error} error - The error object * @param {Error} error - The error object
* @param {boolean} showAlert - Whether to show an alert to the user * @param {boolean} notify - Whether to show a toast (listens on notediscovery:toast)
*/ */
handle(operation, error, showAlert = true) { handle(operation, error, notify = true) {
// Always log to console for debugging
console.error(`Failed to ${operation}:`, error); console.error(`Failed to ${operation}:`, error);
if (notify) {
// Show user-friendly alert if requested const message = `Failed to ${operation}. Please try again.`;
if (showAlert) { window.dispatchEvent(new CustomEvent('notediscovery:toast', {
// Note: ErrorHandler doesn't have access to Alpine's t() function detail: { message, type: 'error' }
// This message remains in English as a fallback }));
alert(`Failed to ${operation}. Please try again.`);
} }
} }
}; };
@ -296,6 +299,27 @@ function noteApp() {
selectedTemplate: '', selectedTemplate: '',
newTemplateNoteName: '', newTemplateNoteName: '',
// New note / folder name modal (replaces window.prompt)
showCreateNameModal: false,
createNameModalKind: 'note',
createNameModalTargetFolder: '',
createNameModalInput: '',
// Rename folder modal (replaces window.prompt)
showRenameFolderModal: false,
renameFolderPath: '',
renameFolderOldName: '',
renameFolderInput: '',
// Generic confirm dialog (replaces window.confirm)
showConfirmModal: false,
confirmModalTitle: '',
confirmModalMessage: '',
confirmModalDanger: true,
confirmModalConfirmLabel: '',
confirmModalCancelLabel: '',
_confirmModalResolve: null,
// Share state // Share state
showShareModal: false, showShareModal: false,
shareInfo: null, shareInfo: null,
@ -310,6 +334,10 @@ function noteApp() {
quickSwitcherIndex: 0, quickSwitcherIndex: 0,
quickSwitcherResults: [], quickSwitcherResults: [],
// Non-blocking notifications (replaces window.alert)
toasts: [],
_toastIdSeq: 0,
// Homepage state // Homepage state
selectedHomepageFolder: '', selectedHomepageFolder: '',
_homepageCache: { _homepageCache: {
@ -725,6 +753,19 @@ function noteApp() {
this.viewMode = this.previousViewMode; this.viewMode = this.previousViewMode;
} }
}); });
// Toasts from ErrorHandler and code paths without Alpine `this`
if (!window.__noteapp_toast_listener) {
window.__noteapp_toast_listener = true;
window.addEventListener('notediscovery:toast', (e) => {
const d = e.detail || {};
if (typeof d.message !== 'string' || !d.message) return;
this.toast(d.message, {
type: d.type || 'error',
durationMs: d.durationMs
});
});
}
}, },
// Load app configuration // Load app configuration
@ -1130,6 +1171,46 @@ function noteApp() {
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`); return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`);
}, },
/**
* Non-blocking toast (replaces window.alert). Types map to theme CSS variables.
* @param {string} message
* @param {{ type?: 'error'|'warning'|'info'|'success', durationMs?: number }} [options]
* @returns {number|undefined} toast id
*/
toast(message, options = {}) {
if (typeof message !== 'string' || !message) return;
const type = ['error', 'warning', 'info', 'success'].includes(options.type)
? options.type
: 'info';
let durationMs = options.durationMs;
if (durationMs == null || typeof durationMs !== 'number' || durationMs < 0) {
durationMs = type === 'error' ? CONFIG.TOAST_DURATION_ERROR_MS
: type === 'warning' ? CONFIG.TOAST_DURATION_WARNING_MS
: type === 'success' ? CONFIG.TOAST_DURATION_SUCCESS_MS
: CONFIG.TOAST_DURATION_INFO_MS;
}
const id = ++this._toastIdSeq;
const entry = { id, message, type, _tid: null };
this.toasts.push(entry);
while (this.toasts.length > CONFIG.TOAST_MAX_VISIBLE) {
const first = this.toasts.shift();
if (first && first._tid) clearTimeout(first._tid);
}
entry._tid = setTimeout(() => this.dismissToast(id), durationMs);
return id;
},
dismissToast(id) {
const idx = this.toasts.findIndex((t) => t.id === id);
if (idx === -1) return;
const entry = this.toasts[idx];
if (entry._tid) {
clearTimeout(entry._tid);
entry._tid = null;
}
this.toasts.splice(idx, 1);
},
/** /**
* Get localized error message from FilenameValidator result * Get localized error message from FilenameValidator result
* @param {object} validation - The validation result from FilenameValidator * @param {object} validation - The validation result from FilenameValidator
@ -1338,7 +1419,7 @@ function noteApp() {
const data = await response.json(); const data = await response.json();
this.allTags = data.tags || {}; this.allTags = data.tags || {};
} catch (error) { } catch (error) {
ErrorHandler.handle('load tags', error, false); // Don't show alert, tags are optional ErrorHandler.handle('load tags', error, false); // Optional: no toast
} }
}, },
@ -1379,7 +1460,7 @@ function noteApp() {
const data = await response.json(); const data = await response.json();
this.availableTemplates = data.templates || []; this.availableTemplates = data.templates || [];
} catch (error) { } catch (error) {
ErrorHandler.handle('load templates', error, false); // Don't show alert, templates are optional ErrorHandler.handle('load templates', error, false); // Optional: no toast
} }
}, },
@ -1393,7 +1474,7 @@ function noteApp() {
// Validate the note name // Validate the note name
const validation = FilenameValidator.validateFilename(this.newTemplateNoteName); const validation = FilenameValidator.validateFilename(this.newTemplateNoteName);
if (!validation.valid) { if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'note')); this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
return; return;
} }
@ -1419,7 +1500,7 @@ function noteApp() {
// CRITICAL: Check if note already exists // CRITICAL: Check if note already exists
const existingNote = this.notes.find(note => note.path === notePath); const existingNote = this.notes.find(note => note.path === notePath);
if (existingNote) { if (existingNote) {
alert(this.t('notes.already_exists', { name: validation.sanitized })); this.toast(this.t('notes.already_exists', { name: validation.sanitized }), { type: 'warning' });
return; return;
} }
@ -1435,7 +1516,7 @@ function noteApp() {
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
alert(error.detail || this.t('templates.create_failed')); this.toast(error.detail || this.t('templates.create_failed'), { type: 'error' });
return; return;
} }
@ -2409,7 +2490,7 @@ function noteApp() {
// Handle media files dropped into editor // Handle media files dropped into editor
async handleMediaDrop(event) { async handleMediaDrop(event) {
if (!this.currentNote) { if (!this.currentNote) {
alert(this.t('notes.open_first')); this.toast(this.t('notes.open_first'), { type: 'info' });
return; return;
} }
@ -2429,7 +2510,7 @@ function noteApp() {
const mediaFiles = files.filter(file => allowedTypes.includes(file.type.toLowerCase())); const mediaFiles = files.filter(file => allowedTypes.includes(file.type.toLowerCase()));
if (mediaFiles.length === 0) { if (mediaFiles.length === 0) {
alert(this.t('media.no_valid_files')); this.toast(this.t('media.no_valid_files'), { type: 'warning' });
return; return;
} }
@ -2617,7 +2698,10 @@ function noteApp() {
// Delete a media file (image, audio, video, PDF) // Delete a media file (image, audio, video, PDF)
async deleteMedia(mediaPath) { async deleteMedia(mediaPath) {
const filename = mediaPath.split('/').pop(); const filename = mediaPath.split('/').pop();
if (!confirm(this.t('media.confirm_delete', { name: filename }))) return; const ok = await this.confirmModalAsk({
message: this.t('media.confirm_delete', { name: filename }),
});
if (!ok) return;
try { try {
const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, { const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
@ -2705,9 +2789,15 @@ function noteApp() {
setTimeout(() => this.scrollToAnchor(anchor), 100); setTimeout(() => this.scrollToAnchor(anchor), 100);
} }
}); });
} else if (confirm(this.t('notes.create_from_link', { path: notePath }))) { } else {
// Note doesn't exist - create it (reuses createNote with duplicate check) this.confirmModalAsk({
this.createNote(null, notePath); message: this.t('notes.create_from_link', { path: notePath }),
danger: false,
title: this.t('sidebar.new_note'),
confirmLabel: this.t('common.create'),
}).then((ok) => {
if (ok) this.createNote(null, notePath);
});
} }
}, },
@ -2768,7 +2858,7 @@ function noteApp() {
// Prevent dropping folder into itself or its subfolders // Prevent dropping folder into itself or its subfolders
if (targetFolderPath === draggedPath || if (targetFolderPath === draggedPath ||
targetFolderPath.startsWith(draggedPath + '/')) { targetFolderPath.startsWith(draggedPath + '/')) {
alert(this.t('folders.cannot_move_into_self')); this.toast(this.t('folders.cannot_move_into_self'), { type: 'warning' });
return; return;
} }
@ -2813,11 +2903,11 @@ function noteApp() {
} }
} else { } else {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
alert(errorData.detail || this.t('move.failed_folder')); this.toast(errorData.detail || this.t('move.failed_folder'), { type: 'error' });
} }
} catch (error) { } catch (error) {
console.error('Failed to move folder:', error); console.error('Failed to move folder:', error);
alert(this.t('move.failed_folder')); this.toast(this.t('move.failed_folder'), { type: 'error' });
} }
return; return;
} }
@ -2866,12 +2956,12 @@ function noteApp() {
} else { } else {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note'; const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
alert(errorData.detail || this.t(errorKey)); this.toast(errorData.detail || this.t(errorKey), { type: 'error' });
} }
} catch (error) { } catch (error) {
console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error); console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error);
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note'; const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
alert(this.t(errorKey)); this.toast(this.t(errorKey), { type: 'error' });
} }
}, },
@ -3282,57 +3372,131 @@ function noteApp() {
}, },
async createNote(folderPath = null, directPath = null) { async createNote(folderPath = null, directPath = null) {
let notePath;
if (directPath) { if (directPath) {
// Direct path provided (e.g., from wiki link) - skip prompting const notePath = directPath.endsWith('.md') ? directPath : `${directPath}.md`;
notePath = directPath.endsWith('.md') ? directPath : `${directPath}.md`; const existingNote = this.notes.find(note => note.path === notePath);
} else { if (existingNote) {
// Use provided folder path, or dropdown target folder context, or homepage folder this.toast(this.t('notes.already_exists', { name: notePath }), { type: 'warning' });
// Note: Check dropdownTargetFolder !== null to distinguish between '' (root) and not set
let targetFolder;
if (folderPath !== null) {
targetFolder = folderPath;
} else if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path
} else {
targetFolder = this.selectedHomepageFolder || '';
}
this.closeDropdown();
const promptText = targetFolder
? this.t('notes.prompt_name_in_folder', { folder: targetFolder })
: this.t('notes.prompt_name_with_path');
const noteName = prompt(promptText);
if (!noteName) return;
// Validate the name/path (may contain / for paths when no target folder)
const validation = targetFolder
? FilenameValidator.validateFilename(noteName)
: FilenameValidator.validatePath(noteName);
if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'note'));
return; return;
} }
await this._finalizeCreateNote(notePath);
const validatedName = validation.sanitized; return;
if (targetFolder) {
notePath = `${targetFolder}/${validatedName}.md`;
} else {
notePath = validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`;
}
} }
const explicitTarget = folderPath !== null && folderPath !== undefined ? folderPath : undefined;
this.openCreateNameModal('note', explicitTarget);
},
/**
* @param {'note'|'folder'} kind
* @param {string|undefined} explicitTargetFolder - if set, use as parent folder context ("" = root)
*/
openCreateNameModal(kind, explicitTargetFolder = undefined) {
let targetFolder;
if (explicitTargetFolder !== undefined) {
targetFolder = explicitTargetFolder;
} else if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
targetFolder = this.dropdownTargetFolder;
} else {
targetFolder = this.selectedHomepageFolder || '';
}
this.closeDropdown();
this.mobileSidebarOpen = false;
this.createNameModalKind = kind;
this.createNameModalTargetFolder = targetFolder;
this.createNameModalInput = '';
this.showCreateNameModal = true;
this.$nextTick(() => {
const el = document.getElementById('create-name-modal-input');
if (el) {
el.focus();
el.select();
}
});
},
closeCreateNameModal() {
this.showCreateNameModal = false;
this.createNameModalInput = '';
},
cancelCreateNameModal() {
this.closeCreateNameModal();
},
createNameModalHelpText() {
const tf = this.createNameModalTargetFolder;
if (this.createNameModalKind === 'note') {
return tf
? this.t('notes.prompt_name_in_folder', { folder: tf })
: this.t('notes.prompt_name_with_path');
}
return tf
? this.t('folders.prompt_name_in_folder', { folder: tf })
: this.t('folders.prompt_name_with_path');
},
createNameModalTitle() {
return this.createNameModalKind === 'note'
? this.t('sidebar.new_note')
: this.t('sidebar.new_folder');
},
createNameModalLabel() {
return this.createNameModalKind === 'note'
? this.t('templates.note_name')
: this.t('folders.prompt_name');
},
async submitCreateNameModal() {
const rawName = (this.createNameModalInput || '').trim();
if (!rawName) {
this.closeCreateNameModal();
return;
}
const targetFolder = this.createNameModalTargetFolder;
const kind = this.createNameModalKind;
// CRITICAL: Check if note already exists (applies to both prompt and direct path) if (kind === 'note') {
const existingNote = this.notes.find(note => note.path === notePath); const validation = targetFolder
if (existingNote) { ? FilenameValidator.validateFilename(rawName)
alert(this.t('notes.already_exists', { name: notePath })); : FilenameValidator.validatePath(rawName);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
return;
}
const validatedName = validation.sanitized;
const notePath = targetFolder
? `${targetFolder}/${validatedName}.md`
: (validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`);
const existingNote = this.notes.find(note => note.path === notePath);
if (existingNote) {
this.toast(this.t('notes.already_exists', { name: notePath }), { type: 'warning' });
return;
}
const ok = await this._finalizeCreateNote(notePath);
if (ok) this.closeCreateNameModal();
return; return;
} }
const validation = targetFolder
? FilenameValidator.validateFilename(rawName)
: FilenameValidator.validatePath(rawName);
if (!validation.valid) {
this.toast(this.getValidationErrorMessage(validation, 'folder'), { type: 'warning' });
return;
}
const validatedName = validation.sanitized;
const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName;
const existingFolder = this.allFolders.find(folder => folder === folderPath);
if (existingFolder) {
this.toast(this.t('folders.already_exists', { name: validatedName }), { type: 'warning' });
return;
}
const ok = await this._finalizeCreateFolder(folderPath, targetFolder);
if (ok) this.closeCreateNameModal();
},
async _finalizeCreateNote(notePath) {
try { try {
const response = await fetch(`/api/notes/${notePath}`, { const response = await fetch(`/api/notes/${notePath}`, {
method: 'POST', method: 'POST',
@ -3341,60 +3505,22 @@ function noteApp() {
}); });
if (response.ok) { if (response.ok) {
// Expand parent folder if note is in a subfolder
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : ''; const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
if (folderPart) this.expandedFolders.add(folderPart); if (folderPart) this.expandedFolders.add(folderPart);
await this.loadNotes(); await this.loadNotes();
await this.loadNote(notePath); await this.loadNote(notePath);
this.focusEditorForNewNote(); this.focusEditorForNewNote();
} else { return true;
ErrorHandler.handle('create note', new Error('Server returned error'));
} }
ErrorHandler.handle('create note', new Error('Server returned error'));
return false;
} catch (error) { } catch (error) {
ErrorHandler.handle('create note', error); ErrorHandler.handle('create note', error);
return false;
} }
}, },
async createFolder(parentPath = null) { async _finalizeCreateFolder(folderPath, targetFolder) {
// Use provided parent path, or dropdown target folder context, or homepage folder
// Note: Check dropdownTargetFolder !== null to distinguish between '' (root) and not set
let targetFolder;
if (parentPath !== null) {
targetFolder = parentPath;
} else if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path
} else {
targetFolder = this.selectedHomepageFolder || '';
}
this.closeDropdown();
const promptText = targetFolder
? this.t('folders.prompt_name_in_folder', { folder: targetFolder })
: this.t('folders.prompt_name_with_path');
const folderName = prompt(promptText);
if (!folderName) return;
// Validate the name/path (may contain / for paths when no target folder)
const validation = targetFolder
? FilenameValidator.validateFilename(folderName)
: FilenameValidator.validatePath(folderName);
if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'folder'));
return;
}
const validatedName = validation.sanitized;
const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName;
// Check if folder already exists
const existingFolder = this.allFolders.find(folder => folder === folderPath);
if (existingFolder) {
alert(this.t('folders.already_exists', { name: validatedName }));
return;
}
try { try {
const response = await fetch('/api/folders', { const response = await fetch('/api/folders', {
method: 'POST', method: 'POST',
@ -3408,54 +3534,125 @@ function noteApp() {
} }
this.expandedFolders.add(folderPath); this.expandedFolders.add(folderPath);
await this.loadNotes(); await this.loadNotes();
// Navigate to the newly created folder on the homepage
this.goToHomepageFolder(folderPath); this.goToHomepageFolder(folderPath);
} else { return true;
ErrorHandler.handle('create folder', new Error('Server returned error'));
} }
ErrorHandler.handle('create folder', new Error('Server returned error'));
return false;
} catch (error) { } catch (error) {
ErrorHandler.handle('create folder', error); ErrorHandler.handle('create folder', error);
return false;
} }
}, },
// Rename a folder async createFolder(parentPath = null) {
async renameFolder(folderPath, currentName) { const explicitTarget = parentPath !== null && parentPath !== undefined ? parentPath : undefined;
const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName); this.openCreateNameModal('folder', explicitTarget);
if (!newName || newName === currentName) return; },
renameFolder(folderPath, currentName) {
this.renameFolderPath = folderPath;
this.renameFolderOldName = currentName;
this.renameFolderInput = currentName;
this.showRenameFolderModal = true;
this.mobileSidebarOpen = false;
this.$nextTick(() => {
const el = document.getElementById('rename-folder-modal-input');
if (el) {
el.focus();
el.select();
}
});
},
closeRenameFolderModal() {
this.showRenameFolderModal = false;
this.renameFolderPath = '';
this.renameFolderOldName = '';
this.renameFolderInput = '';
},
cancelRenameFolderModal() {
this.closeRenameFolderModal();
},
/**
* Theme-styled confirm dialog (replaces window.confirm).
* @param {{ message: string, title?: string, danger?: boolean, confirmLabel?: string, cancelLabel?: string }} options
* @returns {Promise<boolean>}
*/
confirmModalAsk(options = {}) {
const { message, title, danger = true, confirmLabel, cancelLabel } = options;
return new Promise((resolve) => {
this.confirmModalTitle = title || this.t('common.confirm_title');
this.confirmModalMessage = message || '';
this.confirmModalDanger = danger;
this.confirmModalConfirmLabel = confirmLabel
|| (danger ? this.t('common.delete') : this.t('common.ok'));
this.confirmModalCancelLabel = cancelLabel || this.t('common.cancel');
this._confirmModalResolve = resolve;
this.showConfirmModal = true;
this.mobileSidebarOpen = false;
});
},
confirmModalConfirm() {
this._confirmModalFinish(true);
},
confirmModalCancel() {
this._confirmModalFinish(false);
},
_confirmModalFinish(ok) {
if (!this._confirmModalResolve) return;
this.showConfirmModal = false;
const fn = this._confirmModalResolve;
this._confirmModalResolve = null;
fn(!!ok);
},
async submitRenameFolderModal() {
const folderPath = this.renameFolderPath;
const currentName = this.renameFolderOldName;
const newName = (this.renameFolderInput || '').trim();
if (!newName || newName === currentName) {
this.closeRenameFolderModal();
return;
}
// Validate the new name (single segment, no path separators)
const validation = FilenameValidator.validateFilename(newName); const validation = FilenameValidator.validateFilename(newName);
if (!validation.valid) { if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'folder')); this.toast(this.getValidationErrorMessage(validation, 'folder'), { type: 'warning' });
return; return;
} }
const validatedName = validation.sanitized; const validatedName = validation.sanitized;
// Calculate new path
const pathParts = folderPath.split('/'); const pathParts = folderPath.split('/');
pathParts[pathParts.length - 1] = validatedName; pathParts[pathParts.length - 1] = validatedName;
const newPath = pathParts.join('/'); const newPath = pathParts.join('/');
const ok = await this._finalizeRenameFolder(folderPath, newPath);
if (ok) this.closeRenameFolderModal();
},
async _finalizeRenameFolder(folderPath, newPath) {
try { try {
const response = await fetch('/api/folders/rename', { const response = await fetch('/api/folders/rename', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
oldPath: folderPath, oldPath: folderPath,
newPath: newPath newPath: newPath
}) })
}); });
if (response.ok) { if (response.ok) {
// Update expanded folders state
if (this.expandedFolders.has(folderPath)) { if (this.expandedFolders.has(folderPath)) {
this.expandedFolders.delete(folderPath); this.expandedFolders.delete(folderPath);
this.expandedFolders.add(newPath); this.expandedFolders.add(newPath);
} }
// Update favorites that were in the renamed folder
const folderPrefix = folderPath + '/'; const folderPrefix = folderPath + '/';
const newFolderPrefix = newPath + '/'; const newFolderPrefix = newPath + '/';
const newFavorites = this.favorites.map(f => { const newFavorites = this.favorites.map(f => {
@ -3464,32 +3661,33 @@ function noteApp() {
} }
return f; return f;
}); });
// Check if anything changed
if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) { if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) {
this.favorites = newFavorites; this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites); this.favoritesSet = new Set(newFavorites);
this.saveFavorites(); this.saveFavorites();
} }
// Update current note path if it's in the renamed folder
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) { if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix); this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix);
} }
await this.loadNotes(); await this.loadNotes();
} else { return true;
ErrorHandler.handle('rename folder', new Error('Server returned error'));
} }
ErrorHandler.handle('rename folder', new Error('Server returned error'));
return false;
} catch (error) { } catch (error) {
ErrorHandler.handle('rename folder', error); ErrorHandler.handle('rename folder', error);
return false;
} }
}, },
// Delete folder // Delete folder
async deleteFolder(folderPath, folderName) { async deleteFolder(folderPath, folderName) {
const confirmation = confirm(this.t('folders.confirm_delete', { name: folderName })); const ok = await this.confirmModalAsk({
message: this.t('folders.confirm_delete', { name: folderName }),
if (!confirmation) return; });
if (!ok) return;
try { try {
const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, { const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, {
@ -3917,14 +4115,14 @@ function noteApp() {
const newName = this.currentNoteName.trim(); const newName = this.currentNoteName.trim();
if (!newName) { if (!newName) {
alert(this.t('notes.empty_name')); this.toast(this.t('notes.empty_name'), { type: 'warning' });
return; return;
} }
// Validate the new name (single segment, no path separators) // Validate the new name (single segment, no path separators)
const validation = FilenameValidator.validateFilename(newName); const validation = FilenameValidator.validateFilename(newName);
if (!validation.valid) { if (!validation.valid) {
alert(this.getValidationErrorMessage(validation, 'note')); this.toast(this.getValidationErrorMessage(validation, 'note'), { type: 'warning' });
// Reset the name in the UI // Reset the name in the UI
this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
return; return;
@ -3939,7 +4137,7 @@ function noteApp() {
// Check if a note with the new name already exists // Check if a note with the new name already exists
const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase()); const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
if (existingNote) { if (existingNote) {
alert(this.t('notes.already_exists', { name: validatedName })); this.toast(this.t('notes.already_exists', { name: validatedName }), { type: 'warning' });
// Reset the name in the UI // Reset the name in the UI
this.currentNoteName = oldPath.split('/').pop().replace('.md', ''); this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
return; return;
@ -3985,7 +4183,10 @@ function noteApp() {
// Delete any note from sidebar // Delete any note from sidebar
async deleteNote(notePath, noteName) { async deleteNote(notePath, noteName) {
if (!confirm(this.t('notes.confirm_delete', { name: noteName }))) return; const ok = await this.confirmModalAsk({
message: this.t('notes.confirm_delete', { name: noteName }),
});
if (!ok) return;
try { try {
const response = await fetch(`/api/notes/${notePath}`, { const response = await fetch(`/api/notes/${notePath}`, {
@ -5199,7 +5400,7 @@ function noteApp() {
// Export current note as HTML via backend API // Export current note as HTML via backend API
async exportToHTML() { async exportToHTML() {
if (!this.currentNote || !this.noteContent) { if (!this.currentNote || !this.noteContent) {
alert(this.t('notes.no_content')); this.toast(this.t('notes.no_content'), { type: 'info' });
return; return;
} }
@ -5241,14 +5442,14 @@ function noteApp() {
} catch (error) { } catch (error) {
console.error('HTML export failed:', error); console.error('HTML export failed:', error);
alert(this.t('export.failed', { error: error.message })); this.toast(this.t('export.failed', { error: error.message }), { type: 'error' });
} }
}, },
// Open print preview in new window // Open print preview in new window
printPreview() { printPreview() {
if (!this.currentNote || !this.noteContent) { if (!this.currentNote || !this.noteContent) {
alert(this.t('notes.no_content')); this.toast(this.t('notes.no_content'), { type: 'info' });
return; return;
} }
@ -5468,11 +5669,11 @@ function noteApp() {
this._sharedNotePaths.add(this.currentNote); this._sharedNotePaths.add(this.currentNote);
} else { } else {
const error = await response.json(); const error = await response.json();
alert(this.t('share.error_creating', { error: error.detail || 'Unknown error' })); this.toast(this.t('share.error_creating', { error: error.detail || 'Unknown error' }), { type: 'error' });
} }
} catch (error) { } catch (error) {
console.error('Failed to create share link:', error); console.error('Failed to create share link:', error);
alert(this.t('share.error_creating', { error: error.message })); this.toast(this.t('share.error_creating', { error: error.message }), { type: 'error' });
} finally { } finally {
this.shareLoading = false; this.shareLoading = false;
} }
@ -5504,7 +5705,12 @@ function noteApp() {
async revokeShareLink() { async revokeShareLink() {
if (!this.currentNote) return; if (!this.currentNote) return;
if (!confirm(this.t('share.confirm_revoke'))) return; const ok = await this.confirmModalAsk({
message: this.t('share.confirm_revoke'),
danger: false,
confirmLabel: this.t('common.yes'),
});
if (!ok) return;
this.shareLoading = true; this.shareLoading = true;
@ -5521,11 +5727,11 @@ function noteApp() {
this._sharedNotePaths.delete(this.currentNote); this._sharedNotePaths.delete(this.currentNote);
} else { } else {
const error = await response.json(); const error = await response.json();
alert(this.t('share.error_revoking', { error: error.detail || 'Unknown error' })); this.toast(this.t('share.error_revoking', { error: error.detail || 'Unknown error' }), { type: 'error' });
} }
} catch (error) { } catch (error) {
console.error('Failed to revoke share link:', error); console.error('Failed to revoke share link:', error);
alert(this.t('share.error_revoking', { error: error.message })); this.toast(this.t('share.error_revoking', { error: error.message }), { type: 'error' });
} finally { } finally {
this.shareLoading = false; this.shareLoading = false;
} }

View File

@ -1351,6 +1351,47 @@
<span>Exit Zen</span> <span>Exit Zen</span>
</button> </button>
<!-- Toast notifications (non-blocking; z-index above modals at 11001200; desktop-first top-right) -->
<div
class="fixed z-[1300] flex w-[min(28rem,calc(100vw-2rem))] flex-col items-stretch gap-2 pointer-events-none"
style="top: max(1rem, env(safe-area-inset-top, 0px)); right: max(1rem, env(safe-area-inset-right, 0px));"
role="region"
:aria-label="t('toast.region_label')"
aria-live="polite"
>
<template x-for="item in toasts" :key="item.id">
<div
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 -translate-y-1"
class="pointer-events-auto flex items-start gap-2 rounded-lg border shadow-lg px-3 py-2.5 text-sm"
:role="item.type === 'error' ? 'alert' : 'status'"
:style="{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
borderColor: 'var(--border-primary)',
borderLeftWidth: '4px',
borderLeftStyle: 'solid',
borderLeftColor: item.type === 'error' ? 'var(--error)' : (item.type === 'warning' ? 'var(--warning)' : (item.type === 'success' ? 'var(--success)' : 'var(--accent-primary)'))
}"
>
<span class="flex-1 min-w-0 break-words leading-snug" x-text="item.message"></span>
<button
type="button"
class="flex-shrink-0 rounded p-0.5 opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-opacity-50"
style="color: var(--text-secondary); --tw-ring-color: var(--accent-primary);"
@click="dismissToast(item.id)"
:aria-label="t('toast.dismiss')"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</template>
</div>
<!-- Mobile Overlay --> <!-- Mobile Overlay -->
<div <div
@click="mobileSidebarOpen = false" @click="mobileSidebarOpen = false"
@ -3163,6 +3204,141 @@
</div> </div>
</div> </div>
<!-- New note / folder name (replaces window.prompt) -->
<div x-show="showCreateNameModal"
@click.self="cancelCreateNameModal()"
@keydown.escape.window="showCreateNameModal && cancelCreateNameModal()"
class="fixed inset-0 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.5); z-index: 1100;"
x-cloak>
<div class="rounded-lg shadow-xl p-6 max-w-md w-full mx-4"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
@click.stop>
<h2 class="text-xl font-bold mb-2" style="color: var(--text-primary);" x-text="createNameModalTitle()"></h2>
<p class="text-sm mb-4 whitespace-pre-line" style="color: var(--text-secondary);" x-text="createNameModalHelpText()"></p>
<div class="mb-4">
<label for="create-name-modal-input" class="block text-sm font-medium mb-2" style="color: var(--text-secondary);" x-text="createNameModalLabel()"></label>
<input
id="create-name-modal-input"
type="text"
x-model="createNameModalInput"
:placeholder="createNameModalKind === 'folder' ? t('folders.prompt_name') : 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="submitCreateNameModal()"
autocomplete="off"
/>
</div>
<div class="flex gap-2">
<button
type="button"
@click="submitCreateNameModal()"
class="flex-1 px-4 py-2 rounded font-medium text-white transition-colors"
style="background-color: var(--accent-primary);"
onmouseover="this.style.backgroundColor='var(--accent-hover)'"
onmouseout="this.style.backgroundColor='var(--accent-primary)'"
x-text="t('common.create')"
></button>
<button
type="button"
@click="cancelCreateNameModal()"
class="flex-1 px-4 py-2 rounded font-medium transition-colors"
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')"
></button>
</div>
</div>
</div>
<!-- Rename folder (replaces window.prompt) -->
<div x-show="showRenameFolderModal"
@click.self="cancelRenameFolderModal()"
@keydown.escape.window="showRenameFolderModal && cancelRenameFolderModal()"
class="fixed inset-0 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.5); z-index: 1100;"
x-cloak>
<div class="rounded-lg shadow-xl p-6 max-w-md w-full mx-4"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
@click.stop>
<h2 class="text-xl font-bold mb-2" style="color: var(--text-primary);" x-text="t('folders.rename_modal_title')"></h2>
<p class="text-sm mb-4" style="color: var(--text-secondary);" x-text="t('folders.rename_modal_description', { name: renameFolderOldName })"></p>
<div class="mb-4">
<label for="rename-folder-modal-input" class="block text-sm font-medium mb-2" style="color: var(--text-secondary);" x-text="t('folders.rename_modal_label')"></label>
<input
id="rename-folder-modal-input"
type="text"
x-model="renameFolderInput"
:placeholder="renameFolderOldName"
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="submitRenameFolderModal()"
autocomplete="off"
/>
</div>
<div class="flex gap-2">
<button
type="button"
@click="submitRenameFolderModal()"
class="flex-1 px-4 py-2 rounded font-medium text-white transition-colors"
style="background-color: var(--accent-primary);"
onmouseover="this.style.backgroundColor='var(--accent-hover)'"
onmouseout="this.style.backgroundColor='var(--accent-primary)'"
x-text="t('common.rename')"
></button>
<button
type="button"
@click="cancelRenameFolderModal()"
class="flex-1 px-4 py-2 rounded font-medium transition-colors"
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')"
></button>
</div>
</div>
</div>
<!-- Confirm dialog (replaces window.confirm) -->
<div x-show="showConfirmModal"
@click.self="confirmModalCancel()"
@keydown.escape.window="showConfirmModal && confirmModalCancel()"
class="fixed inset-0 flex items-center justify-center p-4"
style="background-color: rgba(0, 0, 0, 0.5); z-index: 1250;"
x-cloak>
<div class="rounded-lg shadow-xl p-6 max-w-lg w-full mx-auto max-h-[90vh] overflow-y-auto"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
@click.stop
role="alertdialog"
aria-modal="true"
:aria-labelledby="'confirm-modal-title'"
:aria-describedby="'confirm-modal-desc'">
<h2 id="confirm-modal-title" class="text-xl font-bold mb-3" style="color: var(--text-primary);" x-text="confirmModalTitle"></h2>
<p id="confirm-modal-desc" class="text-sm mb-6 whitespace-pre-line leading-relaxed" style="color: var(--text-secondary);" x-text="confirmModalMessage"></p>
<div class="flex flex-col-reverse sm:flex-row gap-2 sm:justify-end">
<button
type="button"
@click="confirmModalCancel()"
class="w-full sm:w-auto px-4 py-2.5 rounded font-medium transition-colors"
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="confirmModalCancelLabel"
></button>
<button
type="button"
@click="confirmModalConfirm()"
class="w-full sm:w-auto px-4 py-2.5 rounded font-medium text-white transition-colors"
:style="confirmModalDanger ? 'background-color: var(--error);' : 'background-color: var(--accent-primary);'"
@mouseenter="$el.style.backgroundColor = confirmModalDanger ? '#dc2626' : 'var(--accent-hover)'"
@mouseleave="$el.style.backgroundColor = confirmModalDanger ? 'var(--error)' : 'var(--accent-primary)'"
x-text="confirmModalConfirmLabel"
></button>
</div>
</div>
</div>
<!-- Quick Switcher Modal (Ctrl+K) --> <!-- Quick Switcher Modal (Ctrl+K) -->
<div x-show="showQuickSwitcher" <div x-show="showQuickSwitcher"
@click.self="closeQuickSwitcher()" @click.self="closeQuickSwitcher()"

View File

@ -16,6 +16,7 @@
"rename": "Umbenennen", "rename": "Umbenennen",
"create": "Erstellen", "create": "Erstellen",
"close": "Schließen", "close": "Schließen",
"confirm_title": "Bestätigen",
"yes": "✓ Ja", "yes": "✓ Ja",
"no": "✗ Nein", "no": "✗ Nein",
"ok": "OK", "ok": "OK",
@ -28,6 +29,11 @@
"failed": "{{action}} fehlgeschlagen. Bitte versuche es erneut." "failed": "{{action}} fehlgeschlagen. Bitte versuche es erneut."
}, },
"toast": {
"region_label": "Benachrichtigungen",
"dismiss": "Schließen"
},
"sidebar": { "sidebar": {
"title": "DATEIEN", "title": "DATEIEN",
"favorites_title": "Favoriten", "favorites_title": "Favoriten",
@ -100,6 +106,9 @@
"prompt_name_in_folder": "Unterordner in \"{{folder}}\" erstellen.\nOrdnername 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_name_with_path": "Neuen Ordner erstellen.\nOrdnerpfad eingeben (z.B. \"Projekte\" oder \"Arbeit/2025\"):",
"prompt_rename": "Ordner \"{{name}}\" umbenennen zu:", "prompt_rename": "Ordner \"{{name}}\" umbenennen zu:",
"rename_modal_title": "Ordner umbenennen",
"rename_modal_description": "Neuen Namen für den Ordner „{{name}}“ eingeben.",
"rename_modal_label": "Neuer Name",
"invalid_name": "Ungültiger Ordnername.", "invalid_name": "Ungültiger Ordnername.",
"cannot_move_into_self": "Ordner kann nicht in sich selbst oder einen Unterordner verschoben werden.", "cannot_move_into_self": "Ordner kann nicht in sich selbst oder einen Unterordner verschoben werden.",
"empty": "leer" "empty": "leer"

View File

@ -16,6 +16,7 @@
"rename": "Rename", "rename": "Rename",
"create": "Create", "create": "Create",
"close": "Close", "close": "Close",
"confirm_title": "Confirm",
"yes": "✓ Yes", "yes": "✓ Yes",
"no": "✗ No", "no": "✗ No",
"ok": "OK", "ok": "OK",
@ -28,6 +29,11 @@
"failed": "Failed to {{action}}. Please try again." "failed": "Failed to {{action}}. Please try again."
}, },
"toast": {
"region_label": "Notifications",
"dismiss": "Dismiss"
},
"sidebar": { "sidebar": {
"title": "FILES", "title": "FILES",
"favorites_title": "Favourites", "favorites_title": "Favourites",
@ -99,6 +105,9 @@
"prompt_name_in_folder": "Create subfolder in \"{{folder}}\".\nEnter 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_name_with_path": "Create new folder.\nEnter folder path (e.g., \"Projects\" or \"Work/2025\"):",
"prompt_rename": "Rename folder \"{{name}}\" to:", "prompt_rename": "Rename folder \"{{name}}\" to:",
"rename_modal_title": "Rename folder",
"rename_modal_description": "Enter the new name for the folder \"{{name}}\".",
"rename_modal_label": "New name",
"invalid_name": "Invalid folder name.", "invalid_name": "Invalid folder name.",
"cannot_move_into_self": "Cannot move folder into itself or its subfolder.", "cannot_move_into_self": "Cannot move folder into itself or its subfolder.",
"empty": "empty" "empty": "empty"

View File

@ -16,6 +16,7 @@
"rename": "Rename", "rename": "Rename",
"create": "Create", "create": "Create",
"close": "Close", "close": "Close",
"confirm_title": "Confirm",
"yes": "✓ Yes", "yes": "✓ Yes",
"no": "✗ No", "no": "✗ No",
"ok": "OK", "ok": "OK",
@ -28,6 +29,11 @@
"failed": "Failed to {{action}}. Please try again." "failed": "Failed to {{action}}. Please try again."
}, },
"toast": {
"region_label": "Notifications",
"dismiss": "Dismiss"
},
"sidebar": { "sidebar": {
"title": "FILES", "title": "FILES",
"favorites_title": "Favorites", "favorites_title": "Favorites",
@ -100,6 +106,9 @@
"prompt_name_in_folder": "Create subfolder in \"{{folder}}\".\nEnter 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_name_with_path": "Create new folder.\nEnter folder path (e.g., \"Projects\" or \"Work/2025\"):",
"prompt_rename": "Rename folder \"{{name}}\" to:", "prompt_rename": "Rename folder \"{{name}}\" to:",
"rename_modal_title": "Rename folder",
"rename_modal_description": "Enter the new name for the folder \"{{name}}\".",
"rename_modal_label": "New name",
"invalid_name": "Invalid folder name.", "invalid_name": "Invalid folder name.",
"cannot_move_into_self": "Cannot move folder into itself or its subfolder.", "cannot_move_into_self": "Cannot move folder into itself or its subfolder.",
"empty": "empty" "empty": "empty"

View File

@ -16,6 +16,7 @@
"rename": "Renombrar", "rename": "Renombrar",
"create": "Crear", "create": "Crear",
"close": "Cerrar", "close": "Cerrar",
"confirm_title": "Confirmar",
"yes": "✓ Sí", "yes": "✓ Sí",
"no": "✗ No", "no": "✗ No",
"ok": "Aceptar", "ok": "Aceptar",
@ -28,6 +29,11 @@
"failed": "Error al {{action}}. Por favor, inténtalo de nuevo." "failed": "Error al {{action}}. Por favor, inténtalo de nuevo."
}, },
"toast": {
"region_label": "Notificaciones",
"dismiss": "Cerrar"
},
"sidebar": { "sidebar": {
"title": "ARCHIVOS", "title": "ARCHIVOS",
"favorites_title": "Favoritos", "favorites_title": "Favoritos",
@ -100,6 +106,9 @@
"prompt_name_in_folder": "Crear subcarpeta en \"{{folder}}\".\nIntroduce 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_name_with_path": "Crear nueva carpeta.\nIntroduce la ruta de la carpeta (ej., \"Proyectos\" o \"Trabajo/2025\"):",
"prompt_rename": "Renombrar carpeta \"{{name}}\" a:", "prompt_rename": "Renombrar carpeta \"{{name}}\" a:",
"rename_modal_title": "Renombrar carpeta",
"rename_modal_description": "Introduce el nuevo nombre para la carpeta \"{{name}}\".",
"rename_modal_label": "Nombre nuevo",
"invalid_name": "Nombre de carpeta inválido.", "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.", "cannot_move_into_self": "No se puede mover una carpeta dentro de sí misma o de una subcarpeta.",
"empty": "vacía" "empty": "vacía"

View File

@ -16,6 +16,7 @@
"rename": "Renommer", "rename": "Renommer",
"create": "Créer", "create": "Créer",
"close": "Fermer", "close": "Fermer",
"confirm_title": "Confirmer",
"yes": "✓ Oui", "yes": "✓ Oui",
"no": "✗ Non", "no": "✗ Non",
"ok": "OK", "ok": "OK",
@ -28,6 +29,11 @@
"failed": "Échec de {{action}}. Veuillez réessayer." "failed": "Échec de {{action}}. Veuillez réessayer."
}, },
"toast": {
"region_label": "Notifications",
"dismiss": "Fermer"
},
"sidebar": { "sidebar": {
"title": "FICHIERS", "title": "FICHIERS",
"favorites_title": "Favoris", "favorites_title": "Favoris",
@ -100,6 +106,9 @@
"prompt_name_in_folder": "Créer un sous-dossier dans \"{{folder}}\".\nEntrez 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_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 :", "prompt_rename": "Renommer le dossier \"{{name}}\" en :",
"rename_modal_title": "Renommer le dossier",
"rename_modal_description": "Saisissez le nouveau nom du dossier « {{name}} ».",
"rename_modal_label": "Nouveau nom",
"invalid_name": "Nom de dossier invalide.", "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.", "cannot_move_into_self": "Impossible de déplacer un dossier dans lui-même ou dans un sous-dossier.",
"empty": "vide" "empty": "vide"

View File

@ -16,6 +16,7 @@
"rename": "Átnevez", "rename": "Átnevez",
"create": "Létrehoz", "create": "Létrehoz",
"close": "Bezár", "close": "Bezár",
"confirm_title": "Megerősítés",
"yes": "✓ Igen", "yes": "✓ Igen",
"no": "✗ Nem", "no": "✗ Nem",
"ok": "OK", "ok": "OK",
@ -28,6 +29,11 @@
"failed": "Hiba a(z) {{action}} során. Kérlek, próbáld újra." "failed": "Hiba a(z) {{action}} során. Kérlek, próbáld újra."
}, },
"toast": {
"region_label": "Értesítések",
"dismiss": "Bezárás"
},
"sidebar": { "sidebar": {
"title": "FÁJLOK", "title": "FÁJLOK",
"favorites_title": "Kedvencek", "favorites_title": "Kedvencek",
@ -100,6 +106,9 @@
"prompt_name_in_folder": "Almappa létrehozása a \"{{folder}}\" mappában.\nAdd meg a mappa nevét:", "prompt_name_in_folder": "Almappa létrehozása a \"{{folder}}\" mappában.\nAdd meg a mappa nevét:",
"prompt_name_with_path": "Új mappa létrehozása.\nAdd meg a mappa elérési útját (pl. \"Projektek\" vagy \"Munka/2026\"):", "prompt_name_with_path": "Új mappa létrehozása.\nAdd meg a mappa elérési útját (pl. \"Projektek\" vagy \"Munka/2026\"):",
"prompt_rename": "Nevezd át a(z) \"{{name}}\" mappát:", "prompt_rename": "Nevezd át a(z) \"{{name}}\" mappát:",
"rename_modal_title": "Mappa átnevezése",
"rename_modal_description": "Add meg a(z) „{{name}}” mappa új nevét.",
"rename_modal_label": "Új név",
"invalid_name": "Érvénytelen mappanév.", "invalid_name": "Érvénytelen mappanév.",
"cannot_move_into_self": "A mappa nem mozgatható saját magába vagy bármelyik almappájába.", "cannot_move_into_self": "A mappa nem mozgatható saját magába vagy bármelyik almappájába.",
"empty": "üres" "empty": "üres"

View File

@ -16,6 +16,7 @@
"rename": "Rinomina", "rename": "Rinomina",
"create": "Crea", "create": "Crea",
"close": "Chiudi", "close": "Chiudi",
"confirm_title": "Conferma",
"yes": "✓ Sì", "yes": "✓ Sì",
"no": "✗ No", "no": "✗ No",
"ok": "OK", "ok": "OK",
@ -28,6 +29,11 @@
"failed": "Impossibile {{action}}. Riprova." "failed": "Impossibile {{action}}. Riprova."
}, },
"toast": {
"region_label": "Notifiche",
"dismiss": "Chiudi"
},
"sidebar": { "sidebar": {
"title": "FILE", "title": "FILE",
"favorites_title": "Preferiti", "favorites_title": "Preferiti",
@ -99,6 +105,9 @@
"prompt_name_in_folder": "Crea sottocartella in \"{{folder}}\".\nInserisci nome cartella:", "prompt_name_in_folder": "Crea sottocartella in \"{{folder}}\".\nInserisci nome cartella:",
"prompt_name_with_path": "Crea nuova cartella.\nInserisci percorso cartella (es. \"Progetti\" o \"Lavoro/2025\"):", "prompt_name_with_path": "Crea nuova cartella.\nInserisci percorso cartella (es. \"Progetti\" o \"Lavoro/2025\"):",
"prompt_rename": "Rinomina cartella \"{{name}}\" in:", "prompt_rename": "Rinomina cartella \"{{name}}\" in:",
"rename_modal_title": "Rinomina cartella",
"rename_modal_description": "Inserisci il nuovo nome per la cartella \"{{name}}\".",
"rename_modal_label": "Nuovo nome",
"invalid_name": "Nome cartella non valido.", "invalid_name": "Nome cartella non valido.",
"cannot_move_into_self": "Impossibile spostare la cartella in se stessa o in una sottocartella.", "cannot_move_into_self": "Impossibile spostare la cartella in se stessa o in una sottocartella.",
"empty": "vuota" "empty": "vuota"

View File

@ -16,6 +16,7 @@
"rename": "名前変更", "rename": "名前変更",
"create": "作成", "create": "作成",
"close": "閉じる", "close": "閉じる",
"confirm_title": "確認",
"yes": "✓ はい", "yes": "✓ はい",
"no": "✗ いいえ", "no": "✗ いいえ",
"ok": "OK", "ok": "OK",
@ -28,6 +29,11 @@
"failed": "{{action}}に失敗しました。もう一度お試しください。" "failed": "{{action}}に失敗しました。もう一度お試しください。"
}, },
"toast": {
"region_label": "通知",
"dismiss": "閉じる"
},
"sidebar": { "sidebar": {
"title": "ファイル", "title": "ファイル",
"favorites_title": "お気に入り", "favorites_title": "お気に入り",
@ -99,6 +105,9 @@
"prompt_name_in_folder": "「{{folder}}」にサブフォルダを作成します。\nフォルダ名を入力:", "prompt_name_in_folder": "「{{folder}}」にサブフォルダを作成します。\nフォルダ名を入力:",
"prompt_name_with_path": "新規フォルダを作成します。\nフォルダパスを入力「プロジェクト」または「仕事/2025」:", "prompt_name_with_path": "新規フォルダを作成します。\nフォルダパスを入力「プロジェクト」または「仕事/2025」:",
"prompt_rename": "フォルダ「{{name}}」の新しい名前:", "prompt_rename": "フォルダ「{{name}}」の新しい名前:",
"rename_modal_title": "フォルダの名前を変更",
"rename_modal_description": "フォルダ「{{name}}」の新しい名前を入力してください。",
"rename_modal_label": "新しい名前",
"invalid_name": "無効なフォルダ名です。", "invalid_name": "無効なフォルダ名です。",
"cannot_move_into_self": "フォルダを自身またはそのサブフォルダに移動できません。", "cannot_move_into_self": "フォルダを自身またはそのサブフォルダに移動できません。",
"empty": "空" "empty": "空"

View File

@ -16,6 +16,7 @@
"rename": "Переименовать", "rename": "Переименовать",
"create": "Создать", "create": "Создать",
"close": "Закрыть", "close": "Закрыть",
"confirm_title": "Подтвердить",
"yes": "✓ Да", "yes": "✓ Да",
"no": "✗ Нет", "no": "✗ Нет",
"ok": "ОК", "ok": "ОК",
@ -28,6 +29,11 @@
"failed": "Не удалось {{action}}. Попробуйте снова." "failed": "Не удалось {{action}}. Попробуйте снова."
}, },
"toast": {
"region_label": "Уведомления",
"dismiss": "Закрыть"
},
"sidebar": { "sidebar": {
"title": "ФАЙЛЫ", "title": "ФАЙЛЫ",
"favorites_title": "Избранное", "favorites_title": "Избранное",
@ -99,6 +105,9 @@
"prompt_name_in_folder": "Создать подпапку в «{{folder}}».\nВведите название:", "prompt_name_in_folder": "Создать подпапку в «{{folder}}».\nВведите название:",
"prompt_name_with_path": "Создать новую папку.\nВведите путь (например, «Проекты» или «Работа/2025»):", "prompt_name_with_path": "Создать новую папку.\nВведите путь (например, «Проекты» или «Работа/2025»):",
"prompt_rename": "Переименовать папку «{{name}}» в:", "prompt_rename": "Переименовать папку «{{name}}» в:",
"rename_modal_title": "Переименовать папку",
"rename_modal_description": "Введите новое имя для папки «{{name}}».",
"rename_modal_label": "Новое имя",
"invalid_name": "Недопустимое название папки.", "invalid_name": "Недопустимое название папки.",
"cannot_move_into_self": "Нельзя переместить папку в себя или в подпапку.", "cannot_move_into_self": "Нельзя переместить папку в себя или в подпапку.",
"empty": "пусто" "empty": "пусто"

View File

@ -16,6 +16,7 @@
"rename": "Preimenuj", "rename": "Preimenuj",
"create": "Ustvari", "create": "Ustvari",
"close": "Zapri", "close": "Zapri",
"confirm_title": "Potrdi",
"yes": "✓ Da", "yes": "✓ Da",
"no": "✗ Ne", "no": "✗ Ne",
"ok": "V redu", "ok": "V redu",
@ -28,6 +29,11 @@
"failed": "Napaka pri {{action}}. Prosimo, poskusite znova." "failed": "Napaka pri {{action}}. Prosimo, poskusite znova."
}, },
"toast": {
"region_label": "Obvestila",
"dismiss": "Zapri"
},
"sidebar": { "sidebar": {
"title": "DATOTEKE", "title": "DATOTEKE",
"favorites_title": "Priljubljene", "favorites_title": "Priljubljene",
@ -99,6 +105,9 @@
"prompt_name_in_folder": "Ustvari podmapo v \"{{folder}}\".\nVnesite ime mape:", "prompt_name_in_folder": "Ustvari podmapo v \"{{folder}}\".\nVnesite ime mape:",
"prompt_name_with_path": "Ustvari novo mapo.\nVnesite pot mape (npr. \"Projekti\" ali \"Delo/2025\"):", "prompt_name_with_path": "Ustvari novo mapo.\nVnesite pot mape (npr. \"Projekti\" ali \"Delo/2025\"):",
"prompt_rename": "Preimenuj mapo \"{{name}}\" v:", "prompt_rename": "Preimenuj mapo \"{{name}}\" v:",
"rename_modal_title": "Preimenuj mapo",
"rename_modal_description": "Vnesite novo ime za mapo \"{{name}}\".",
"rename_modal_label": "Novo ime",
"invalid_name": "Neveljavno ime mape.", "invalid_name": "Neveljavno ime mape.",
"cannot_move_into_self": "Mape ni mogoče premakniti vase ali v njeno podmapo.", "cannot_move_into_self": "Mape ni mogoče premakniti vase ali v njeno podmapo.",
"empty": "prazno" "empty": "prazno"

View File

@ -16,6 +16,7 @@
"rename": "重命名", "rename": "重命名",
"create": "创建", "create": "创建",
"close": "关闭", "close": "关闭",
"confirm_title": "确认",
"yes": "✓ 是", "yes": "✓ 是",
"no": "✗ 否", "no": "✗ 否",
"ok": "确定", "ok": "确定",
@ -28,6 +29,11 @@
"failed": "{{action}} 失败。请重试。" "failed": "{{action}} 失败。请重试。"
}, },
"toast": {
"region_label": "通知",
"dismiss": "关闭"
},
"sidebar": { "sidebar": {
"title": "文件", "title": "文件",
"favorites_title": "收藏夹", "favorites_title": "收藏夹",
@ -99,6 +105,9 @@
"prompt_name_in_folder": "在 \"{{folder}}\" 中创建子文件夹。\n输入文件夹名称", "prompt_name_in_folder": "在 \"{{folder}}\" 中创建子文件夹。\n输入文件夹名称",
"prompt_name_with_path": "创建新文件夹。\n输入文件夹路径例如 \"Projects\" 或 \"Work/2025\"", "prompt_name_with_path": "创建新文件夹。\n输入文件夹路径例如 \"Projects\" 或 \"Work/2025\"",
"prompt_rename": "将文件夹 \"{{name}}\" 重命名为:", "prompt_rename": "将文件夹 \"{{name}}\" 重命名为:",
"rename_modal_title": "重命名文件夹",
"rename_modal_description": "为文件夹 \"{{name}}\" 输入新名称。",
"rename_modal_label": "新名称",
"invalid_name": "无效的文件夹名称。", "invalid_name": "无效的文件夹名称。",
"cannot_move_into_self": "无法将文件夹移动到其自身或其子文件夹中。", "cannot_move_into_self": "无法将文件夹移动到其自身或其子文件夹中。",
"empty": "空" "empty": "空"