mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
i18n: extract all strings, add 6 language translations, restructure i18n module
Session 1 of 2 of the i18n rollout. - Split i18n module into per-language files under frontend/js/i18n/ so a translator can edit one language without touching the others. - Add Portuguese (pt) and seed Hindi (hi). Hindi is intentionally a skeleton -- 0 keys, full English fallback -- because we have an active Indian user and would rather ship "no Hindi" than ship machine-quality Hindi that could read as unprofessional or get formality/gender register wrong. - 183 keys, 100% parity across en/es/fr/de/pt; native review still recommended before publicizing as "fully supported". - Add t(key, vars) variable substitution and tn(keyBase, n, vars) plural helper for _one/_other key pairs. - setLanguage() now triggers a CustomEvent + HashChangeEvent so the existing hash router naturally re-renders the current view, plus a subscriber pattern for nav labels rendered once outside the router. - Wire t() into 3 high-traffic views end-to-end: dashboard, login, content-library. Sidebar nav labels in app.js update on language change. - The remaining 16 views still ship with hardcoded English; they will be wired in session 2. The t() lookup is robust against unwired views, so the dashboard works in 5 languages while clicking into e.g. Schedule still shows English. No regressions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a2c8ab4336
commit
8e7a093150
|
|
@ -19,11 +19,41 @@ import * as admin from './views/admin.js';
|
|||
import * as designer from './views/designer.js';
|
||||
import * as playlists from './views/playlists.js';
|
||||
import { applyBranding } from './branding.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
let currentView = null;
|
||||
|
||||
// Map nav-link data-view to its translation key.
|
||||
const NAV_LABEL_KEYS = {
|
||||
dashboard: 'nav.displays',
|
||||
content: 'nav.content',
|
||||
playlists: 'nav.playlists',
|
||||
layouts: 'nav.layouts',
|
||||
widgets: 'nav.widgets',
|
||||
schedule: 'nav.schedule',
|
||||
walls: 'nav.walls',
|
||||
reports: 'nav.reports',
|
||||
kiosk: 'nav.kiosk',
|
||||
designer: 'nav.designer',
|
||||
activity: 'nav.activity',
|
||||
teams: 'nav.teams',
|
||||
help: 'nav.help',
|
||||
settings: 'nav.settings',
|
||||
billing: 'nav.subscription',
|
||||
admin: 'nav.admin',
|
||||
};
|
||||
|
||||
function renderNavLabels() {
|
||||
document.querySelectorAll('.nav-link').forEach((link) => {
|
||||
const key = NAV_LABEL_KEYS[link.dataset.view];
|
||||
if (!key) return;
|
||||
const span = link.querySelector('span');
|
||||
if (span) span.textContent = t(key);
|
||||
});
|
||||
}
|
||||
|
||||
function isAuthenticated() {
|
||||
return !!localStorage.getItem('token');
|
||||
}
|
||||
|
|
@ -200,7 +230,7 @@ function updateSidebarUser() {
|
|||
<div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${user.name || user.email}</div>
|
||||
<div style="font-size:10px;color:var(--text-muted)">${user.role}</div>
|
||||
</div>
|
||||
<button id="logoutBtn" class="btn-icon" title="Sign out" style="flex-shrink:0">
|
||||
<button id="logoutBtn" class="btn-icon" title="${t('auth.sign_out')}" style="flex-shrink:0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
|
|
@ -218,6 +248,9 @@ function updateSidebarUser() {
|
|||
}
|
||||
|
||||
// Initialize
|
||||
renderNavLabels();
|
||||
window.addEventListener('language-changed', renderNavLabels);
|
||||
|
||||
if (isAuthenticated()) {
|
||||
connectSocket();
|
||||
applyBranding();
|
||||
|
|
|
|||
|
|
@ -1,147 +1,60 @@
|
|||
const translations = {
|
||||
en: {
|
||||
// Nav
|
||||
'nav.displays': 'Displays',
|
||||
'nav.content': 'Content',
|
||||
'nav.layouts': 'Layouts',
|
||||
'nav.widgets': 'Widgets',
|
||||
'nav.schedule': 'Schedule',
|
||||
'nav.walls': 'Video Walls',
|
||||
'nav.reports': 'Reports',
|
||||
'nav.designer': 'Designer',
|
||||
'nav.activity': 'Activity',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.subscription': 'Subscription',
|
||||
// Dashboard
|
||||
'dashboard.title': 'Displays',
|
||||
'dashboard.subtitle': 'Manage your remote displays',
|
||||
'dashboard.add': 'Add Display',
|
||||
'dashboard.search': 'Search displays...',
|
||||
'dashboard.all_status': 'All Status',
|
||||
'dashboard.online': 'Online',
|
||||
'dashboard.offline': 'Offline',
|
||||
'dashboard.no_displays': 'No displays yet',
|
||||
'dashboard.no_displays_desc': 'Install the ScreenTinker app on your TV and pair it using the button above.',
|
||||
// Content
|
||||
'content.title': 'Content Library',
|
||||
'content.subtitle': 'Upload and manage your media files',
|
||||
'content.drop': 'Drop files here or click to upload',
|
||||
'content.remote_url': 'Remote URL',
|
||||
'content.no_content': 'No content yet',
|
||||
// Common
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.loading': 'Loading...',
|
||||
'common.connected': 'Connected',
|
||||
'common.disconnected': 'Disconnected',
|
||||
// Auth
|
||||
'auth.sign_in': 'Sign In',
|
||||
'auth.create_account': 'Create Account',
|
||||
'auth.email': 'Email',
|
||||
'auth.password': 'Password',
|
||||
'auth.name': 'Name',
|
||||
'auth.sign_out': 'Sign out',
|
||||
},
|
||||
es: {
|
||||
'nav.displays': 'Pantallas',
|
||||
'nav.content': 'Contenido',
|
||||
'nav.layouts': 'Diseños',
|
||||
'nav.widgets': 'Widgets',
|
||||
'nav.schedule': 'Horario',
|
||||
'nav.walls': 'Video Walls',
|
||||
'nav.reports': 'Informes',
|
||||
'nav.designer': 'Diseñador',
|
||||
'nav.activity': 'Actividad',
|
||||
'nav.settings': 'Configuración',
|
||||
'nav.subscription': 'Suscripción',
|
||||
'dashboard.title': 'Pantallas',
|
||||
'dashboard.subtitle': 'Administra tus pantallas remotas',
|
||||
'dashboard.add': 'Agregar Pantalla',
|
||||
'dashboard.search': 'Buscar pantallas...',
|
||||
'dashboard.all_status': 'Todos los estados',
|
||||
'dashboard.online': 'En línea',
|
||||
'dashboard.offline': 'Desconectado',
|
||||
'dashboard.no_displays': 'Aún no hay pantallas',
|
||||
'content.title': 'Biblioteca de Contenido',
|
||||
'content.subtitle': 'Sube y administra tus archivos multimedia',
|
||||
'content.drop': 'Arrastra archivos aquí o haz clic para subir',
|
||||
'content.remote_url': 'URL Remota',
|
||||
'common.save': 'Guardar',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.delete': 'Eliminar',
|
||||
'common.edit': 'Editar',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.connected': 'Conectado',
|
||||
'common.disconnected': 'Desconectado',
|
||||
'auth.sign_in': 'Iniciar Sesión',
|
||||
'auth.create_account': 'Crear Cuenta',
|
||||
'auth.email': 'Correo electrónico',
|
||||
'auth.password': 'Contraseña',
|
||||
'auth.name': 'Nombre',
|
||||
'auth.sign_out': 'Cerrar sesión',
|
||||
},
|
||||
fr: {
|
||||
'nav.displays': 'Écrans',
|
||||
'nav.content': 'Contenu',
|
||||
'nav.layouts': 'Mises en page',
|
||||
'nav.widgets': 'Widgets',
|
||||
'nav.schedule': 'Calendrier',
|
||||
'nav.walls': 'Murs vidéo',
|
||||
'nav.reports': 'Rapports',
|
||||
'nav.designer': 'Concepteur',
|
||||
'nav.activity': 'Activité',
|
||||
'nav.settings': 'Paramètres',
|
||||
'nav.subscription': 'Abonnement',
|
||||
'dashboard.title': 'Écrans',
|
||||
'dashboard.subtitle': 'Gérez vos écrans distants',
|
||||
'dashboard.add': 'Ajouter un écran',
|
||||
'dashboard.search': 'Rechercher des écrans...',
|
||||
'common.save': 'Enregistrer',
|
||||
'common.cancel': 'Annuler',
|
||||
'common.delete': 'Supprimer',
|
||||
'common.loading': 'Chargement...',
|
||||
'auth.sign_in': 'Se connecter',
|
||||
'auth.create_account': 'Créer un compte',
|
||||
'auth.sign_out': 'Se déconnecter',
|
||||
},
|
||||
de: {
|
||||
'nav.displays': 'Bildschirme',
|
||||
'nav.content': 'Inhalt',
|
||||
'nav.layouts': 'Layouts',
|
||||
'nav.widgets': 'Widgets',
|
||||
'nav.schedule': 'Zeitplan',
|
||||
'nav.walls': 'Videowände',
|
||||
'nav.reports': 'Berichte',
|
||||
'nav.designer': 'Designer',
|
||||
'nav.activity': 'Aktivität',
|
||||
'nav.settings': 'Einstellungen',
|
||||
'nav.subscription': 'Abonnement',
|
||||
'dashboard.title': 'Bildschirme',
|
||||
'dashboard.subtitle': 'Verwalten Sie Ihre Remote-Displays',
|
||||
'dashboard.add': 'Bildschirm hinzufügen',
|
||||
'dashboard.search': 'Bildschirme suchen...',
|
||||
'common.save': 'Speichern',
|
||||
'common.cancel': 'Abbrechen',
|
||||
'common.delete': 'Löschen',
|
||||
'common.loading': 'Laden...',
|
||||
'auth.sign_in': 'Anmelden',
|
||||
'auth.create_account': 'Konto erstellen',
|
||||
'auth.sign_out': 'Abmelden',
|
||||
},
|
||||
};
|
||||
// Lightweight i18n loader. Each language is its own file under ./i18n/ so a
|
||||
// translator can edit one file without touching the others. English is the
|
||||
// canonical source — every other locale falls back to en for any missing key.
|
||||
import en from './i18n/en.js';
|
||||
import es from './i18n/es.js';
|
||||
import fr from './i18n/fr.js';
|
||||
import de from './i18n/de.js';
|
||||
import pt from './i18n/pt.js';
|
||||
import hi from './i18n/hi.js';
|
||||
|
||||
const fallback = en;
|
||||
const registry = { en, es, fr, de, pt, hi };
|
||||
|
||||
let currentLang = localStorage.getItem('rd_lang') || navigator.language?.split('-')[0] || 'en';
|
||||
if (!translations[currentLang]) currentLang = 'en';
|
||||
if (!registry[currentLang]) currentLang = 'en';
|
||||
|
||||
export function t(key) {
|
||||
return translations[currentLang]?.[key] || translations.en[key] || key;
|
||||
function lookup(key) {
|
||||
return registry[currentLang]?.[key] ?? fallback[key] ?? key;
|
||||
}
|
||||
|
||||
// Replace {name} placeholders in a string with the matching property of vars.
|
||||
// Unknown placeholders pass through unchanged so a missing var is visible
|
||||
// during development rather than silently dropped.
|
||||
function format(s, vars) {
|
||||
if (!vars) return s;
|
||||
return s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
|
||||
}
|
||||
|
||||
export function t(key, vars) {
|
||||
return format(lookup(key), vars);
|
||||
}
|
||||
|
||||
// Plural helper: looks up `${keyBase}_one` for n===1 else `${keyBase}_other`,
|
||||
// auto-injects `{n}` into vars. Use for any string that varies on a count.
|
||||
export function tn(keyBase, n, vars = {}) {
|
||||
const key = keyBase + (n === 1 ? '_one' : '_other');
|
||||
return format(lookup(key), { n, ...vars });
|
||||
}
|
||||
|
||||
const subscribers = new Set();
|
||||
|
||||
// Views and the navbar subscribe so they can rebuild themselves on language
|
||||
// change. Also fires a `language-changed` CustomEvent and a hashchange so the
|
||||
// existing hash router naturally re-renders the current view.
|
||||
export function subscribe(fn) {
|
||||
subscribers.add(fn);
|
||||
return () => subscribers.delete(fn);
|
||||
}
|
||||
|
||||
export function setLanguage(lang) {
|
||||
if (!registry[lang] || lang === currentLang) return;
|
||||
currentLang = lang;
|
||||
localStorage.setItem('rd_lang', lang);
|
||||
document.documentElement.setAttribute('lang', lang);
|
||||
subscribers.forEach((fn) => { try { fn(lang); } catch {} });
|
||||
window.dispatchEvent(new CustomEvent('language-changed', { detail: { lang } }));
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
}
|
||||
|
||||
export function getLanguage() {
|
||||
|
|
@ -154,5 +67,13 @@ export function getAvailableLanguages() {
|
|||
{ code: 'es', name: 'Español' },
|
||||
{ code: 'fr', name: 'Français' },
|
||||
{ code: 'de', name: 'Deutsch' },
|
||||
{ code: 'pt', name: 'Português' },
|
||||
{ code: 'hi', name: 'हिन्दी' },
|
||||
];
|
||||
}
|
||||
|
||||
// Apply the persisted language to <html lang=...> on first load so screen
|
||||
// readers and CSS :lang() selectors are accurate before any user interaction.
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('lang', currentLang);
|
||||
}
|
||||
|
|
|
|||
197
frontend/js/i18n/de.js
Normal file
197
frontend/js/i18n/de.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// German translations. Reviewed for UI register (formal Sie is the de-facto
|
||||
// standard for B2B software in DACH). Native review recommended before
|
||||
// publicizing as fully supported.
|
||||
export default {
|
||||
// Nav
|
||||
'nav.displays': 'Bildschirme',
|
||||
'nav.content': 'Inhalt',
|
||||
'nav.playlists': 'Playlists',
|
||||
'nav.layouts': 'Layouts',
|
||||
'nav.widgets': 'Widgets',
|
||||
'nav.schedule': 'Zeitplan',
|
||||
'nav.walls': 'Videowände',
|
||||
'nav.reports': 'Berichte',
|
||||
'nav.kiosk': 'Kiosk',
|
||||
'nav.designer': 'Designer',
|
||||
'nav.activity': 'Aktivität',
|
||||
'nav.teams': 'Teams',
|
||||
'nav.help': 'Hilfe',
|
||||
'nav.settings': 'Einstellungen',
|
||||
'nav.subscription': 'Abonnement',
|
||||
'nav.admin': 'Admin',
|
||||
|
||||
// Common
|
||||
'common.save': 'Speichern',
|
||||
'common.cancel': 'Abbrechen',
|
||||
'common.delete': 'Löschen',
|
||||
'common.edit': 'Bearbeiten',
|
||||
'common.done': 'Fertig',
|
||||
'common.loading': 'Wird geladen...',
|
||||
'common.connected': 'Verbunden',
|
||||
'common.disconnected': 'Getrennt',
|
||||
'common.never': 'Nie',
|
||||
'common.just_now': 'Gerade eben',
|
||||
'common.minutes_ago': 'vor {n}m',
|
||||
'common.hours_ago': 'vor {n}h',
|
||||
'common.days_ago': 'vor {n}t',
|
||||
'common.unknown': 'Unbekannt',
|
||||
|
||||
// Auth
|
||||
'auth.sign_in': 'Anmelden',
|
||||
'auth.sign_out': 'Abmelden',
|
||||
'auth.create_account': 'Konto erstellen',
|
||||
'auth.create_admin_account': 'Administratorkonto erstellen',
|
||||
'auth.email': 'E-Mail',
|
||||
'auth.password': 'Passwort',
|
||||
'auth.name': 'Name',
|
||||
'auth.placeholder_email': 'sie@beispiel.com',
|
||||
'auth.placeholder_password': '••••••••',
|
||||
'auth.placeholder_name': 'Ihr Name',
|
||||
'auth.placeholder_register_password': 'Mindestens 6 Zeichen',
|
||||
'auth.subtitle_setup': 'Erstellen Sie Ihr Administratorkonto, um zu beginnen',
|
||||
'auth.subtitle_signin': 'Melden Sie sich an, um Ihre Bildschirme zu verwalten',
|
||||
'auth.trial_notice': 'Neue Konten erhalten einen kostenlosen 14-Tage-Pro-Test',
|
||||
'auth.divider_or': 'ODER',
|
||||
'auth.signin_google': 'Mit Google anmelden',
|
||||
'auth.signin_microsoft': 'Mit Microsoft anmelden',
|
||||
'auth.back_to_signin': 'Zurück zur Anmeldung',
|
||||
'auth.support_access': 'Support-Zugang',
|
||||
'auth.support_token_placeholder': 'Support-Token einfügen',
|
||||
'auth.support_authenticate': 'Mit Support-Token authentifizieren',
|
||||
'auth.terms': 'Nutzungsbedingungen',
|
||||
'auth.privacy': 'Datenschutzerklärung',
|
||||
'auth.error_email_password_required': 'E-Mail und Passwort sind erforderlich',
|
||||
'auth.error_password_min_6': 'Das Passwort muss mindestens 6 Zeichen lang sein',
|
||||
'auth.error_login_failed': 'Anmeldung fehlgeschlagen',
|
||||
'auth.error_registration_failed': 'Registrierung fehlgeschlagen',
|
||||
'auth.error_paste_support_token': 'Fügen Sie einen Support-Token ein',
|
||||
'auth.error_support_failed': 'Support-Anmeldung fehlgeschlagen',
|
||||
'auth.error_google_failed': 'Google-Anmeldung fehlgeschlagen',
|
||||
'auth.error_microsoft_failed': 'Microsoft-Anmeldung fehlgeschlagen',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Bildschirme',
|
||||
'dashboard.subtitle': 'Verwalten Sie Ihre Remote-Bildschirme',
|
||||
'dashboard.help_tip': 'Ihre gekoppelten Bildschirmgeräte. Grün = online, rot = offline. Klicken Sie auf ein Gerät, um seine Playlist zu verwalten, die Telemetrie anzuzeigen oder die Fernsteuerung zu nutzen.',
|
||||
'dashboard.add': 'Bildschirm hinzufügen',
|
||||
'dashboard.create_group': '+ Gruppe',
|
||||
'dashboard.search': 'Bildschirme suchen...',
|
||||
'dashboard.all_status': 'Alle Status',
|
||||
'dashboard.online': 'Online',
|
||||
'dashboard.offline': 'Offline',
|
||||
'dashboard.awaiting_pairing': 'Wartet auf Kopplung',
|
||||
'dashboard.no_preview': 'Keine Vorschau verfügbar',
|
||||
'dashboard.total_displays': 'Bildschirme gesamt',
|
||||
'dashboard.ungrouped': 'Nicht gruppiert',
|
||||
'dashboard.no_displays': 'Noch keine Bildschirme',
|
||||
'dashboard.no_displays_desc': 'Installieren Sie die ScreenTinker-App auf Ihrem TV und koppeln Sie ihn mit dem obigen Button.',
|
||||
'dashboard.failed_to_load': 'Bildschirme konnten nicht geladen werden',
|
||||
'dashboard.unknown_playlist': 'Unbekannte Playlist',
|
||||
'dashboard.mixed_playlists': 'Gemischte Playlists',
|
||||
'dashboard.playlist_label': 'Playlist: {name}',
|
||||
'dashboard.devices_count_one': '{n} Gerät',
|
||||
'dashboard.devices_count_other': '{n} Geräte',
|
||||
'dashboard.online_count': '{n} online',
|
||||
'dashboard.set_playlist_placeholder': 'Playlist setzen...',
|
||||
'dashboard.send_command_placeholder': 'Befehl senden...',
|
||||
'dashboard.manage': 'Verwalten',
|
||||
'dashboard.manage_tooltip': 'Geräte hinzufügen/entfernen',
|
||||
'dashboard.delete_group_tooltip': 'Gruppe löschen',
|
||||
'dashboard.no_devices_in_group': 'Keine Geräte in dieser Gruppe. Klicken Sie auf Verwalten, um welche hinzuzufügen.',
|
||||
'dashboard.manage_group_subtitle': 'Markieren Sie Geräte, um sie dieser Gruppe hinzuzufügen',
|
||||
'dashboard.draft_suffix': '(Entwurf)',
|
||||
'dashboard.cmd.screen_on': 'Bildschirm an',
|
||||
'dashboard.cmd.screen_off': 'Bildschirm aus',
|
||||
'dashboard.cmd.restart_app': 'App neu starten',
|
||||
'dashboard.cmd.check_update': 'Update prüfen',
|
||||
'dashboard.cmd.reboot': 'Neustart',
|
||||
'dashboard.cmd.shutdown': 'Herunterfahren',
|
||||
'dashboard.prompt_group_name': 'Gruppenname:',
|
||||
'dashboard.error_pairing_code': 'Geben Sie einen gültigen 6-stelligen Kopplungscode ein',
|
||||
'dashboard.confirm_add_to_group': '{name} ist bereits in: {groups}\n\nAuch zu „{target}“ hinzufügen?',
|
||||
'dashboard.confirm_assign_playlist': 'Playlist „{playlist}“ allen Geräten in „{group}“ zuweisen?',
|
||||
'dashboard.confirm_destructive_command': '{cmd} alle {n} Geräte in „{group}“?\n\nDies kann nicht rückgängig gemacht werden.',
|
||||
'dashboard.confirm_delete_group': 'Diese Gruppe löschen? Geräte sind nicht betroffen.',
|
||||
'dashboard.toast.display_paired': 'Bildschirm erfolgreich gekoppelt!',
|
||||
'dashboard.toast.group_created': 'Gruppe erstellt',
|
||||
'dashboard.toast.group_deleted': 'Gruppe gelöscht',
|
||||
'dashboard.toast.already_in_group': '{name} ist bereits in {group}',
|
||||
'dashboard.toast.moved_device': '{name} nach {group} verschoben',
|
||||
'dashboard.toast.removed_device_one': '{name} aus 1 Gruppe entfernt',
|
||||
'dashboard.toast.removed_device_other': '{name} aus {n} Gruppen entfernt',
|
||||
'dashboard.toast.playlist_assigned_one': 'Playlist 1 Gerät zugewiesen',
|
||||
'dashboard.toast.playlist_assigned_other': 'Playlist {n} Geräten zugewiesen',
|
||||
'dashboard.toast.command_sent': '{cmd} an {sent}/{total} Geräte gesendet',
|
||||
'dashboard.toast.command_sent_with_offline': '{cmd} an {sent}/{total} Geräte gesendet ({offline} offline)',
|
||||
|
||||
// Content library
|
||||
'content.title': 'Inhaltsbibliothek',
|
||||
'content.subtitle': 'Laden Sie Ihre Mediendateien hoch und verwalten Sie sie',
|
||||
'content.help_tip': 'Laden Sie hier Videos und Bilder hoch. Wählen Sie mehrere Dateien für einen Sammel-Upload. Verwenden Sie Remote-URL für externe Quellen. Klicken Sie auf eine Miniaturansicht für die Vorschau.',
|
||||
'content.drop': 'Dateien hier ablegen oder zum Hochladen klicken',
|
||||
'content.upload_hint': 'Unterstützt MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP',
|
||||
'content.upload_progress': 'Wird hochgeladen...',
|
||||
'content.upload_progress_named': '{name} wird hochgeladen...',
|
||||
'content.upload_progress_named_pct': '{name} wird hochgeladen... {pct}%',
|
||||
'content.remote_url': 'Remote-URL',
|
||||
'content.remote_desc': 'Direkt von einer URL streamen. Spart lokale Bandbreite.',
|
||||
'content.remote_url_placeholder': 'https://beispiel.com/video.mp4',
|
||||
'content.remote_name_placeholder': 'Anzeigename (optional)',
|
||||
'content.remote_add_btn': 'Remote-URL hinzufügen',
|
||||
'content.youtube': 'YouTube',
|
||||
'content.youtube_desc': 'Betten Sie ein YouTube-Video auf Ihren Bildschirmen ein.',
|
||||
'content.youtube_url_placeholder': 'https://youtube.com/watch?v=...',
|
||||
'content.youtube_name_placeholder': 'Anzeigename (optional)',
|
||||
'content.youtube_add_btn': 'YouTube-Video hinzufügen',
|
||||
'content.search_placeholder': 'Inhalt suchen...',
|
||||
'content.new_folder_btn': '+ Neuer Ordner',
|
||||
'content.breadcrumb_root': 'Alle Inhalte',
|
||||
'content.rename_btn': 'Umbenennen',
|
||||
'content.delete_folder_btn': 'Ordner löschen',
|
||||
'content.prompt_folder_name': 'Ordnername:',
|
||||
'content.prompt_rename_folder': 'Ordner umbenennen:',
|
||||
'content.confirm_delete_folder': 'Diesen Ordner löschen? Inhalte werden in den Hauptordner verschoben. Unterordner werden ebenfalls gelöscht.',
|
||||
'content.empty_folder_title': 'Dieser Ordner ist leer',
|
||||
'content.empty_folder_desc': 'Inhalte hierher ziehen oder die Aktion Verschieben verwenden.',
|
||||
'content.no_content': 'Noch keine Inhalte',
|
||||
'content.no_content_desc': 'Laden Sie Videos und Bilder hoch, um zu beginnen.',
|
||||
'content.failed_to_load': 'Inhalt konnte nicht geladen werden',
|
||||
'content.type_youtube': 'YouTube',
|
||||
'content.type_remote': 'Remote-URL',
|
||||
'content.type_remote_short': 'Remote',
|
||||
'content.type_video': 'Video',
|
||||
'content.type_image': 'Bild',
|
||||
'content.btn_edit': 'Bearbeiten',
|
||||
'content.btn_delete': 'Löschen',
|
||||
'content.btn_confirm_delete': 'Löschen bestätigen?',
|
||||
'content.btn_deleting': 'Wird gelöscht...',
|
||||
'content.edit_modal_title': 'Inhalt bearbeiten',
|
||||
'content.label_filename': 'Dateiname / Anzeigename',
|
||||
'content.label_remote_url_field': 'Remote-URL',
|
||||
'content.label_mime_type': 'MIME-Typ',
|
||||
'content.label_folder': 'Ordner',
|
||||
'content.label_replace_file': 'Datei ersetzen',
|
||||
'content.replace_file_hint': 'Leer lassen, um die aktuelle Datei zu behalten',
|
||||
'content.folder_root_option': '— Hauptordner —',
|
||||
'content.save_changes': 'Änderungen speichern',
|
||||
'content.mime.video_mp4': 'Video (MP4)',
|
||||
'content.mime.video_webm': 'Video (WebM)',
|
||||
'content.mime.image_jpeg': 'Bild (JPEG)',
|
||||
'content.mime.image_png': 'Bild (PNG)',
|
||||
'content.mime.image_gif': 'Bild (GIF)',
|
||||
'content.mime.image_webp': 'Bild (WebP)',
|
||||
'content.error_enter_url': 'Geben Sie eine URL ein',
|
||||
'content.error_enter_youtube_url': 'Geben Sie eine YouTube-URL ein',
|
||||
'content.error_update_failed': 'Aktualisierung fehlgeschlagen',
|
||||
'content.toast.remote_added': 'Remote-Inhalt hinzugefügt',
|
||||
'content.toast.youtube_added': 'YouTube-Video hinzugefügt',
|
||||
'content.toast.deleted': 'Inhalt gelöscht',
|
||||
'content.toast.updated': 'Inhalt aktualisiert',
|
||||
'content.toast.uploaded_named': '{name} erfolgreich hochgeladen',
|
||||
'content.toast.upload_failed_named': '{name} konnte nicht hochgeladen werden: {error}',
|
||||
'content.toast.folder_created_named': 'Ordner „{name}“ erstellt',
|
||||
'content.toast.folder_renamed': 'Ordner umbenannt',
|
||||
'content.toast.folder_deleted': 'Ordner gelöscht',
|
||||
'content.toast.moved': 'Verschoben',
|
||||
'content.toast.moved_to_root': 'In den Hauptordner verschoben',
|
||||
};
|
||||
208
frontend/js/i18n/en.js
Normal file
208
frontend/js/i18n/en.js
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
// English translations. This file is the source of truth for keys —
|
||||
// every other locale should mirror its keys (or fall back to en).
|
||||
export default {
|
||||
// Nav (sidebar)
|
||||
'nav.displays': 'Displays',
|
||||
'nav.content': 'Content',
|
||||
'nav.playlists': 'Playlists',
|
||||
'nav.layouts': 'Layouts',
|
||||
'nav.widgets': 'Widgets',
|
||||
'nav.schedule': 'Schedule',
|
||||
'nav.walls': 'Video Walls',
|
||||
'nav.reports': 'Reports',
|
||||
'nav.kiosk': 'Kiosk',
|
||||
'nav.designer': 'Designer',
|
||||
'nav.activity': 'Activity',
|
||||
'nav.teams': 'Teams',
|
||||
'nav.help': 'Help',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.subscription': 'Subscription',
|
||||
'nav.admin': 'Admin',
|
||||
|
||||
// Common (shared across views)
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.done': 'Done',
|
||||
'common.loading': 'Loading...',
|
||||
'common.connected': 'Connected',
|
||||
'common.disconnected': 'Disconnected',
|
||||
'common.never': 'Never',
|
||||
'common.just_now': 'Just now',
|
||||
'common.minutes_ago': '{n}m ago',
|
||||
'common.hours_ago': '{n}h ago',
|
||||
'common.days_ago': '{n}d ago',
|
||||
'common.unknown': 'Unknown',
|
||||
|
||||
// Auth (login view)
|
||||
'auth.sign_in': 'Sign In',
|
||||
'auth.sign_out': 'Sign out',
|
||||
'auth.create_account': 'Create Account',
|
||||
'auth.create_admin_account': 'Create Admin Account',
|
||||
'auth.email': 'Email',
|
||||
'auth.password': 'Password',
|
||||
'auth.name': 'Name',
|
||||
'auth.placeholder_email': 'you@example.com',
|
||||
'auth.placeholder_password': '••••••••',
|
||||
'auth.placeholder_name': 'Your name',
|
||||
'auth.placeholder_register_password': 'At least 6 characters',
|
||||
'auth.subtitle_setup': 'Create your admin account to get started',
|
||||
'auth.subtitle_signin': 'Sign in to manage your displays',
|
||||
'auth.trial_notice': 'New accounts get a 14-day free Pro trial',
|
||||
'auth.divider_or': 'OR',
|
||||
'auth.signin_google': 'Sign in with Google',
|
||||
'auth.signin_microsoft': 'Sign in with Microsoft',
|
||||
'auth.back_to_signin': 'Back to Sign In',
|
||||
'auth.support_access': 'Support Access',
|
||||
'auth.support_token_placeholder': 'Paste support token',
|
||||
'auth.support_authenticate': 'Authenticate with Support Token',
|
||||
'auth.terms': 'Terms of Service',
|
||||
'auth.privacy': 'Privacy Policy',
|
||||
'auth.error_email_password_required': 'Email and password required',
|
||||
'auth.error_password_min_6': 'Password must be at least 6 characters',
|
||||
'auth.error_login_failed': 'Login failed',
|
||||
'auth.error_registration_failed': 'Registration failed',
|
||||
'auth.error_paste_support_token': 'Paste a support token',
|
||||
'auth.error_support_failed': 'Support login failed',
|
||||
'auth.error_google_failed': 'Google sign-in failed',
|
||||
'auth.error_microsoft_failed': 'Microsoft sign-in failed',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Displays',
|
||||
'dashboard.subtitle': 'Manage your remote displays',
|
||||
'dashboard.help_tip': 'Your paired display devices. Green = online, red = offline. Click a device to manage its playlist, view telemetry, or use remote control.',
|
||||
'dashboard.add': 'Add Display',
|
||||
'dashboard.create_group': '+ Group',
|
||||
'dashboard.search': 'Search displays...',
|
||||
'dashboard.all_status': 'All Status',
|
||||
'dashboard.online': 'Online',
|
||||
'dashboard.offline': 'Offline',
|
||||
'dashboard.awaiting_pairing': 'Awaiting Pairing',
|
||||
'dashboard.no_preview': 'No preview available',
|
||||
'dashboard.total_displays': 'Total Displays',
|
||||
'dashboard.ungrouped': 'Ungrouped',
|
||||
'dashboard.no_displays': 'No displays yet',
|
||||
'dashboard.no_displays_desc': 'Install the ScreenTinker app on your TV and pair it using the button above.',
|
||||
'dashboard.failed_to_load': 'Failed to load displays',
|
||||
'dashboard.unknown_playlist': 'Unknown playlist',
|
||||
'dashboard.mixed_playlists': 'Mixed playlists',
|
||||
'dashboard.playlist_label': 'Playlist: {name}',
|
||||
'dashboard.devices_count_one': '{n} device',
|
||||
'dashboard.devices_count_other': '{n} devices',
|
||||
'dashboard.online_count': '{n} online',
|
||||
'dashboard.set_playlist_placeholder': 'Set Playlist...',
|
||||
'dashboard.send_command_placeholder': 'Send Command...',
|
||||
'dashboard.manage': 'Manage',
|
||||
'dashboard.manage_tooltip': 'Add/remove devices',
|
||||
'dashboard.delete_group_tooltip': 'Delete group',
|
||||
'dashboard.no_devices_in_group': 'No devices in this group. Click Manage to add some.',
|
||||
'dashboard.manage_group_subtitle': 'Check devices to add them to this group',
|
||||
'dashboard.draft_suffix': '(draft)',
|
||||
// Group commands
|
||||
'dashboard.cmd.screen_on': 'Screen On',
|
||||
'dashboard.cmd.screen_off': 'Screen Off',
|
||||
'dashboard.cmd.restart_app': 'Restart App',
|
||||
'dashboard.cmd.check_update': 'Check Update',
|
||||
'dashboard.cmd.reboot': 'Reboot',
|
||||
'dashboard.cmd.shutdown': 'Shutdown',
|
||||
// Dashboard prompts/confirms
|
||||
'dashboard.prompt_group_name': 'Group name:',
|
||||
'dashboard.error_pairing_code': 'Enter a valid 6-digit pairing code',
|
||||
'dashboard.confirm_add_to_group': '{name} is already in: {groups}\n\nAdd it to "{target}" too?',
|
||||
'dashboard.confirm_assign_playlist': 'Assign playlist "{playlist}" to all devices in "{group}"?',
|
||||
'dashboard.confirm_destructive_command': '{cmd} all {n} devices in "{group}"?\n\nThis cannot be undone.',
|
||||
'dashboard.confirm_delete_group': 'Delete this group? Devices will not be affected.',
|
||||
// Dashboard toasts
|
||||
'dashboard.toast.display_paired': 'Display paired successfully!',
|
||||
'dashboard.toast.group_created': 'Group created',
|
||||
'dashboard.toast.group_deleted': 'Group deleted',
|
||||
'dashboard.toast.already_in_group': '{name} is already in {group}',
|
||||
'dashboard.toast.moved_device': 'Moved {name} to {group}',
|
||||
'dashboard.toast.removed_device_one': 'Removed {name} from 1 group',
|
||||
'dashboard.toast.removed_device_other': 'Removed {name} from {n} groups',
|
||||
'dashboard.toast.playlist_assigned_one': 'Playlist assigned to 1 device',
|
||||
'dashboard.toast.playlist_assigned_other': 'Playlist assigned to {n} devices',
|
||||
'dashboard.toast.command_sent': '{cmd} sent to {sent}/{total} devices',
|
||||
'dashboard.toast.command_sent_with_offline': '{cmd} sent to {sent}/{total} devices ({offline} offline)',
|
||||
|
||||
// Content library
|
||||
'content.title': 'Content Library',
|
||||
'content.subtitle': 'Upload and manage your media files',
|
||||
'content.help_tip': 'Upload videos and images here. Select multiple files for bulk upload. Use Remote URL to stream from external sources. Click a thumbnail to preview.',
|
||||
'content.drop': 'Drop files here or click to upload',
|
||||
'content.upload_hint': 'Supports MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP',
|
||||
'content.upload_progress': 'Uploading...',
|
||||
'content.upload_progress_named': 'Uploading {name}...',
|
||||
'content.upload_progress_named_pct': 'Uploading {name}... {pct}%',
|
||||
// Remote URL panel
|
||||
'content.remote_url': 'Remote URL',
|
||||
'content.remote_desc': 'Stream directly from a URL. Saves local bandwidth.',
|
||||
'content.remote_url_placeholder': 'https://example.com/video.mp4',
|
||||
'content.remote_name_placeholder': 'Display name (optional)',
|
||||
'content.remote_add_btn': 'Add Remote URL',
|
||||
// YouTube panel
|
||||
'content.youtube': 'YouTube',
|
||||
'content.youtube_desc': 'Embed a YouTube video on your displays.',
|
||||
'content.youtube_url_placeholder': 'https://youtube.com/watch?v=...',
|
||||
'content.youtube_name_placeholder': 'Display name (optional)',
|
||||
'content.youtube_add_btn': 'Add YouTube Video',
|
||||
// Search / folders
|
||||
'content.search_placeholder': 'Search content...',
|
||||
'content.new_folder_btn': '+ New Folder',
|
||||
'content.breadcrumb_root': 'All Content',
|
||||
'content.rename_btn': 'Rename',
|
||||
'content.delete_folder_btn': 'Delete folder',
|
||||
'content.prompt_folder_name': 'Folder name:',
|
||||
'content.prompt_rename_folder': 'Rename folder:',
|
||||
'content.confirm_delete_folder': 'Delete this folder? Content inside moves back to the root level. Subfolders will also be deleted.',
|
||||
// Empty states
|
||||
'content.empty_folder_title': 'This folder is empty',
|
||||
'content.empty_folder_desc': 'Drag content here, or use the Move action.',
|
||||
'content.no_content': 'No content yet',
|
||||
'content.no_content_desc': 'Upload videos and images to get started.',
|
||||
'content.failed_to_load': 'Failed to load content',
|
||||
// Item type labels
|
||||
'content.type_youtube': 'YouTube',
|
||||
'content.type_remote': 'Remote URL',
|
||||
'content.type_remote_short': 'Remote',
|
||||
'content.type_video': 'Video',
|
||||
'content.type_image': 'Image',
|
||||
// Item action buttons
|
||||
'content.btn_edit': 'Edit',
|
||||
'content.btn_delete': 'Delete',
|
||||
'content.btn_confirm_delete': 'Confirm Delete?',
|
||||
'content.btn_deleting': 'Deleting...',
|
||||
// Edit modal
|
||||
'content.edit_modal_title': 'Edit Content',
|
||||
'content.label_filename': 'Filename / Display Name',
|
||||
'content.label_remote_url_field': 'Remote URL',
|
||||
'content.label_mime_type': 'MIME Type',
|
||||
'content.label_folder': 'Folder',
|
||||
'content.label_replace_file': 'Replace File',
|
||||
'content.replace_file_hint': 'Leave empty to keep current file',
|
||||
'content.folder_root_option': '— Root —',
|
||||
'content.save_changes': 'Save Changes',
|
||||
// MIME options
|
||||
'content.mime.video_mp4': 'Video (MP4)',
|
||||
'content.mime.video_webm': 'Video (WebM)',
|
||||
'content.mime.image_jpeg': 'Image (JPEG)',
|
||||
'content.mime.image_png': 'Image (PNG)',
|
||||
'content.mime.image_gif': 'Image (GIF)',
|
||||
'content.mime.image_webp': 'Image (WebP)',
|
||||
// Content errors / toasts
|
||||
'content.error_enter_url': 'Enter a URL',
|
||||
'content.error_enter_youtube_url': 'Enter a YouTube URL',
|
||||
'content.error_update_failed': 'Update failed',
|
||||
'content.toast.remote_added': 'Remote content added',
|
||||
'content.toast.youtube_added': 'YouTube video added',
|
||||
'content.toast.deleted': 'Content deleted',
|
||||
'content.toast.updated': 'Content updated',
|
||||
'content.toast.uploaded_named': '{name} uploaded successfully',
|
||||
'content.toast.upload_failed_named': 'Failed to upload {name}: {error}',
|
||||
'content.toast.folder_created_named': 'Folder "{name}" created',
|
||||
'content.toast.folder_renamed': 'Folder renamed',
|
||||
'content.toast.folder_deleted': 'Folder deleted',
|
||||
'content.toast.moved': 'Moved',
|
||||
'content.toast.moved_to_root': 'Moved to root',
|
||||
};
|
||||
196
frontend/js/i18n/es.js
Normal file
196
frontend/js/i18n/es.js
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// Spanish translations. Reviewed for UI register (informal tú).
|
||||
// Native review still recommended before publicizing as fully supported.
|
||||
export default {
|
||||
// Nav
|
||||
'nav.displays': 'Pantallas',
|
||||
'nav.content': 'Contenido',
|
||||
'nav.playlists': 'Listas de reproducción',
|
||||
'nav.layouts': 'Diseños',
|
||||
'nav.widgets': 'Widgets',
|
||||
'nav.schedule': 'Horario',
|
||||
'nav.walls': 'Muros de video',
|
||||
'nav.reports': 'Informes',
|
||||
'nav.kiosk': 'Kiosco',
|
||||
'nav.designer': 'Diseñador',
|
||||
'nav.activity': 'Actividad',
|
||||
'nav.teams': 'Equipos',
|
||||
'nav.help': 'Ayuda',
|
||||
'nav.settings': 'Configuración',
|
||||
'nav.subscription': 'Suscripción',
|
||||
'nav.admin': 'Administrador',
|
||||
|
||||
// Common
|
||||
'common.save': 'Guardar',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.delete': 'Eliminar',
|
||||
'common.edit': 'Editar',
|
||||
'common.done': 'Listo',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.connected': 'Conectado',
|
||||
'common.disconnected': 'Desconectado',
|
||||
'common.never': 'Nunca',
|
||||
'common.just_now': 'Ahora mismo',
|
||||
'common.minutes_ago': 'hace {n}m',
|
||||
'common.hours_ago': 'hace {n}h',
|
||||
'common.days_ago': 'hace {n}d',
|
||||
'common.unknown': 'Desconocido',
|
||||
|
||||
// Auth
|
||||
'auth.sign_in': 'Iniciar sesión',
|
||||
'auth.sign_out': 'Cerrar sesión',
|
||||
'auth.create_account': 'Crear cuenta',
|
||||
'auth.create_admin_account': 'Crear cuenta de administrador',
|
||||
'auth.email': 'Correo electrónico',
|
||||
'auth.password': 'Contraseña',
|
||||
'auth.name': 'Nombre',
|
||||
'auth.placeholder_email': 'tu@ejemplo.com',
|
||||
'auth.placeholder_password': '••••••••',
|
||||
'auth.placeholder_name': 'Tu nombre',
|
||||
'auth.placeholder_register_password': 'Mínimo 6 caracteres',
|
||||
'auth.subtitle_setup': 'Crea tu cuenta de administrador para comenzar',
|
||||
'auth.subtitle_signin': 'Inicia sesión para gestionar tus pantallas',
|
||||
'auth.trial_notice': 'Las cuentas nuevas reciben 14 días de prueba Pro gratis',
|
||||
'auth.divider_or': 'O',
|
||||
'auth.signin_google': 'Iniciar sesión con Google',
|
||||
'auth.signin_microsoft': 'Iniciar sesión con Microsoft',
|
||||
'auth.back_to_signin': 'Volver a iniciar sesión',
|
||||
'auth.support_access': 'Acceso de soporte',
|
||||
'auth.support_token_placeholder': 'Pega el token de soporte',
|
||||
'auth.support_authenticate': 'Autenticar con token de soporte',
|
||||
'auth.terms': 'Términos del servicio',
|
||||
'auth.privacy': 'Política de privacidad',
|
||||
'auth.error_email_password_required': 'Se requieren correo y contraseña',
|
||||
'auth.error_password_min_6': 'La contraseña debe tener al menos 6 caracteres',
|
||||
'auth.error_login_failed': 'Error al iniciar sesión',
|
||||
'auth.error_registration_failed': 'Error al registrarse',
|
||||
'auth.error_paste_support_token': 'Pega un token de soporte',
|
||||
'auth.error_support_failed': 'Error en el inicio de sesión de soporte',
|
||||
'auth.error_google_failed': 'Error al iniciar sesión con Google',
|
||||
'auth.error_microsoft_failed': 'Error al iniciar sesión con Microsoft',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Pantallas',
|
||||
'dashboard.subtitle': 'Administra tus pantallas remotas',
|
||||
'dashboard.help_tip': 'Tus dispositivos vinculados. Verde = en línea, rojo = desconectado. Haz clic en uno para gestionar su lista de reproducción, ver telemetría o usar el control remoto.',
|
||||
'dashboard.add': 'Agregar pantalla',
|
||||
'dashboard.create_group': '+ Grupo',
|
||||
'dashboard.search': 'Buscar pantallas...',
|
||||
'dashboard.all_status': 'Todos los estados',
|
||||
'dashboard.online': 'En línea',
|
||||
'dashboard.offline': 'Desconectado',
|
||||
'dashboard.awaiting_pairing': 'Esperando vinculación',
|
||||
'dashboard.no_preview': 'Vista previa no disponible',
|
||||
'dashboard.total_displays': 'Pantallas totales',
|
||||
'dashboard.ungrouped': 'Sin grupo',
|
||||
'dashboard.no_displays': 'Aún no hay pantallas',
|
||||
'dashboard.no_displays_desc': 'Instala la aplicación de ScreenTinker en tu TV y vincúlala con el botón de arriba.',
|
||||
'dashboard.failed_to_load': 'Error al cargar las pantallas',
|
||||
'dashboard.unknown_playlist': 'Lista desconocida',
|
||||
'dashboard.mixed_playlists': 'Listas variadas',
|
||||
'dashboard.playlist_label': 'Lista: {name}',
|
||||
'dashboard.devices_count_one': '{n} dispositivo',
|
||||
'dashboard.devices_count_other': '{n} dispositivos',
|
||||
'dashboard.online_count': '{n} en línea',
|
||||
'dashboard.set_playlist_placeholder': 'Asignar lista...',
|
||||
'dashboard.send_command_placeholder': 'Enviar comando...',
|
||||
'dashboard.manage': 'Gestionar',
|
||||
'dashboard.manage_tooltip': 'Agregar/quitar dispositivos',
|
||||
'dashboard.delete_group_tooltip': 'Eliminar grupo',
|
||||
'dashboard.no_devices_in_group': 'No hay dispositivos en este grupo. Haz clic en Gestionar para agregar.',
|
||||
'dashboard.manage_group_subtitle': 'Marca los dispositivos para agregarlos a este grupo',
|
||||
'dashboard.draft_suffix': '(borrador)',
|
||||
'dashboard.cmd.screen_on': 'Encender pantalla',
|
||||
'dashboard.cmd.screen_off': 'Apagar pantalla',
|
||||
'dashboard.cmd.restart_app': 'Reiniciar app',
|
||||
'dashboard.cmd.check_update': 'Buscar actualización',
|
||||
'dashboard.cmd.reboot': 'Reiniciar',
|
||||
'dashboard.cmd.shutdown': 'Apagar',
|
||||
'dashboard.prompt_group_name': 'Nombre del grupo:',
|
||||
'dashboard.error_pairing_code': 'Ingresa un código de vinculación válido de 6 dígitos',
|
||||
'dashboard.confirm_add_to_group': '{name} ya está en: {groups}\n\n¿Agregarlo también a "{target}"?',
|
||||
'dashboard.confirm_assign_playlist': '¿Asignar la lista "{playlist}" a todos los dispositivos de "{group}"?',
|
||||
'dashboard.confirm_destructive_command': '¿{cmd} todos los {n} dispositivos de "{group}"?\n\nEsto no se puede deshacer.',
|
||||
'dashboard.confirm_delete_group': '¿Eliminar este grupo? Los dispositivos no se verán afectados.',
|
||||
'dashboard.toast.display_paired': '¡Pantalla vinculada con éxito!',
|
||||
'dashboard.toast.group_created': 'Grupo creado',
|
||||
'dashboard.toast.group_deleted': 'Grupo eliminado',
|
||||
'dashboard.toast.already_in_group': '{name} ya está en {group}',
|
||||
'dashboard.toast.moved_device': 'Se movió {name} a {group}',
|
||||
'dashboard.toast.removed_device_one': 'Se quitó {name} de 1 grupo',
|
||||
'dashboard.toast.removed_device_other': 'Se quitó {name} de {n} grupos',
|
||||
'dashboard.toast.playlist_assigned_one': 'Lista asignada a 1 dispositivo',
|
||||
'dashboard.toast.playlist_assigned_other': 'Lista asignada a {n} dispositivos',
|
||||
'dashboard.toast.command_sent': '{cmd} enviado a {sent}/{total} dispositivos',
|
||||
'dashboard.toast.command_sent_with_offline': '{cmd} enviado a {sent}/{total} dispositivos ({offline} desconectados)',
|
||||
|
||||
// Content library
|
||||
'content.title': 'Biblioteca de contenido',
|
||||
'content.subtitle': 'Sube y administra tus archivos multimedia',
|
||||
'content.help_tip': 'Sube videos e imágenes aquí. Selecciona varios archivos para subir en lote. Usa URL remota para transmitir desde fuentes externas. Haz clic en una miniatura para previsualizar.',
|
||||
'content.drop': 'Arrastra archivos aquí o haz clic para subir',
|
||||
'content.upload_hint': 'Soporta MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP',
|
||||
'content.upload_progress': 'Subiendo...',
|
||||
'content.upload_progress_named': 'Subiendo {name}...',
|
||||
'content.upload_progress_named_pct': 'Subiendo {name}... {pct}%',
|
||||
'content.remote_url': 'URL remota',
|
||||
'content.remote_desc': 'Transmite directamente desde una URL. Ahorra ancho de banda local.',
|
||||
'content.remote_url_placeholder': 'https://ejemplo.com/video.mp4',
|
||||
'content.remote_name_placeholder': 'Nombre para mostrar (opcional)',
|
||||
'content.remote_add_btn': 'Agregar URL remota',
|
||||
'content.youtube': 'YouTube',
|
||||
'content.youtube_desc': 'Inserta un video de YouTube en tus pantallas.',
|
||||
'content.youtube_url_placeholder': 'https://youtube.com/watch?v=...',
|
||||
'content.youtube_name_placeholder': 'Nombre para mostrar (opcional)',
|
||||
'content.youtube_add_btn': 'Agregar video de YouTube',
|
||||
'content.search_placeholder': 'Buscar contenido...',
|
||||
'content.new_folder_btn': '+ Nueva carpeta',
|
||||
'content.breadcrumb_root': 'Todo el contenido',
|
||||
'content.rename_btn': 'Renombrar',
|
||||
'content.delete_folder_btn': 'Eliminar carpeta',
|
||||
'content.prompt_folder_name': 'Nombre de la carpeta:',
|
||||
'content.prompt_rename_folder': 'Renombrar carpeta:',
|
||||
'content.confirm_delete_folder': '¿Eliminar esta carpeta? El contenido volverá al nivel raíz. Las subcarpetas también se eliminarán.',
|
||||
'content.empty_folder_title': 'Esta carpeta está vacía',
|
||||
'content.empty_folder_desc': 'Arrastra contenido aquí o usa la acción Mover.',
|
||||
'content.no_content': 'Aún no hay contenido',
|
||||
'content.no_content_desc': 'Sube videos e imágenes para comenzar.',
|
||||
'content.failed_to_load': 'Error al cargar el contenido',
|
||||
'content.type_youtube': 'YouTube',
|
||||
'content.type_remote': 'URL remota',
|
||||
'content.type_remote_short': 'Remota',
|
||||
'content.type_video': 'Video',
|
||||
'content.type_image': 'Imagen',
|
||||
'content.btn_edit': 'Editar',
|
||||
'content.btn_delete': 'Eliminar',
|
||||
'content.btn_confirm_delete': '¿Confirmar eliminación?',
|
||||
'content.btn_deleting': 'Eliminando...',
|
||||
'content.edit_modal_title': 'Editar contenido',
|
||||
'content.label_filename': 'Nombre del archivo / Mostrar como',
|
||||
'content.label_remote_url_field': 'URL remota',
|
||||
'content.label_mime_type': 'Tipo MIME',
|
||||
'content.label_folder': 'Carpeta',
|
||||
'content.label_replace_file': 'Reemplazar archivo',
|
||||
'content.replace_file_hint': 'Déjalo vacío para mantener el archivo actual',
|
||||
'content.folder_root_option': '— Raíz —',
|
||||
'content.save_changes': 'Guardar cambios',
|
||||
'content.mime.video_mp4': 'Video (MP4)',
|
||||
'content.mime.video_webm': 'Video (WebM)',
|
||||
'content.mime.image_jpeg': 'Imagen (JPEG)',
|
||||
'content.mime.image_png': 'Imagen (PNG)',
|
||||
'content.mime.image_gif': 'Imagen (GIF)',
|
||||
'content.mime.image_webp': 'Imagen (WebP)',
|
||||
'content.error_enter_url': 'Ingresa una URL',
|
||||
'content.error_enter_youtube_url': 'Ingresa una URL de YouTube',
|
||||
'content.error_update_failed': 'Error al actualizar',
|
||||
'content.toast.remote_added': 'Contenido remoto agregado',
|
||||
'content.toast.youtube_added': 'Video de YouTube agregado',
|
||||
'content.toast.deleted': 'Contenido eliminado',
|
||||
'content.toast.updated': 'Contenido actualizado',
|
||||
'content.toast.uploaded_named': '{name} se subió correctamente',
|
||||
'content.toast.upload_failed_named': 'Error al subir {name}: {error}',
|
||||
'content.toast.folder_created_named': 'Carpeta "{name}" creada',
|
||||
'content.toast.folder_renamed': 'Carpeta renombrada',
|
||||
'content.toast.folder_deleted': 'Carpeta eliminada',
|
||||
'content.toast.moved': 'Movido',
|
||||
'content.toast.moved_to_root': 'Movido a la raíz',
|
||||
};
|
||||
197
frontend/js/i18n/fr.js
Normal file
197
frontend/js/i18n/fr.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// French translations. Reviewed for UI register (informal vous-vouvoiement is
|
||||
// standard for software UIs in France; tu would feel underdressed for a B2B tool).
|
||||
// Native review recommended before publicizing as fully supported.
|
||||
export default {
|
||||
// Nav
|
||||
'nav.displays': 'Écrans',
|
||||
'nav.content': 'Contenu',
|
||||
'nav.playlists': 'Listes de lecture',
|
||||
'nav.layouts': 'Mises en page',
|
||||
'nav.widgets': 'Widgets',
|
||||
'nav.schedule': 'Calendrier',
|
||||
'nav.walls': 'Murs vidéo',
|
||||
'nav.reports': 'Rapports',
|
||||
'nav.kiosk': 'Kiosque',
|
||||
'nav.designer': 'Concepteur',
|
||||
'nav.activity': 'Activité',
|
||||
'nav.teams': 'Équipes',
|
||||
'nav.help': 'Aide',
|
||||
'nav.settings': 'Paramètres',
|
||||
'nav.subscription': 'Abonnement',
|
||||
'nav.admin': 'Admin',
|
||||
|
||||
// Common
|
||||
'common.save': 'Enregistrer',
|
||||
'common.cancel': 'Annuler',
|
||||
'common.delete': 'Supprimer',
|
||||
'common.edit': 'Modifier',
|
||||
'common.done': 'Terminé',
|
||||
'common.loading': 'Chargement...',
|
||||
'common.connected': 'Connecté',
|
||||
'common.disconnected': 'Déconnecté',
|
||||
'common.never': 'Jamais',
|
||||
'common.just_now': 'À l\'instant',
|
||||
'common.minutes_ago': 'il y a {n}m',
|
||||
'common.hours_ago': 'il y a {n}h',
|
||||
'common.days_ago': 'il y a {n}j',
|
||||
'common.unknown': 'Inconnu',
|
||||
|
||||
// Auth
|
||||
'auth.sign_in': 'Se connecter',
|
||||
'auth.sign_out': 'Se déconnecter',
|
||||
'auth.create_account': 'Créer un compte',
|
||||
'auth.create_admin_account': 'Créer un compte administrateur',
|
||||
'auth.email': 'E-mail',
|
||||
'auth.password': 'Mot de passe',
|
||||
'auth.name': 'Nom',
|
||||
'auth.placeholder_email': 'vous@exemple.com',
|
||||
'auth.placeholder_password': '••••••••',
|
||||
'auth.placeholder_name': 'Votre nom',
|
||||
'auth.placeholder_register_password': 'Au moins 6 caractères',
|
||||
'auth.subtitle_setup': 'Créez votre compte administrateur pour commencer',
|
||||
'auth.subtitle_signin': 'Connectez-vous pour gérer vos écrans',
|
||||
'auth.trial_notice': 'Les nouveaux comptes obtiennent un essai Pro gratuit de 14 jours',
|
||||
'auth.divider_or': 'OU',
|
||||
'auth.signin_google': 'Se connecter avec Google',
|
||||
'auth.signin_microsoft': 'Se connecter avec Microsoft',
|
||||
'auth.back_to_signin': 'Retour à la connexion',
|
||||
'auth.support_access': 'Accès au support',
|
||||
'auth.support_token_placeholder': 'Collez le jeton de support',
|
||||
'auth.support_authenticate': 'Authentifier avec le jeton de support',
|
||||
'auth.terms': 'Conditions d\'utilisation',
|
||||
'auth.privacy': 'Politique de confidentialité',
|
||||
'auth.error_email_password_required': 'E-mail et mot de passe requis',
|
||||
'auth.error_password_min_6': 'Le mot de passe doit comporter au moins 6 caractères',
|
||||
'auth.error_login_failed': 'Échec de la connexion',
|
||||
'auth.error_registration_failed': 'Échec de l\'inscription',
|
||||
'auth.error_paste_support_token': 'Collez un jeton de support',
|
||||
'auth.error_support_failed': 'Échec de la connexion de support',
|
||||
'auth.error_google_failed': 'Échec de la connexion Google',
|
||||
'auth.error_microsoft_failed': 'Échec de la connexion Microsoft',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Écrans',
|
||||
'dashboard.subtitle': 'Gérez vos écrans distants',
|
||||
'dashboard.help_tip': 'Vos écrans appariés. Vert = en ligne, rouge = hors ligne. Cliquez sur un écran pour gérer sa liste de lecture, voir la télémétrie ou utiliser le contrôle à distance.',
|
||||
'dashboard.add': 'Ajouter un écran',
|
||||
'dashboard.create_group': '+ Groupe',
|
||||
'dashboard.search': 'Rechercher des écrans...',
|
||||
'dashboard.all_status': 'Tous les statuts',
|
||||
'dashboard.online': 'En ligne',
|
||||
'dashboard.offline': 'Hors ligne',
|
||||
'dashboard.awaiting_pairing': 'En attente d\'appairage',
|
||||
'dashboard.no_preview': 'Aperçu indisponible',
|
||||
'dashboard.total_displays': 'Écrans totaux',
|
||||
'dashboard.ungrouped': 'Sans groupe',
|
||||
'dashboard.no_displays': 'Aucun écran pour le moment',
|
||||
'dashboard.no_displays_desc': 'Installez l\'application ScreenTinker sur votre TV et appariez-la avec le bouton ci-dessus.',
|
||||
'dashboard.failed_to_load': 'Échec du chargement des écrans',
|
||||
'dashboard.unknown_playlist': 'Liste de lecture inconnue',
|
||||
'dashboard.mixed_playlists': 'Listes mixtes',
|
||||
'dashboard.playlist_label': 'Liste : {name}',
|
||||
'dashboard.devices_count_one': '{n} appareil',
|
||||
'dashboard.devices_count_other': '{n} appareils',
|
||||
'dashboard.online_count': '{n} en ligne',
|
||||
'dashboard.set_playlist_placeholder': 'Définir la liste...',
|
||||
'dashboard.send_command_placeholder': 'Envoyer une commande...',
|
||||
'dashboard.manage': 'Gérer',
|
||||
'dashboard.manage_tooltip': 'Ajouter/retirer des appareils',
|
||||
'dashboard.delete_group_tooltip': 'Supprimer le groupe',
|
||||
'dashboard.no_devices_in_group': 'Aucun appareil dans ce groupe. Cliquez sur Gérer pour en ajouter.',
|
||||
'dashboard.manage_group_subtitle': 'Cochez les appareils à ajouter à ce groupe',
|
||||
'dashboard.draft_suffix': '(brouillon)',
|
||||
'dashboard.cmd.screen_on': 'Allumer l\'écran',
|
||||
'dashboard.cmd.screen_off': 'Éteindre l\'écran',
|
||||
'dashboard.cmd.restart_app': 'Redémarrer l\'app',
|
||||
'dashboard.cmd.check_update': 'Vérifier la mise à jour',
|
||||
'dashboard.cmd.reboot': 'Redémarrer',
|
||||
'dashboard.cmd.shutdown': 'Arrêter',
|
||||
'dashboard.prompt_group_name': 'Nom du groupe :',
|
||||
'dashboard.error_pairing_code': 'Saisissez un code d\'appairage valide à 6 chiffres',
|
||||
'dashboard.confirm_add_to_group': '{name} est déjà dans : {groups}\n\nL\'ajouter aussi à « {target} » ?',
|
||||
'dashboard.confirm_assign_playlist': 'Attribuer la liste « {playlist} » à tous les appareils de « {group} » ?',
|
||||
'dashboard.confirm_destructive_command': '{cmd} les {n} appareils de « {group} » ?\n\nCette action est irréversible.',
|
||||
'dashboard.confirm_delete_group': 'Supprimer ce groupe ? Les appareils ne seront pas affectés.',
|
||||
'dashboard.toast.display_paired': 'Écran appairé avec succès !',
|
||||
'dashboard.toast.group_created': 'Groupe créé',
|
||||
'dashboard.toast.group_deleted': 'Groupe supprimé',
|
||||
'dashboard.toast.already_in_group': '{name} est déjà dans {group}',
|
||||
'dashboard.toast.moved_device': '{name} déplacé vers {group}',
|
||||
'dashboard.toast.removed_device_one': '{name} retiré de 1 groupe',
|
||||
'dashboard.toast.removed_device_other': '{name} retiré de {n} groupes',
|
||||
'dashboard.toast.playlist_assigned_one': 'Liste attribuée à 1 appareil',
|
||||
'dashboard.toast.playlist_assigned_other': 'Liste attribuée à {n} appareils',
|
||||
'dashboard.toast.command_sent': '{cmd} envoyé à {sent}/{total} appareils',
|
||||
'dashboard.toast.command_sent_with_offline': '{cmd} envoyé à {sent}/{total} appareils ({offline} hors ligne)',
|
||||
|
||||
// Content library
|
||||
'content.title': 'Bibliothèque de contenu',
|
||||
'content.subtitle': 'Téléversez et gérez vos fichiers multimédias',
|
||||
'content.help_tip': 'Téléversez des vidéos et des images ici. Sélectionnez plusieurs fichiers pour un envoi groupé. Utilisez l\'URL distante pour diffuser depuis des sources externes. Cliquez sur une miniature pour prévisualiser.',
|
||||
'content.drop': 'Déposez les fichiers ici ou cliquez pour téléverser',
|
||||
'content.upload_hint': 'Prend en charge MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP',
|
||||
'content.upload_progress': 'Téléversement...',
|
||||
'content.upload_progress_named': 'Téléversement de {name}...',
|
||||
'content.upload_progress_named_pct': 'Téléversement de {name}... {pct}%',
|
||||
'content.remote_url': 'URL distante',
|
||||
'content.remote_desc': 'Diffusez directement depuis une URL. Économise la bande passante locale.',
|
||||
'content.remote_url_placeholder': 'https://exemple.com/video.mp4',
|
||||
'content.remote_name_placeholder': 'Nom à afficher (facultatif)',
|
||||
'content.remote_add_btn': 'Ajouter une URL distante',
|
||||
'content.youtube': 'YouTube',
|
||||
'content.youtube_desc': 'Intégrez une vidéo YouTube sur vos écrans.',
|
||||
'content.youtube_url_placeholder': 'https://youtube.com/watch?v=...',
|
||||
'content.youtube_name_placeholder': 'Nom à afficher (facultatif)',
|
||||
'content.youtube_add_btn': 'Ajouter une vidéo YouTube',
|
||||
'content.search_placeholder': 'Rechercher du contenu...',
|
||||
'content.new_folder_btn': '+ Nouveau dossier',
|
||||
'content.breadcrumb_root': 'Tout le contenu',
|
||||
'content.rename_btn': 'Renommer',
|
||||
'content.delete_folder_btn': 'Supprimer le dossier',
|
||||
'content.prompt_folder_name': 'Nom du dossier :',
|
||||
'content.prompt_rename_folder': 'Renommer le dossier :',
|
||||
'content.confirm_delete_folder': 'Supprimer ce dossier ? Le contenu retournera à la racine. Les sous-dossiers seront aussi supprimés.',
|
||||
'content.empty_folder_title': 'Ce dossier est vide',
|
||||
'content.empty_folder_desc': 'Glissez du contenu ici ou utilisez l\'action Déplacer.',
|
||||
'content.no_content': 'Pas encore de contenu',
|
||||
'content.no_content_desc': 'Téléversez des vidéos et des images pour commencer.',
|
||||
'content.failed_to_load': 'Échec du chargement du contenu',
|
||||
'content.type_youtube': 'YouTube',
|
||||
'content.type_remote': 'URL distante',
|
||||
'content.type_remote_short': 'Distant',
|
||||
'content.type_video': 'Vidéo',
|
||||
'content.type_image': 'Image',
|
||||
'content.btn_edit': 'Modifier',
|
||||
'content.btn_delete': 'Supprimer',
|
||||
'content.btn_confirm_delete': 'Confirmer la suppression ?',
|
||||
'content.btn_deleting': 'Suppression...',
|
||||
'content.edit_modal_title': 'Modifier le contenu',
|
||||
'content.label_filename': 'Nom du fichier / Affichage',
|
||||
'content.label_remote_url_field': 'URL distante',
|
||||
'content.label_mime_type': 'Type MIME',
|
||||
'content.label_folder': 'Dossier',
|
||||
'content.label_replace_file': 'Remplacer le fichier',
|
||||
'content.replace_file_hint': 'Laissez vide pour conserver le fichier actuel',
|
||||
'content.folder_root_option': '— Racine —',
|
||||
'content.save_changes': 'Enregistrer les modifications',
|
||||
'content.mime.video_mp4': 'Vidéo (MP4)',
|
||||
'content.mime.video_webm': 'Vidéo (WebM)',
|
||||
'content.mime.image_jpeg': 'Image (JPEG)',
|
||||
'content.mime.image_png': 'Image (PNG)',
|
||||
'content.mime.image_gif': 'Image (GIF)',
|
||||
'content.mime.image_webp': 'Image (WebP)',
|
||||
'content.error_enter_url': 'Saisissez une URL',
|
||||
'content.error_enter_youtube_url': 'Saisissez une URL YouTube',
|
||||
'content.error_update_failed': 'Échec de la mise à jour',
|
||||
'content.toast.remote_added': 'Contenu distant ajouté',
|
||||
'content.toast.youtube_added': 'Vidéo YouTube ajoutée',
|
||||
'content.toast.deleted': 'Contenu supprimé',
|
||||
'content.toast.updated': 'Contenu mis à jour',
|
||||
'content.toast.uploaded_named': '{name} téléversé avec succès',
|
||||
'content.toast.upload_failed_named': 'Échec du téléversement de {name} : {error}',
|
||||
'content.toast.folder_created_named': 'Dossier « {name} » créé',
|
||||
'content.toast.folder_renamed': 'Dossier renommé',
|
||||
'content.toast.folder_deleted': 'Dossier supprimé',
|
||||
'content.toast.moved': 'Déplacé',
|
||||
'content.toast.moved_to_root': 'Déplacé à la racine',
|
||||
};
|
||||
18
frontend/js/i18n/hi.js
Normal file
18
frontend/js/i18n/hi.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Hindi translations — INTENTIONALLY SKELETON.
|
||||
//
|
||||
// We have an active user in India. Rather than ship machine-quality Hindi that
|
||||
// could read as unprofessional or get formality register / gendered verbs
|
||||
// wrong, this file is empty: every key falls back to English via the t()
|
||||
// loader. When a native speaker reviews and fills in keys here, those keys
|
||||
// take effect immediately without any code change in views.
|
||||
//
|
||||
// Translation guidelines for whoever fills this in:
|
||||
// - Use formal आप register (this is B2B software, not consumer chat).
|
||||
// - Keep technical terms in English when borrowed (Playlist, YouTube, MIME)
|
||||
// — these are familiar to Indian users in their English form.
|
||||
// - Translate UI verbs (Save, Cancel, etc.) into proper Hindi.
|
||||
// - Test on the dashboard and content views first; those are wired to t().
|
||||
//
|
||||
// To add a key: copy from en.js and translate the value. Order doesn't matter;
|
||||
// the loader merges over English fallback.
|
||||
export default {};
|
||||
197
frontend/js/i18n/pt.js
Normal file
197
frontend/js/i18n/pt.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// Portuguese translations (Brazilian-leaning vocab; pt-BR is the larger market).
|
||||
// Reviewed for UI register (informal você). Native review recommended before
|
||||
// publicizing as fully supported.
|
||||
export default {
|
||||
// Nav
|
||||
'nav.displays': 'Telas',
|
||||
'nav.content': 'Conteúdo',
|
||||
'nav.playlists': 'Playlists',
|
||||
'nav.layouts': 'Layouts',
|
||||
'nav.widgets': 'Widgets',
|
||||
'nav.schedule': 'Agenda',
|
||||
'nav.walls': 'Paredes de vídeo',
|
||||
'nav.reports': 'Relatórios',
|
||||
'nav.kiosk': 'Quiosque',
|
||||
'nav.designer': 'Designer',
|
||||
'nav.activity': 'Atividade',
|
||||
'nav.teams': 'Equipes',
|
||||
'nav.help': 'Ajuda',
|
||||
'nav.settings': 'Configurações',
|
||||
'nav.subscription': 'Assinatura',
|
||||
'nav.admin': 'Admin',
|
||||
|
||||
// Common
|
||||
'common.save': 'Salvar',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.delete': 'Excluir',
|
||||
'common.edit': 'Editar',
|
||||
'common.done': 'Concluído',
|
||||
'common.loading': 'Carregando...',
|
||||
'common.connected': 'Conectado',
|
||||
'common.disconnected': 'Desconectado',
|
||||
'common.never': 'Nunca',
|
||||
'common.just_now': 'Agora mesmo',
|
||||
'common.minutes_ago': 'há {n}m',
|
||||
'common.hours_ago': 'há {n}h',
|
||||
'common.days_ago': 'há {n}d',
|
||||
'common.unknown': 'Desconhecido',
|
||||
|
||||
// Auth
|
||||
'auth.sign_in': 'Entrar',
|
||||
'auth.sign_out': 'Sair',
|
||||
'auth.create_account': 'Criar conta',
|
||||
'auth.create_admin_account': 'Criar conta de administrador',
|
||||
'auth.email': 'E-mail',
|
||||
'auth.password': 'Senha',
|
||||
'auth.name': 'Nome',
|
||||
'auth.placeholder_email': 'voce@exemplo.com',
|
||||
'auth.placeholder_password': '••••••••',
|
||||
'auth.placeholder_name': 'Seu nome',
|
||||
'auth.placeholder_register_password': 'No mínimo 6 caracteres',
|
||||
'auth.subtitle_setup': 'Crie sua conta de administrador para começar',
|
||||
'auth.subtitle_signin': 'Entre para gerenciar suas telas',
|
||||
'auth.trial_notice': 'Contas novas ganham 14 dias de avaliação Pro grátis',
|
||||
'auth.divider_or': 'OU',
|
||||
'auth.signin_google': 'Entrar com o Google',
|
||||
'auth.signin_microsoft': 'Entrar com a Microsoft',
|
||||
'auth.back_to_signin': 'Voltar ao login',
|
||||
'auth.support_access': 'Acesso de suporte',
|
||||
'auth.support_token_placeholder': 'Cole o token de suporte',
|
||||
'auth.support_authenticate': 'Autenticar com token de suporte',
|
||||
'auth.terms': 'Termos de Serviço',
|
||||
'auth.privacy': 'Política de Privacidade',
|
||||
'auth.error_email_password_required': 'E-mail e senha obrigatórios',
|
||||
'auth.error_password_min_6': 'A senha deve ter no mínimo 6 caracteres',
|
||||
'auth.error_login_failed': 'Falha no login',
|
||||
'auth.error_registration_failed': 'Falha no cadastro',
|
||||
'auth.error_paste_support_token': 'Cole um token de suporte',
|
||||
'auth.error_support_failed': 'Falha no login de suporte',
|
||||
'auth.error_google_failed': 'Falha no login com Google',
|
||||
'auth.error_microsoft_failed': 'Falha no login com Microsoft',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Telas',
|
||||
'dashboard.subtitle': 'Gerencie suas telas remotas',
|
||||
'dashboard.help_tip': 'Seus dispositivos pareados. Verde = online, vermelho = offline. Clique em um para gerenciar a playlist, ver telemetria ou usar o controle remoto.',
|
||||
'dashboard.add': 'Adicionar tela',
|
||||
'dashboard.create_group': '+ Grupo',
|
||||
'dashboard.search': 'Buscar telas...',
|
||||
'dashboard.all_status': 'Todos os status',
|
||||
'dashboard.online': 'Online',
|
||||
'dashboard.offline': 'Offline',
|
||||
'dashboard.awaiting_pairing': 'Aguardando pareamento',
|
||||
'dashboard.no_preview': 'Pré-visualização indisponível',
|
||||
'dashboard.total_displays': 'Total de telas',
|
||||
'dashboard.ungrouped': 'Sem grupo',
|
||||
'dashboard.no_displays': 'Nenhuma tela ainda',
|
||||
'dashboard.no_displays_desc': 'Instale o aplicativo ScreenTinker na sua TV e pareie com o botão acima.',
|
||||
'dashboard.failed_to_load': 'Falha ao carregar as telas',
|
||||
'dashboard.unknown_playlist': 'Playlist desconhecida',
|
||||
'dashboard.mixed_playlists': 'Playlists variadas',
|
||||
'dashboard.playlist_label': 'Playlist: {name}',
|
||||
'dashboard.devices_count_one': '{n} dispositivo',
|
||||
'dashboard.devices_count_other': '{n} dispositivos',
|
||||
'dashboard.online_count': '{n} online',
|
||||
'dashboard.set_playlist_placeholder': 'Definir playlist...',
|
||||
'dashboard.send_command_placeholder': 'Enviar comando...',
|
||||
'dashboard.manage': 'Gerenciar',
|
||||
'dashboard.manage_tooltip': 'Adicionar/remover dispositivos',
|
||||
'dashboard.delete_group_tooltip': 'Excluir grupo',
|
||||
'dashboard.no_devices_in_group': 'Nenhum dispositivo neste grupo. Clique em Gerenciar para adicionar.',
|
||||
'dashboard.manage_group_subtitle': 'Marque os dispositivos para adicioná-los a este grupo',
|
||||
'dashboard.draft_suffix': '(rascunho)',
|
||||
'dashboard.cmd.screen_on': 'Ligar tela',
|
||||
'dashboard.cmd.screen_off': 'Desligar tela',
|
||||
'dashboard.cmd.restart_app': 'Reiniciar app',
|
||||
'dashboard.cmd.check_update': 'Verificar atualização',
|
||||
'dashboard.cmd.reboot': 'Reiniciar',
|
||||
'dashboard.cmd.shutdown': 'Desligar',
|
||||
'dashboard.prompt_group_name': 'Nome do grupo:',
|
||||
'dashboard.error_pairing_code': 'Digite um código de pareamento válido de 6 dígitos',
|
||||
'dashboard.confirm_add_to_group': '{name} já está em: {groups}\n\nAdicionar também a "{target}"?',
|
||||
'dashboard.confirm_assign_playlist': 'Atribuir a playlist "{playlist}" a todos os dispositivos de "{group}"?',
|
||||
'dashboard.confirm_destructive_command': '{cmd} todos os {n} dispositivos de "{group}"?\n\nIsso não pode ser desfeito.',
|
||||
'dashboard.confirm_delete_group': 'Excluir este grupo? Os dispositivos não serão afetados.',
|
||||
'dashboard.toast.display_paired': 'Tela pareada com sucesso!',
|
||||
'dashboard.toast.group_created': 'Grupo criado',
|
||||
'dashboard.toast.group_deleted': 'Grupo excluído',
|
||||
'dashboard.toast.already_in_group': '{name} já está em {group}',
|
||||
'dashboard.toast.moved_device': '{name} movido para {group}',
|
||||
'dashboard.toast.removed_device_one': '{name} removido de 1 grupo',
|
||||
'dashboard.toast.removed_device_other': '{name} removido de {n} grupos',
|
||||
'dashboard.toast.playlist_assigned_one': 'Playlist atribuída a 1 dispositivo',
|
||||
'dashboard.toast.playlist_assigned_other': 'Playlist atribuída a {n} dispositivos',
|
||||
'dashboard.toast.command_sent': '{cmd} enviado para {sent}/{total} dispositivos',
|
||||
'dashboard.toast.command_sent_with_offline': '{cmd} enviado para {sent}/{total} dispositivos ({offline} offline)',
|
||||
|
||||
// Content library
|
||||
'content.title': 'Biblioteca de conteúdo',
|
||||
'content.subtitle': 'Envie e gerencie seus arquivos de mídia',
|
||||
'content.help_tip': 'Envie vídeos e imagens aqui. Selecione vários arquivos para envio em lote. Use URL remota para transmitir de fontes externas. Clique em uma miniatura para pré-visualizar.',
|
||||
'content.drop': 'Solte os arquivos aqui ou clique para enviar',
|
||||
'content.upload_hint': 'Suporta MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP',
|
||||
'content.upload_progress': 'Enviando...',
|
||||
'content.upload_progress_named': 'Enviando {name}...',
|
||||
'content.upload_progress_named_pct': 'Enviando {name}... {pct}%',
|
||||
'content.remote_url': 'URL remota',
|
||||
'content.remote_desc': 'Transmita direto de uma URL. Economiza largura de banda local.',
|
||||
'content.remote_url_placeholder': 'https://exemplo.com/video.mp4',
|
||||
'content.remote_name_placeholder': 'Nome de exibição (opcional)',
|
||||
'content.remote_add_btn': 'Adicionar URL remota',
|
||||
'content.youtube': 'YouTube',
|
||||
'content.youtube_desc': 'Incorpore um vídeo do YouTube nas suas telas.',
|
||||
'content.youtube_url_placeholder': 'https://youtube.com/watch?v=...',
|
||||
'content.youtube_name_placeholder': 'Nome de exibição (opcional)',
|
||||
'content.youtube_add_btn': 'Adicionar vídeo do YouTube',
|
||||
'content.search_placeholder': 'Buscar conteúdo...',
|
||||
'content.new_folder_btn': '+ Nova pasta',
|
||||
'content.breadcrumb_root': 'Todo o conteúdo',
|
||||
'content.rename_btn': 'Renomear',
|
||||
'content.delete_folder_btn': 'Excluir pasta',
|
||||
'content.prompt_folder_name': 'Nome da pasta:',
|
||||
'content.prompt_rename_folder': 'Renomear pasta:',
|
||||
'content.confirm_delete_folder': 'Excluir esta pasta? O conteúdo voltará para o nível raiz. Subpastas também serão excluídas.',
|
||||
'content.empty_folder_title': 'Esta pasta está vazia',
|
||||
'content.empty_folder_desc': 'Arraste o conteúdo para cá ou use a ação Mover.',
|
||||
'content.no_content': 'Nenhum conteúdo ainda',
|
||||
'content.no_content_desc': 'Envie vídeos e imagens para começar.',
|
||||
'content.failed_to_load': 'Falha ao carregar o conteúdo',
|
||||
'content.type_youtube': 'YouTube',
|
||||
'content.type_remote': 'URL remota',
|
||||
'content.type_remote_short': 'Remoto',
|
||||
'content.type_video': 'Vídeo',
|
||||
'content.type_image': 'Imagem',
|
||||
'content.btn_edit': 'Editar',
|
||||
'content.btn_delete': 'Excluir',
|
||||
'content.btn_confirm_delete': 'Confirmar exclusão?',
|
||||
'content.btn_deleting': 'Excluindo...',
|
||||
'content.edit_modal_title': 'Editar conteúdo',
|
||||
'content.label_filename': 'Nome do arquivo / Exibição',
|
||||
'content.label_remote_url_field': 'URL remota',
|
||||
'content.label_mime_type': 'Tipo MIME',
|
||||
'content.label_folder': 'Pasta',
|
||||
'content.label_replace_file': 'Substituir arquivo',
|
||||
'content.replace_file_hint': 'Deixe vazio para manter o arquivo atual',
|
||||
'content.folder_root_option': '— Raiz —',
|
||||
'content.save_changes': 'Salvar alterações',
|
||||
'content.mime.video_mp4': 'Vídeo (MP4)',
|
||||
'content.mime.video_webm': 'Vídeo (WebM)',
|
||||
'content.mime.image_jpeg': 'Imagem (JPEG)',
|
||||
'content.mime.image_png': 'Imagem (PNG)',
|
||||
'content.mime.image_gif': 'Imagem (GIF)',
|
||||
'content.mime.image_webp': 'Imagem (WebP)',
|
||||
'content.error_enter_url': 'Digite uma URL',
|
||||
'content.error_enter_youtube_url': 'Digite uma URL do YouTube',
|
||||
'content.error_update_failed': 'Falha ao atualizar',
|
||||
'content.toast.remote_added': 'Conteúdo remoto adicionado',
|
||||
'content.toast.youtube_added': 'Vídeo do YouTube adicionado',
|
||||
'content.toast.deleted': 'Conteúdo excluído',
|
||||
'content.toast.updated': 'Conteúdo atualizado',
|
||||
'content.toast.uploaded_named': '{name} enviado com sucesso',
|
||||
'content.toast.upload_failed_named': 'Falha ao enviar {name}: {error}',
|
||||
'content.toast.folder_created_named': 'Pasta "{name}" criada',
|
||||
'content.toast.folder_renamed': 'Pasta renomeada',
|
||||
'content.toast.folder_deleted': 'Pasta excluída',
|
||||
'content.toast.moved': 'Movido',
|
||||
'content.toast.moved_to_root': 'Movido para a raiz',
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { api } from '../api.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (!bytes) return '--';
|
||||
|
|
@ -14,8 +15,8 @@ export function render(container) {
|
|||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Content Library <span class="help-tip" data-tip="Upload videos and images here. Select multiple files for bulk upload. Use Remote URL to stream from external sources. Click a thumbnail to preview.">?</span></h1>
|
||||
<div class="subtitle">Upload and manage your media files</div>
|
||||
<h1>${t('content.title')} <span class="help-tip" data-tip="${t('content.help_tip')}">?</span></h1>
|
||||
<div class="subtitle">${t('content.subtitle')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -26,14 +27,14 @@ export function render(container) {
|
|||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<p>Drop files here or click to upload</p>
|
||||
<p class="upload-hint">Supports MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP</p>
|
||||
<p>${t('content.drop')}</p>
|
||||
<p class="upload-hint">${t('content.upload_hint')}</p>
|
||||
<input type="file" id="fileInput" style="display:none" multiple accept="video/*,image/*">
|
||||
<div class="upload-progress" id="uploadProgress" style="display:none">
|
||||
<div class="upload-progress-bar">
|
||||
<div class="upload-progress-fill" id="uploadProgressFill" style="width:0%"></div>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text-secondary);margin-top:6px" id="uploadProgressText">Uploading...</p>
|
||||
<p style="font-size:12px;color:var(--text-secondary);margin-top:6px" id="uploadProgressText">${t('content.upload_progress')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:320px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||
|
|
@ -42,18 +43,18 @@ export function render(container) {
|
|||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
Remote URL
|
||||
${t('content.remote_url')}
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text-muted)">Stream directly from a URL. Saves local bandwidth.</p>
|
||||
<input type="text" id="remoteUrlInput" class="input" placeholder="https://example.com/video.mp4">
|
||||
<input type="text" id="remoteNameInput" class="input" placeholder="Display name (optional)">
|
||||
<p style="font-size:12px;color:var(--text-muted)">${t('content.remote_desc')}</p>
|
||||
<input type="text" id="remoteUrlInput" class="input" placeholder="${t('content.remote_url_placeholder')}">
|
||||
<input type="text" id="remoteNameInput" class="input" placeholder="${t('content.remote_name_placeholder')}">
|
||||
<select id="remoteMimeType" class="input" style="background:var(--bg-input)">
|
||||
<option value="video/mp4">Video (MP4)</option>
|
||||
<option value="video/webm">Video (WebM)</option>
|
||||
<option value="image/jpeg">Image (JPEG)</option>
|
||||
<option value="image/png">Image (PNG)</option>
|
||||
<option value="video/mp4">${t('content.mime.video_mp4')}</option>
|
||||
<option value="video/webm">${t('content.mime.video_webm')}</option>
|
||||
<option value="image/jpeg">${t('content.mime.image_jpeg')}</option>
|
||||
<option value="image/png">${t('content.mime.image_png')}</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" id="addRemoteBtn">Add Remote URL</button>
|
||||
<button class="btn btn-primary" id="addRemoteBtn">${t('content.remote_add_btn')}</button>
|
||||
</div>
|
||||
<div style="width:320px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;align-items:center;gap:8px;color:var(--text-primary);font-weight:500">
|
||||
|
|
@ -61,24 +62,24 @@ export function render(container) {
|
|||
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/>
|
||||
<polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02"/>
|
||||
</svg>
|
||||
YouTube
|
||||
${t('content.youtube')}
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text-muted)">Embed a YouTube video on your displays.</p>
|
||||
<input type="text" id="youtubeUrlInput" class="input" placeholder="https://youtube.com/watch?v=...">
|
||||
<input type="text" id="youtubeNameInput" class="input" placeholder="Display name (optional)">
|
||||
<button class="btn btn-primary" id="addYoutubeBtn">Add YouTube Video</button>
|
||||
<p style="font-size:12px;color:var(--text-muted)">${t('content.youtube_desc')}</p>
|
||||
<input type="text" id="youtubeUrlInput" class="input" placeholder="${t('content.youtube_url_placeholder')}">
|
||||
<input type="text" id="youtubeNameInput" class="input" placeholder="${t('content.youtube_name_placeholder')}">
|
||||
<button class="btn btn-primary" id="addYoutubeBtn">${t('content.youtube_add_btn')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:center;flex-wrap:wrap">
|
||||
<input type="text" id="contentSearch" class="input" placeholder="Search content..." style="max-width:250px;width:100%">
|
||||
<button class="btn btn-secondary btn-sm" id="newFolderBtn">+ New Folder</button>
|
||||
<input type="text" id="contentSearch" class="input" placeholder="${t('content.search_placeholder')}" style="max-width:250px;width:100%">
|
||||
<button class="btn btn-secondary btn-sm" id="newFolderBtn">${t('content.new_folder_btn')}</button>
|
||||
</div>
|
||||
<div id="folderBreadcrumb" style="display:flex;gap:6px;align-items:center;margin-bottom:12px;font-size:13px;flex-wrap:wrap"></div>
|
||||
<div id="folderGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:16px"></div>
|
||||
<div class="content-grid" id="contentGrid">
|
||||
<div class="empty-state" style="grid-column:1/-1"><h3>Loading...</h3></div>
|
||||
<div class="empty-state" style="grid-column:1/-1"><h3>${t('common.loading')}</h3></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -114,12 +115,12 @@ export function render(container) {
|
|||
const name = document.getElementById('remoteNameInput').value.trim();
|
||||
const mimeType = document.getElementById('remoteMimeType').value;
|
||||
if (!url) {
|
||||
showToast('Enter a URL', 'error');
|
||||
showToast(t('content.error_enter_url'), 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.addRemoteContent(url, name, mimeType);
|
||||
showToast('Remote content added', 'success');
|
||||
showToast(t('content.toast.remote_added'), 'success');
|
||||
document.getElementById('remoteUrlInput').value = '';
|
||||
document.getElementById('remoteNameInput').value = '';
|
||||
loadContent();
|
||||
|
|
@ -133,12 +134,12 @@ export function render(container) {
|
|||
const url = document.getElementById('youtubeUrlInput').value.trim();
|
||||
const name = document.getElementById('youtubeNameInput').value.trim();
|
||||
if (!url) {
|
||||
showToast('Enter a YouTube URL', 'error');
|
||||
showToast(t('content.error_enter_youtube_url'), 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.addYoutubeContent(url, name);
|
||||
showToast('YouTube video added', 'success');
|
||||
showToast(t('content.toast.youtube_added'), 'success');
|
||||
document.getElementById('youtubeUrlInput').value = '';
|
||||
document.getElementById('youtubeNameInput').value = '';
|
||||
loadContent();
|
||||
|
|
@ -163,11 +164,11 @@ export function render(container) {
|
|||
|
||||
// Create folder in the current folder.
|
||||
document.getElementById('newFolderBtn').onclick = async () => {
|
||||
const name = prompt('Folder name:');
|
||||
const name = prompt(t('content.prompt_folder_name'));
|
||||
if (!name || !name.trim()) return;
|
||||
try {
|
||||
await api.createFolder(name.trim(), state.currentFolderId);
|
||||
showToast(`Folder "${name}" created`, 'success');
|
||||
showToast(t('content.toast.folder_created_named', { name }), 'success');
|
||||
loadContent();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
};
|
||||
|
|
@ -190,16 +191,16 @@ async function handleFiles(files) {
|
|||
for (const file of files) {
|
||||
progress.style.display = 'block';
|
||||
progressFill.style.width = '0%';
|
||||
progressText.textContent = `Uploading ${file.name}...`;
|
||||
progressText.textContent = t('content.upload_progress_named', { name: file.name });
|
||||
|
||||
try {
|
||||
await api.uploadContent(file, (pct) => {
|
||||
progressFill.style.width = pct + '%';
|
||||
progressText.textContent = `Uploading ${file.name}... ${pct}%`;
|
||||
progressText.textContent = t('content.upload_progress_named_pct', { name: file.name, pct });
|
||||
});
|
||||
showToast(`${file.name} uploaded successfully`, 'success');
|
||||
showToast(t('content.toast.uploaded_named', { name: file.name }), 'success');
|
||||
} catch (err) {
|
||||
showToast(`Failed to upload ${file.name}: ${err.message}`, 'error');
|
||||
showToast(t('content.toast.upload_failed_named', { name: file.name, error: err.message }), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,14 +230,14 @@ async function loadContent() {
|
|||
cursor = cursor.parent_id ? folderById.get(cursor.parent_id) : null;
|
||||
}
|
||||
breadcrumb.innerHTML = `
|
||||
<a href="#" data-folder-nav="" style="color:var(--text-secondary);text-decoration:none">All Content</a>
|
||||
<a href="#" data-folder-nav="" style="color:var(--text-secondary);text-decoration:none">${t('content.breadcrumb_root')}</a>
|
||||
${path.map(f => `
|
||||
<span style="color:var(--text-muted)">/</span>
|
||||
<a href="#" data-folder-nav="${f.id}" style="color:var(--text-primary);text-decoration:none">${esc(f.name)}</a>
|
||||
`).join('')}
|
||||
${state.currentFolderId ? `
|
||||
<button class="btn btn-secondary btn-sm" id="renameFolderBtn" style="margin-left:auto">Rename</button>
|
||||
<button class="btn btn-danger btn-sm" id="deleteFolderBtn">Delete folder</button>
|
||||
<button class="btn btn-secondary btn-sm" id="renameFolderBtn" style="margin-left:auto">${t('content.rename_btn')}</button>
|
||||
<button class="btn btn-danger btn-sm" id="deleteFolderBtn">${t('content.delete_folder_btn')}</button>
|
||||
` : ''}
|
||||
`;
|
||||
breadcrumb.querySelectorAll('[data-folder-nav]').forEach(a => {
|
||||
|
|
@ -271,7 +272,7 @@ async function loadContent() {
|
|||
const targetFolderId = a.dataset.folderNav || null; // empty string = root
|
||||
try {
|
||||
await api.moveContent(contentId, targetFolderId);
|
||||
showToast(targetFolderId ? 'Moved' : 'Moved to root', 'success');
|
||||
showToast(targetFolderId ? t('content.toast.moved') : t('content.toast.moved_to_root'), 'success');
|
||||
loadContent();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
});
|
||||
|
|
@ -279,21 +280,21 @@ async function loadContent() {
|
|||
const renameBtn = breadcrumb.querySelector('#renameFolderBtn');
|
||||
if (renameBtn) renameBtn.onclick = async () => {
|
||||
const current = folderById.get(state.currentFolderId);
|
||||
const name = prompt('Rename folder:', current?.name || '');
|
||||
const name = prompt(t('content.prompt_rename_folder'), current?.name || '');
|
||||
if (!name || !name.trim() || name === current?.name) return;
|
||||
try {
|
||||
await api.renameFolder(state.currentFolderId, name.trim());
|
||||
showToast('Folder renamed', 'success');
|
||||
showToast(t('content.toast.folder_renamed'), 'success');
|
||||
loadContent();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
};
|
||||
const deleteBtn = breadcrumb.querySelector('#deleteFolderBtn');
|
||||
if (deleteBtn) deleteBtn.onclick = async () => {
|
||||
if (!confirm('Delete this folder? Content inside moves back to the root level. Subfolders will also be deleted.')) return;
|
||||
if (!confirm(t('content.confirm_delete_folder'))) return;
|
||||
try {
|
||||
const parentId = folderById.get(state.currentFolderId)?.parent_id || null;
|
||||
await api.deleteFolder(state.currentFolderId);
|
||||
showToast('Folder deleted', 'success');
|
||||
showToast(t('content.toast.folder_deleted'), 'success');
|
||||
state.currentFolderId = parentId;
|
||||
loadContent();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
|
|
@ -326,7 +327,7 @@ async function loadContent() {
|
|||
if (!contentId) return;
|
||||
try {
|
||||
await api.moveContent(contentId, card.dataset.folderId);
|
||||
showToast('Moved', 'success');
|
||||
showToast(t('content.toast.moved'), 'success');
|
||||
loadContent();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
});
|
||||
|
|
@ -339,8 +340,8 @@ async function loadContent() {
|
|||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
|
||||
<polyline points="13 2 13 9 20 9"/>
|
||||
</svg>
|
||||
<h3>${state.currentFolderId ? 'This folder is empty' : 'No content yet'}</h3>
|
||||
<p>${state.currentFolderId ? 'Drag content here, or use the Move action.' : 'Upload videos and images to get started.'}</p>
|
||||
<h3>${state.currentFolderId ? t('content.empty_folder_title') : t('content.no_content')}</h3>
|
||||
<p>${state.currentFolderId ? t('content.empty_folder_desc') : t('content.no_content_desc')}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
|
|
@ -365,7 +366,7 @@ async function loadContent() {
|
|||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
<span style="font-size:10px;color:var(--text-muted)">Remote</span>
|
||||
<span style="font-size:10px;color:var(--text-muted)">${t('content.type_remote_short')}</span>
|
||||
</div>`
|
||||
: c.thumbnail_path
|
||||
? `<img src="/api/content/${c.id}/thumbnail" alt="${esc(c.filename)}" loading="lazy">`
|
||||
|
|
@ -381,26 +382,26 @@ async function loadContent() {
|
|||
<div class="content-item-body">
|
||||
<div class="content-item-name" title="${esc(c.filename)}">${esc(c.filename)}</div>
|
||||
<div class="content-item-size">
|
||||
${c.mime_type === 'video/youtube' ? 'YouTube' : c.remote_url ? 'Remote URL' : (c.mime_type?.startsWith('video/') ? 'Video' : 'Image')}
|
||||
${c.mime_type === 'video/youtube' ? t('content.type_youtube') : c.remote_url ? t('content.type_remote') : (c.mime_type?.startsWith('video/') ? t('content.type_video') : t('content.type_image'))}
|
||||
${c.duration_sec ? ` · ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''}
|
||||
${c.file_size ? ' · ' + formatFileSize(c.file_size) : ''}
|
||||
${c.width && c.height ? ` · ${c.width}x${c.height}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-item-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-edit-content="${c.id}" title="Edit">
|
||||
<button class="btn btn-secondary btn-sm" data-edit-content="${c.id}" title="${t('content.btn_edit')}">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
Edit
|
||||
${t('content.btn_edit')}
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" data-delete-content="${c.id}" title="Delete">
|
||||
<button class="btn btn-danger btn-sm" data-delete-content="${c.id}" title="${t('content.btn_delete')}">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
Delete
|
||||
${t('content.btn_delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -446,14 +447,14 @@ async function loadContent() {
|
|||
if (btn.dataset.confirming === 'true') {
|
||||
try {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Deleting...';
|
||||
btn.textContent = t('content.btn_deleting');
|
||||
await api.deleteContent(id);
|
||||
showToast('Content deleted', 'success');
|
||||
showToast(t('content.toast.deleted'), 'success');
|
||||
loadContent();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Delete';
|
||||
btn.textContent = t('content.btn_delete');
|
||||
btn.dataset.confirming = 'false';
|
||||
}
|
||||
return;
|
||||
|
|
@ -461,14 +462,14 @@ async function loadContent() {
|
|||
|
||||
// First click - show confirm state
|
||||
btn.dataset.confirming = 'true';
|
||||
btn.innerHTML = 'Confirm Delete?';
|
||||
btn.innerHTML = t('content.btn_confirm_delete');
|
||||
btn.style.background = 'var(--danger)';
|
||||
btn.style.color = 'white';
|
||||
// Reset after 3 seconds if not clicked
|
||||
setTimeout(() => {
|
||||
if (btn.dataset.confirming === 'true') {
|
||||
btn.dataset.confirming = 'false';
|
||||
btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> Delete`;
|
||||
btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> ${t('content.btn_delete')}`;
|
||||
btn.style.background = '';
|
||||
btn.style.color = '';
|
||||
}
|
||||
|
|
@ -476,7 +477,7 @@ async function loadContent() {
|
|||
};
|
||||
|
||||
} catch (err) {
|
||||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Failed to load content</h3><p>${esc(err.message)}</p></div>`;
|
||||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('content.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -490,51 +491,51 @@ function showEditModal(contentItem, onSave) {
|
|||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width:500px;width:95vw">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Content</h3>
|
||||
<h3>${t('content.edit_modal_title')}</h3>
|
||||
<button class="btn-icon" id="closeEditModal">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Filename / Display Name</label>
|
||||
<label>${t('content.label_filename')}</label>
|
||||
<input type="text" id="editFilename" class="input" value="${esc(contentItem.filename)}">
|
||||
</div>
|
||||
${isRemote ? `
|
||||
<div class="form-group">
|
||||
<label>Remote URL</label>
|
||||
<label>${t('content.label_remote_url_field')}</label>
|
||||
<input type="text" id="editRemoteUrl" class="input" value="${esc(contentItem.remote_url)}">
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="form-group">
|
||||
<label>MIME Type</label>
|
||||
<label>${t('content.label_mime_type')}</label>
|
||||
<select id="editMimeType" class="input" style="background:var(--bg-input)">
|
||||
<option value="video/mp4" ${contentItem.mime_type === 'video/mp4' ? 'selected' : ''}>Video (MP4)</option>
|
||||
<option value="video/webm" ${contentItem.mime_type === 'video/webm' ? 'selected' : ''}>Video (WebM)</option>
|
||||
<option value="image/jpeg" ${contentItem.mime_type === 'image/jpeg' ? 'selected' : ''}>Image (JPEG)</option>
|
||||
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>Image (PNG)</option>
|
||||
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>Image (GIF)</option>
|
||||
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>Image (WebP)</option>
|
||||
<option value="video/mp4" ${contentItem.mime_type === 'video/mp4' ? 'selected' : ''}>${t('content.mime.video_mp4')}</option>
|
||||
<option value="video/webm" ${contentItem.mime_type === 'video/webm' ? 'selected' : ''}>${t('content.mime.video_webm')}</option>
|
||||
<option value="image/jpeg" ${contentItem.mime_type === 'image/jpeg' ? 'selected' : ''}>${t('content.mime.image_jpeg')}</option>
|
||||
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>${t('content.mime.image_png')}</option>
|
||||
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>${t('content.mime.image_gif')}</option>
|
||||
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>${t('content.mime.image_webp')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Folder</label>
|
||||
<label>${t('content.label_folder')}</label>
|
||||
<select id="editFolderId" class="input" style="background:var(--bg-input)">
|
||||
<option value="">— Root —</option>
|
||||
<option value="">${t('content.folder_root_option')}</option>
|
||||
${state.folders.map(f => `<option value="${f.id}" ${contentItem.folder_id === f.id ? 'selected' : ''}>${esc(folderPath(f, state.folders))}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
${!isRemote ? `
|
||||
<div class="form-group">
|
||||
<label>Replace File</label>
|
||||
<label>${t('content.label_replace_file')}</label>
|
||||
<input type="file" id="editFileReplace" accept="video/*,image/*" style="font-size:13px;color:var(--text-secondary)">
|
||||
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">Leave empty to keep current file</p>
|
||||
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('content.replace_file_hint')}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="cancelEditBtn">Cancel</button>
|
||||
<button class="btn btn-primary" id="saveEditBtn">Save Changes</button>
|
||||
<button class="btn btn-secondary" id="cancelEditBtn">${t('common.cancel')}</button>
|
||||
<button class="btn btn-primary" id="saveEditBtn">${t('content.save_changes')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -582,10 +583,10 @@ function showEditModal(contentItem, onSave) {
|
|||
}
|
||||
|
||||
overlay.remove();
|
||||
showToast('Content updated', 'success');
|
||||
showToast(t('content.toast.updated'), 'success');
|
||||
if (onSave) onSave();
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Update failed', 'error');
|
||||
showToast(err.message || t('content.error_update_failed'), 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -611,7 +612,7 @@ function showPreview(content) {
|
|||
</div>
|
||||
<div style="padding:12px 16px;border-top:1px solid var(--border)">
|
||||
<div style="font-weight:500">${esc(content.filename)}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted)">${esc(content.mime_type)} ${content.remote_url ? '(Remote URL)' : ''}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted)">${esc(content.mime_type)} ${content.remote_url ? `(${t('content.type_remote')})` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -2,28 +2,38 @@ import { api } from '../api.js';
|
|||
import { on, off, requestScreenshot } from '../socket.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc } from '../utils.js';
|
||||
import { t, tn } from '../i18n.js';
|
||||
|
||||
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
||||
// Command types only — labels resolved through t('dashboard.cmd.<type>')
|
||||
const GROUP_COMMANDS = [
|
||||
{ type: 'screen_on', label: 'Screen On' },
|
||||
{ type: 'screen_off', label: 'Screen Off' },
|
||||
{ type: 'launch', label: 'Restart App' },
|
||||
{ type: 'update', label: 'Check Update' },
|
||||
{ type: 'reboot', label: 'Reboot', destructive: true },
|
||||
{ type: 'shutdown', label: 'Shutdown', destructive: true },
|
||||
{ type: 'screen_on' },
|
||||
{ type: 'screen_off' },
|
||||
{ type: 'launch' },
|
||||
{ type: 'update' },
|
||||
{ type: 'reboot', destructive: true },
|
||||
{ type: 'shutdown', destructive: true },
|
||||
];
|
||||
const CMD_LABEL_KEY = {
|
||||
screen_on: 'dashboard.cmd.screen_on',
|
||||
screen_off: 'dashboard.cmd.screen_off',
|
||||
launch: 'dashboard.cmd.restart_app',
|
||||
update: 'dashboard.cmd.check_update',
|
||||
reboot: 'dashboard.cmd.reboot',
|
||||
shutdown: 'dashboard.cmd.shutdown',
|
||||
};
|
||||
|
||||
let statusHandler = null;
|
||||
let screenshotHandler = null;
|
||||
let refreshInterval = null;
|
||||
|
||||
function formatTimeAgo(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
if (!timestamp) return t('common.never');
|
||||
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||
if (seconds < 60) return 'Just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
if (seconds < 60) return t('common.just_now');
|
||||
if (seconds < 3600) return t('common.minutes_ago', { n: Math.floor(seconds / 60) });
|
||||
if (seconds < 86400) return t('common.hours_ago', { n: Math.floor(seconds / 3600) });
|
||||
return t('common.days_ago', { n: Math.floor(seconds / 86400) });
|
||||
}
|
||||
|
||||
function formatBytes(mb) {
|
||||
|
|
@ -49,12 +59,12 @@ function renderDeviceCard(device) {
|
|||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<span>No preview available</span>
|
||||
<span>${t('dashboard.no_preview')}</span>
|
||||
</div>`
|
||||
}
|
||||
<div class="device-card-status">
|
||||
<span class="status-dot ${device.status}"></span>
|
||||
<span>${device.status === 'provisioning' ? 'Awaiting Pairing' : device.status}</span>
|
||||
<span>${device.status === 'provisioning' ? t('dashboard.awaiting_pairing') : device.status}</span>
|
||||
</div>
|
||||
${device.status === 'provisioning' && device.pairing_code ? `
|
||||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#f59e0b;padding:4px 12px;border-radius:6px;font-size:13px;font-weight:600;letter-spacing:2px;font-family:monospace">
|
||||
|
|
@ -112,9 +122,9 @@ function getGroupPlaylistLabel(devices, playlists) {
|
|||
const unique = [...new Set(assigned)];
|
||||
if (unique.length === 1) {
|
||||
const pl = playlistMap.get(unique[0]);
|
||||
return pl ? esc(pl.name) : 'Unknown playlist';
|
||||
return pl ? esc(pl.name) : t('dashboard.unknown_playlist');
|
||||
}
|
||||
return 'Mixed playlists';
|
||||
return t('dashboard.mixed_playlists');
|
||||
}
|
||||
|
||||
function renderGroupSection(group, devices, playlists) {
|
||||
|
|
@ -125,26 +135,26 @@ function renderGroupSection(group, devices, playlists) {
|
|||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#3B82F6')}">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<strong style="font-size:15px">${esc(group.name)}</strong>
|
||||
<span style="color:var(--text-muted);font-size:12px">${devices.length} device${devices.length !== 1 ? 's' : ''} · ${onlineCount} online</span>
|
||||
${playlistLabel ? `<span style="font-size:11px;color:var(--text-secondary);background:var(--bg-primary);padding:2px 8px;border-radius:10px">Playlist: ${playlistLabel}</span>` : ''}
|
||||
<span style="color:var(--text-muted);font-size:12px">${tn('dashboard.devices_count', devices.length)} · ${t('dashboard.online_count', { n: onlineCount })}</span>
|
||||
${playlistLabel ? `<span style="font-size:11px;color:var(--text-secondary);background:var(--bg-primary);padding:2px 8px;border-radius:10px">${t('dashboard.playlist_label', { name: playlistLabel })}</span>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
${devices.length > 0 ? `
|
||||
<select class="input group-playlist-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" style="width:160px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
|
||||
<option value="">Set Playlist...</option>
|
||||
${(playlists || []).map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' (draft)' : ''}</option>`).join('')}
|
||||
<option value="">${t('dashboard.set_playlist_placeholder')}</option>
|
||||
${(playlists || []).map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' ' + t('dashboard.draft_suffix') : ''}</option>`).join('')}
|
||||
</select>
|
||||
<select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
|
||||
<option value="">Send Command...</option>
|
||||
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${c.label}</option>`).join('')}
|
||||
<option value="">${t('dashboard.send_command_placeholder')}</option>
|
||||
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${t(CMD_LABEL_KEY[c.type])}</option>`).join('')}
|
||||
</select>
|
||||
` : ''}
|
||||
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="Add/remove devices">Manage</button>
|
||||
<button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="Delete group">✕</button>
|
||||
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="${t('dashboard.manage_tooltip')}">${t('dashboard.manage')}</button>
|
||||
<button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="${t('dashboard.delete_group_tooltip')}">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-grid">
|
||||
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : '<div style="color:var(--text-muted);font-size:13px;padding:8px 12px">No devices in this group. Click Manage to add some.</div>'}
|
||||
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : `<div style="color:var(--text-muted);font-size:13px;padding:8px 12px">${t('dashboard.no_devices_in_group')}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -154,26 +164,26 @@ export function render(container) {
|
|||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Displays <span class="help-tip" data-tip="Your paired display devices. Green = online, red = offline. Click a device to manage its playlist, view telemetry, or use remote control.">?</span></h1>
|
||||
<div class="subtitle">Manage your remote displays</div>
|
||||
<h1>${t('dashboard.title')} <span class="help-tip" data-tip="${t('dashboard.help_tip')}">?</span></h1>
|
||||
<div class="subtitle">${t('dashboard.subtitle')}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn" id="createGroupBtn">+ Group</button>
|
||||
<button class="btn" id="createGroupBtn">${t('dashboard.create_group')}</button>
|
||||
<button class="btn btn-primary" id="addDeviceBtn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Add Display
|
||||
${t('dashboard.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashStats" class="dash-stats-row" style="display:flex;gap:12px;margin-bottom:16px"></div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
|
||||
<input type="text" id="deviceSearch" class="input" placeholder="Search displays..." style="max-width:300px">
|
||||
<input type="text" id="deviceSearch" class="input" placeholder="${t('dashboard.search')}" style="max-width:300px">
|
||||
<select id="deviceFilter" class="input" style="width:140px;background:var(--bg-input)">
|
||||
<option value="">All Status</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="">${t('dashboard.all_status')}</option>
|
||||
<option value="online">${t('dashboard.online')}</option>
|
||||
<option value="offline">${t('dashboard.offline')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="groupedDevices"></div>
|
||||
|
|
@ -209,13 +219,13 @@ export function render(container) {
|
|||
const code = document.getElementById('pairingCodeInput').value.trim();
|
||||
const name = document.getElementById('deviceNameInput').value.trim();
|
||||
if (!code || code.length !== 6) {
|
||||
showToast('Enter a valid 6-digit pairing code', 'error');
|
||||
showToast(t('dashboard.error_pairing_code'), 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.pairDevice(code, name || undefined);
|
||||
document.getElementById('addDeviceModal').style.display = 'none';
|
||||
showToast('Display paired successfully!', 'success');
|
||||
showToast(t('dashboard.toast.display_paired'), 'success');
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
|
|
@ -224,11 +234,11 @@ export function render(container) {
|
|||
|
||||
// Create group
|
||||
container.querySelector('#createGroupBtn').addEventListener('click', async () => {
|
||||
const name = prompt('Group name:');
|
||||
const name = prompt(t('dashboard.prompt_group_name'));
|
||||
if (!name) return;
|
||||
try {
|
||||
await api.createGroup(name);
|
||||
showToast('Group created', 'success');
|
||||
showToast(t('dashboard.toast.group_created'), 'success');
|
||||
loadDashboard();
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
});
|
||||
|
|
@ -301,20 +311,20 @@ async function loadDashboard() {
|
|||
if (statsEl) {
|
||||
statsEl.innerHTML = `
|
||||
<div class="info-card" style="flex:1;min-width:120px">
|
||||
<div class="info-card-label">Total Displays</div>
|
||||
<div class="info-card-label">${t('dashboard.total_displays')}</div>
|
||||
<div class="info-card-value">${devices.length}</div>
|
||||
</div>
|
||||
<div class="info-card" style="flex:1;min-width:120px">
|
||||
<div class="info-card-label">Online</div>
|
||||
<div class="info-card-label">${t('dashboard.online')}</div>
|
||||
<div class="info-card-value" style="color:var(--success)">${online}</div>
|
||||
</div>
|
||||
<div class="info-card" style="flex:1;min-width:120px">
|
||||
<div class="info-card-label">Offline</div>
|
||||
<div class="info-card-label">${t('dashboard.offline')}</div>
|
||||
<div class="info-card-value" style="color:${offline > 0 ? 'var(--danger)' : 'var(--text-muted)'}">${offline}</div>
|
||||
</div>
|
||||
${provisioning > 0 ? `
|
||||
<div class="info-card" style="flex:1;min-width:120px">
|
||||
<div class="info-card-label">Awaiting Pairing</div>
|
||||
<div class="info-card-label">${t('dashboard.awaiting_pairing')}</div>
|
||||
<div class="info-card-value" style="color:var(--warning,#f59e0b)">${provisioning}</div>
|
||||
</div>` : ''}
|
||||
`;
|
||||
|
|
@ -328,8 +338,8 @@ async function loadDashboard() {
|
|||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<h3>No displays yet</h3>
|
||||
<p>Install the ScreenTinker app on your Apolosign TV and pair it using the button above.</p>
|
||||
<h3>${t('dashboard.no_displays')}</h3>
|
||||
<p>${t('dashboard.no_displays_desc')}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
|
|
@ -371,8 +381,8 @@ async function loadDashboard() {
|
|||
<div class="ungrouped-section" data-ungrouped="1" style="margin-bottom:24px">
|
||||
${groups.length > 0 ? `
|
||||
<div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid var(--text-muted)">
|
||||
<strong style="font-size:15px;color:var(--text-muted)">Ungrouped</strong>
|
||||
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${ungrouped.length} device${ungrouped.length !== 1 ? 's' : ''}</span>
|
||||
<strong style="font-size:15px;color:var(--text-muted)">${t('dashboard.ungrouped')}</strong>
|
||||
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${tn('dashboard.devices_count', ungrouped.length)}</span>
|
||||
</div>` : ''}
|
||||
<div class="device-grid">
|
||||
${ungrouped.map(renderDeviceCard).join('')}
|
||||
|
|
@ -385,7 +395,7 @@ async function loadDashboard() {
|
|||
attachGroupHandlers(groupsWithDevices, devices);
|
||||
|
||||
} catch (err) {
|
||||
main.innerHTML = `<div class="empty-state"><h3>Failed to load displays</h3><p>${esc(err.message)}</p></div>`;
|
||||
main.innerHTML = `<div class="empty-state"><h3>${t('dashboard.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -435,17 +445,17 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
if (!targetGroup) return;
|
||||
// Already in this group — no-op.
|
||||
if (targetGroup.memberIds.has(deviceId)) {
|
||||
showToast(`${deviceName} is already in ${targetGroup.name}`, 'info');
|
||||
showToast(t('dashboard.toast.already_in_group', { name: deviceName, group: targetGroup.name }), 'info');
|
||||
return;
|
||||
}
|
||||
// If the device is in another group, mirror the Manage modal's confirm.
|
||||
const others = (groupsByDeviceId.get(deviceId) || []).map(g => g.name);
|
||||
if (others.length > 0) {
|
||||
if (!confirm(`${deviceName} is already in: ${others.join(', ')}\n\nAdd it to "${targetGroup.name}" too?`)) return;
|
||||
if (!confirm(t('dashboard.confirm_add_to_group', { name: deviceName, groups: others.join(', '), target: targetGroup.name }))) return;
|
||||
}
|
||||
try {
|
||||
await api.addDeviceToGroup(groupId, deviceId);
|
||||
showToast(`Moved ${deviceName} to ${targetGroup.name}`, 'success');
|
||||
showToast(t('dashboard.toast.moved_device', { name: deviceName, group: targetGroup.name }), 'success');
|
||||
loadDashboard();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
});
|
||||
|
|
@ -472,7 +482,7 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
if (memberships.length === 0) return; // already ungrouped
|
||||
try {
|
||||
await Promise.all(memberships.map(m => api.removeDeviceFromGroup(m.id, deviceId)));
|
||||
showToast(`Removed ${deviceName} from ${memberships.length} group${memberships.length !== 1 ? 's' : ''}`, 'success');
|
||||
showToast(tn('dashboard.toast.removed_device', memberships.length, { name: deviceName }), 'success');
|
||||
loadDashboard();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
});
|
||||
|
|
@ -487,14 +497,14 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
const groupName = e.target.dataset.groupName;
|
||||
const playlistName = e.target.options[e.target.selectedIndex].textContent;
|
||||
|
||||
if (!confirm(`Assign playlist "${playlistName}" to all devices in "${groupName}"?`)) {
|
||||
if (!confirm(t('dashboard.confirm_assign_playlist', { playlist: playlistName, group: groupName }))) {
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.groupAssignPlaylist(groupId, playlistId);
|
||||
showToast(`Playlist assigned to ${result.devices_updated} device${result.devices_updated !== 1 ? 's' : ''}`, 'success');
|
||||
showToast(tn('dashboard.toast.playlist_assigned', result.devices_updated), 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
|
|
@ -510,9 +520,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
const groupId = e.target.dataset.groupId;
|
||||
const groupName = e.target.dataset.groupName;
|
||||
const count = e.target.dataset.deviceCount;
|
||||
const cmdLabel = t(CMD_LABEL_KEY[type] || type);
|
||||
|
||||
if (DESTRUCTIVE_COMMANDS.includes(type)) {
|
||||
if (!confirm(`${type.toUpperCase()} all ${count} device${count !== '1' ? 's' : ''} in "${groupName}"?\n\nThis cannot be undone.`)) {
|
||||
if (!confirm(t('dashboard.confirm_destructive_command', { cmd: cmdLabel.toUpperCase(), n: count, group: groupName }))) {
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
|
@ -520,7 +531,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
|
||||
try {
|
||||
const result = await api.sendGroupCommand(groupId, type);
|
||||
showToast(`${type} sent to ${result.sent}/${result.total} devices${result.offline > 0 ? ` (${result.offline} offline)` : ''}`, result.offline > 0 ? 'warning' : 'success');
|
||||
const msg = result.offline > 0
|
||||
? t('dashboard.toast.command_sent_with_offline', { cmd: cmdLabel, sent: result.sent, total: result.total, offline: result.offline })
|
||||
: t('dashboard.toast.command_sent', { cmd: cmdLabel, sent: result.sent, total: result.total });
|
||||
showToast(msg, result.offline > 0 ? 'warning' : 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
|
|
@ -533,10 +547,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const id = btn.dataset.groupDelete;
|
||||
if (!confirm('Delete this group? Devices will not be affected.')) return;
|
||||
if (!confirm(t('dashboard.confirm_delete_group'))) return;
|
||||
try {
|
||||
await api.deleteGroup(id);
|
||||
showToast('Group deleted', 'success');
|
||||
showToast(t('dashboard.toast.group_deleted'), 'success');
|
||||
loadDashboard();
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
});
|
||||
|
|
@ -558,7 +572,7 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
modal.innerHTML = `
|
||||
<div style="background:var(--bg-card);border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:70vh;overflow-y:auto">
|
||||
<h3 style="margin:0 0 4px">${esc(group.name)}</h3>
|
||||
<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">Check devices to add them to this group</p>
|
||||
<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">${t('dashboard.manage_group_subtitle')}</p>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
${allDevices.filter(d => d.status !== 'provisioning').map(d => {
|
||||
const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name);
|
||||
|
|
@ -573,7 +587,7 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
}).join('')}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
|
||||
<button class="btn" id="manageGroupClose">Done</button>
|
||||
<button class="btn" id="manageGroupClose">${t('common.done')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -586,9 +600,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
|||
cb.addEventListener('change', async () => {
|
||||
const deviceId = cb.dataset.deviceId;
|
||||
const existingGroups = cb.dataset.inGroups;
|
||||
const cbName = cb.closest('label')?.querySelector('span:not(.status-dot)')?.textContent || '';
|
||||
try {
|
||||
if (cb.checked && existingGroups) {
|
||||
if (!confirm(`This device is already in: ${existingGroups}\n\nAdd it to "${group.name}" too?`)) {
|
||||
if (!confirm(t('dashboard.confirm_add_to_group', { name: cbName, groups: existingGroups, target: group.name }))) {
|
||||
cb.checked = false;
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { showToast } from '../components/toast.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
let authConfig = null;
|
||||
|
||||
|
|
@ -26,34 +27,34 @@ export async function render(container) {
|
|||
</svg>
|
||||
<h1 style="font-size:24px;font-weight:700;color:var(--accent)">ScreenTinker</h1>
|
||||
<p style="color:var(--text-secondary);font-size:13px;margin-top:4px">
|
||||
${isSetup ? 'Create your admin account to get started' : 'Sign in to manage your displays'}
|
||||
${isSetup ? t('auth.subtitle_setup') : t('auth.subtitle_signin')}
|
||||
</p>
|
||||
${!isSetup && canRegister ? '<p style="color:var(--warning);font-size:12px;margin-top:8px">New accounts get a 14-day free Pro trial</p>' : ''}
|
||||
${!isSetup && canRegister ? `<p style="color:var(--warning);font-size:12px;margin-top:8px">${t('auth.trial_notice')}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px">
|
||||
<!-- Local Auth Form -->
|
||||
<div id="localAuthForm">
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="loginEmail" class="input" placeholder="you@example.com" autocomplete="email">
|
||||
<label>${t('auth.email')}</label>
|
||||
<input type="email" id="loginEmail" class="input" placeholder="${t('auth.placeholder_email')}" autocomplete="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" id="loginPassword" class="input" placeholder="••••••••" autocomplete="current-password">
|
||||
<label>${t('auth.password')}</label>
|
||||
<input type="password" id="loginPassword" class="input" placeholder="${t('auth.placeholder_password')}" autocomplete="current-password">
|
||||
</div>
|
||||
${isSetup ? `
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" id="loginName" class="input" placeholder="Your name">
|
||||
<label>${t('auth.name')}</label>
|
||||
<input type="text" id="loginName" class="input" placeholder="${t('auth.placeholder_name')}">
|
||||
</div>
|
||||
` : ''}
|
||||
<button class="btn btn-primary" id="loginBtn" style="width:100%;justify-content:center;padding:10px">
|
||||
${isSetup ? 'Create Admin Account' : 'Sign In'}
|
||||
${isSetup ? t('auth.create_admin_account') : t('auth.sign_in')}
|
||||
</button>
|
||||
${!isSetup && canRegister ? `
|
||||
<button class="btn btn-secondary" id="showRegisterBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
|
||||
Create Account
|
||||
${t('auth.create_account')}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
|
@ -61,29 +62,29 @@ export async function render(container) {
|
|||
<!-- Register form (hidden by default) -->
|
||||
<div id="registerForm" style="display:none">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" id="regName" class="input" placeholder="Your name">
|
||||
<label>${t('auth.name')}</label>
|
||||
<input type="text" id="regName" class="input" placeholder="${t('auth.placeholder_name')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="regEmail" class="input" placeholder="you@example.com">
|
||||
<label>${t('auth.email')}</label>
|
||||
<input type="email" id="regEmail" class="input" placeholder="${t('auth.placeholder_email')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" id="regPassword" class="input" placeholder="At least 6 characters">
|
||||
<label>${t('auth.password')}</label>
|
||||
<input type="password" id="regPassword" class="input" placeholder="${t('auth.placeholder_register_password')}">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="registerBtn" style="width:100%;justify-content:center;padding:10px">
|
||||
Create Account
|
||||
${t('auth.create_account')}
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="showLoginBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
|
||||
Back to Sign In
|
||||
${t('auth.back_to_signin')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${config.googleEnabled || config.microsoftEnabled ? `
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:20px 0">
|
||||
<hr style="flex:1;border-color:var(--border)">
|
||||
<span style="color:var(--text-muted);font-size:12px">OR</span>
|
||||
<span style="color:var(--text-muted);font-size:12px">${t('auth.divider_or')}</span>
|
||||
<hr style="flex:1;border-color:var(--border)">
|
||||
</div>
|
||||
` : ''}
|
||||
|
|
@ -97,7 +98,7 @@ export async function render(container) {
|
|||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
${t('auth.signin_google')}
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
|
@ -110,25 +111,25 @@ export async function render(container) {
|
|||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||
</svg>
|
||||
Sign in with Microsoft
|
||||
${t('auth.signin_microsoft')}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Support Access (collapsible) -->
|
||||
<details style="margin-top:16px">
|
||||
<summary style="font-size:11px;color:var(--text-muted);cursor:pointer;text-align:center">Support Access</summary>
|
||||
<summary style="font-size:11px;color:var(--text-muted);cursor:pointer;text-align:center">${t('auth.support_access')}</summary>
|
||||
<div style="margin-top:8px">
|
||||
<input type="text" id="supportToken" class="input" placeholder="Paste support token" style="font-family:monospace">
|
||||
<button class="btn btn-secondary" id="supportLoginBtn" style="width:100%;justify-content:center;padding:8px;margin-top:6px;font-size:12px">Authenticate with Support Token</button>
|
||||
<input type="text" id="supportToken" class="input" placeholder="${t('auth.support_token_placeholder')}" style="font-family:monospace">
|
||||
<button class="btn btn-secondary" id="supportLoginBtn" style="width:100%;justify-content:center;padding:8px;margin-top:6px;font-size:12px">${t('auth.support_authenticate')}</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p id="loginError" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></p>
|
||||
<p style="text-align:center;margin-top:16px;font-size:11px;color:var(--text-muted)">
|
||||
<a href="/legal/terms.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">Terms of Service</a>
|
||||
<a href="/legal/terms.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">${t('auth.terms')}</a>
|
||||
·
|
||||
<a href="/legal/privacy.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">Privacy Policy</a>
|
||||
<a href="/legal/privacy.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">${t('auth.privacy')}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -147,7 +148,7 @@ function setupHandlers(config, isSetup) {
|
|||
// Support token login
|
||||
document.getElementById('supportLoginBtn')?.addEventListener('click', async () => {
|
||||
const token = document.getElementById('supportToken')?.value.trim();
|
||||
if (!token) { showError('Paste a support token'); return; }
|
||||
if (!token) { showError(t('auth.error_paste_support_token')); return; }
|
||||
try {
|
||||
const res = await fetch('/api/auth/support', {
|
||||
method: 'POST',
|
||||
|
|
@ -157,7 +158,7 @@ function setupHandlers(config, isSetup) {
|
|||
const data = await res.json();
|
||||
if (!res.ok) { showError(data.error); return; }
|
||||
onAuthSuccess(data);
|
||||
} catch (err) { showError('Support login failed'); }
|
||||
} catch (err) { showError(t('auth.error_support_failed')); }
|
||||
});
|
||||
|
||||
// Local login/register
|
||||
|
|
@ -184,7 +185,7 @@ function setupHandlers(config, isSetup) {
|
|||
async function doLogin() {
|
||||
const email = document.getElementById('loginEmail').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
if (!email || !password) { showError('Email and password required'); return; }
|
||||
if (!email || !password) { showError(t('auth.error_email_password_required')); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
|
|
@ -196,7 +197,7 @@ function setupHandlers(config, isSetup) {
|
|||
if (!res.ok) { showError(data.error); return; }
|
||||
onAuthSuccess(data);
|
||||
} catch (err) {
|
||||
showError('Login failed');
|
||||
showError(t('auth.error_login_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -204,8 +205,8 @@ function setupHandlers(config, isSetup) {
|
|||
const email = document.getElementById(isFirstUser ? 'loginEmail' : 'regEmail').value.trim();
|
||||
const password = document.getElementById(isFirstUser ? 'loginPassword' : 'regPassword').value;
|
||||
const name = document.getElementById(isFirstUser ? 'loginName' : 'regName')?.value.trim() || '';
|
||||
if (!email || !password) { showError('Email and password required'); return; }
|
||||
if (password.length < 6) { showError('Password must be at least 6 characters'); return; }
|
||||
if (!email || !password) { showError(t('auth.error_email_password_required')); return; }
|
||||
if (password.length < 6) { showError(t('auth.error_password_min_6')); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
|
|
@ -217,7 +218,7 @@ function setupHandlers(config, isSetup) {
|
|||
if (!res.ok) { showError(data.error); return; }
|
||||
onAuthSuccess(data);
|
||||
} catch (err) {
|
||||
showError('Registration failed');
|
||||
showError(t('auth.error_registration_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -248,7 +249,7 @@ function setupHandlers(config, isSetup) {
|
|||
});
|
||||
client.requestAccessToken();
|
||||
} catch (err) {
|
||||
showError('Google sign-in failed');
|
||||
showError(t('auth.error_google_failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -278,7 +279,7 @@ function setupHandlers(config, isSetup) {
|
|||
else showError(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
showError('Microsoft sign-in failed');
|
||||
showError(t('auth.error_microsoft_failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -281,8 +281,9 @@ export async function render(container) {
|
|||
});
|
||||
|
||||
document.getElementById('langSelect')?.addEventListener('change', (e) => {
|
||||
// setLanguage dispatches hashchange so the router re-renders the current
|
||||
// view (including this settings page) with new strings — no refresh needed.
|
||||
setLanguage(e.target.value);
|
||||
showToast('Language changed. Refresh for full effect.', 'info');
|
||||
});
|
||||
|
||||
document.getElementById('saveAcctBtn')?.addEventListener('click', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue