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:
ScreenTinker 2026-04-29 19:25:22 -05:00
parent a2c8ab4336
commit 8e7a093150
12 changed files with 1290 additions and 305 deletions

View file

@ -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();

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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',
};

View file

@ -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 ? ` &middot; ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''}
${c.file_size ? ' &middot; ' + formatFileSize(c.file_size) : ''}
${c.width && c.height ? ` &middot; ${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>
`;

View file

@ -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' : ''} &middot; ${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)} &middot; ${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">&#x2715;</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')}">&#x2715;</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;
}

View file

@ -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>
&nbsp;&middot;&nbsp;
<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'));
}
});
}

View file

@ -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 () => {