i18n batch 1/6: wire device-detail + settings (~242 keys)

- device-detail.js: tabs, draft banner, layout selector, info cards,
  uptime timeline, controls, remote tab, playlist items, copy/assign
  modals, all toasts and confirms
- settings.js: account, change password, license, user management,
  white-label, server info, setup guide, your data export/import,
  language selector, about
- es/fr/de/pt all at 425/425 key parity; hi skeleton untouched
- Native review still recommended before publicizing as fully supported

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-29 19:47:17 -05:00
parent 8e7a093150
commit eccf4b7af1
7 changed files with 1496 additions and 246 deletions

View file

@ -194,4 +194,250 @@ export default {
'content.toast.folder_deleted': 'Ordner gelöscht', 'content.toast.folder_deleted': 'Ordner gelöscht',
'content.toast.moved': 'Verschoben', 'content.toast.moved': 'Verschoben',
'content.toast.moved_to_root': 'In den Hauptordner verschoben', 'content.toast.moved_to_root': 'In den Hauptordner verschoben',
// Device detail
'device.back': 'Zurück zu Bildschirmen',
'device.owner_label': 'Besitzer: {owner}',
'device.rename': 'Umbenennen',
'device.screenshot_btn': 'Screenshot',
'device.remove': 'Entfernen',
'device.click_to_confirm': 'Erneut klicken zum Bestätigen',
'device.prompt_new_name': 'Neuen Namen eingeben:',
'device.confirm_discard_draft': 'Alle nicht veröffentlichten Änderungen verwerfen und zur letzten veröffentlichten Version zurückkehren?',
'device.failed_load': 'Gerät konnte nicht geladen werden',
'device.no_screenshot': 'Kein Screenshot verfügbar. Klicken Sie auf „Screenshot", um einen aufzunehmen.',
'device.no_content_assigned': 'Kein Inhalt zugewiesen',
'device.now_playing_id': 'Wiedergabe: {id}',
'device.playlist_count_one': '1 Element in der Playlist',
'device.playlist_count_other': '{n} Elemente in der Playlist',
'device.tab.now_playing': 'Aktuelle Wiedergabe',
'device.tab.now_playing_tip': 'Live-Screenshot dessen, was aktuell auf diesem Gerät angezeigt wird.',
'device.tab.playlist': 'Playlist',
'device.tab.playlist_tip': 'Diesem Gerät zugewiesener Inhalt. Ziehen zum Sortieren. Medien, Widgets oder Kioskseiten hinzufügen.',
'device.tab.info': 'Geräteinfos',
'device.tab.info_tip': 'Hardware-Telemetrie, Ausrichtung, Notizen und Gerätesteuerung.',
'device.tab.remote': 'Fernsteuerung',
'device.tab.remote_tip': 'Bildschirm in Echtzeit anzeigen und Tastendrücke senden. Funktioniert mit Android-APK und Web-Player.',
'device.draft.banner_title': 'Unveröffentlichte Änderungen',
'device.draft.devices_showing_published': 'Geräte zeigen weiterhin die zuletzt veröffentlichte Version.',
'device.draft.never_published': 'Diese Playlist wurde noch nie veröffentlicht. Geräte zeigen nichts an, bis Sie veröffentlichen.',
'device.draft.discard': 'Verwerfen',
'device.draft.publish': 'Veröffentlichen',
'device.draft.publishing': 'Wird veröffentlicht...',
'device.layout.label': 'Bildschirm-Layout',
'device.layout.fullscreen_default': 'Vollbild (Standard)',
'device.layout.zones_count': '{name} ({n} Zonen)',
'device.layout.template_zones_count': '[Vorlage] {name} ({n} Zonen)',
'device.layout.apply': 'Anwenden',
'device.playlist.label': 'Playlist',
'device.playlist.no_playlist': 'Keine Playlist',
'device.playlist.copy_to_btn': 'Kopieren nach...',
'device.playlist.add_content_btn': 'Inhalt hinzufügen',
'device.playlist.empty_title': 'Kein Inhalt zugewiesen',
'device.playlist.empty_desc': 'Fügen Sie Inhalt aus Ihrer Bibliothek zur Playlist dieses Bildschirms hinzu.',
'device.playlist_picker.with_count': '{name} — {n} Elemente',
'device.playlist_picker.with_auto': '{name} (auto) — {n} Elemente',
'device.info.status': 'Status',
'device.info.ip_address': 'IP-Adresse',
'device.info.battery': 'Akku',
'device.info.storage': 'Speicher',
'device.info.size_free': '{size} frei',
'device.info.player_type': 'Player-Typ',
'device.info.web_player': 'Web-Player',
'device.info.wifi': 'WLAN',
'device.info.uptime': 'Betriebszeit',
'device.info.android_version': 'Android-Version',
'device.info.app_version': 'App-Version',
'device.info.screen_resolution': 'Bildschirmauflösung',
'device.info.ram': 'RAM',
'device.info.cpu_usage': 'CPU-Auslastung',
'device.timeline.title': 'Verfügbarkeit (letzte 24 Stunden)',
'device.timeline.h24_ago': 'vor 24h',
'device.timeline.now': 'Jetzt',
'device.timeline.online': 'Online',
'device.timeline.offline': 'Offline',
'device.timeline.no_data': 'Keine Daten',
'device.timeline.uptime_pct_tracked': '{pct}% Verfügbarkeit ({n}min erfasst)',
'device.timeline.uptime_pct_no_data': '{pct}% Verfügbarkeit (keine Daten)',
'device.form.orientation_label': 'Ausrichtung / Drehung',
'device.form.orientation.landscape': 'Querformat (0°)',
'device.form.orientation.portrait': 'Hochformat (90° im UZS)',
'device.form.orientation.landscape_flipped': 'Querformat gedreht (180°)',
'device.form.orientation.portrait_flipped': 'Hochformat gedreht (270° im UZS)',
'device.form.default_content_label': 'Standardinhalt',
'device.form.default_content_none': 'Keiner ("Warten..." anzeigen)',
'device.form.notes_label': 'Notizen',
'device.form.notes_placeholder': 'Standort, Setup-Details usw.',
'device.form.save_settings': 'Einstellungen speichern',
'device.ctl.reboot_device': 'Gerät neu starten',
'device.ctl.screen_off': 'Bildschirm aus',
'device.ctl.screen_on': 'Bildschirm an',
'device.ctl.launch_player': 'Player starten',
'device.ctl.force_update': 'Update erzwingen',
'device.ctl.shutdown': 'Herunterfahren',
'device.remote.start_prompt': 'Auf „Fernsteuerung starten" klicken zum Beginnen',
'device.remote.start': 'Fernsteuerung starten',
'device.remote.stop': 'Fernsteuerung beenden',
'device.remote.vol_up': 'Vol +',
'device.remote.vol_down': 'Vol -',
'device.remote.home': 'Start',
'device.remote.back': 'Zurück',
'device.remote.recents': 'Zuletzt',
'device.remote.power': 'Power',
'device.remote.ok': 'OK',
'device.remote.settings': 'Einstellungen',
'device.remote.scrn_off': 'Bs. aus',
'device.remote.scrn_on': 'Bs. an',
'device.remote.enable_system_view': 'Systemansicht aktivieren',
'device.remote.system_view_tooltip': 'Fordert den Gerätenutzer auf, Vollbildaufnahme zu erlauben - aktiviert Fernansicht von Startbildschirm, Einstellungen und anderen Apps',
'device.remote.system_view_hint': 'Einmalige Genehmigung am Gerät erforderlich',
'device.remote.waiting_for_approval': 'Warte auf Genehmigung am Gerät...',
'device.remote.system_view_enabled': 'Systemansicht aktiviert',
'device.remote.unlocked_hint': 'Navigation und Systemsteuerung freigeschaltet',
'device.pl_item.widget_with_type': 'Widget ({type})',
'device.pl_item.youtube': 'YouTube',
'device.pl_item.video': 'Video',
'device.pl_item.image': 'Bild',
'device.pl_item.zone_label': 'Zone: {id}',
'device.pl_item.no_zone': 'Keine Zone',
'device.pl_item.mute': 'Stummschalten',
'device.pl_item.unmute': 'Stummschaltung aufheben',
'device.pl_item.remove': 'Entfernen',
'device.copy.no_other_devices': 'Keine anderen Geräte zum Kopieren',
'device.copy.prompt': 'Playlist auf welches Gerät kopieren?\n\n{list}\n\nNummer eingeben:',
'device.copy.invalid_selection': 'Ungültige Auswahl',
'device.copy.toast': '{n} Elemente nach {device} kopiert',
'device.assign.empty_all': 'Noch keine Inhalte, Widgets oder Kioskseiten. Erstellen Sie zuerst etwas!',
'device.assign.modal_title': 'Zur Playlist hinzufügen',
'device.assign.zone_label': 'Zone',
'device.assign.zone_default': 'Standard (Vollbild)',
'device.assign.duration_label': 'Anzeigedauer (Sekunden, für Bilder/Widgets)',
'device.assign.tab.media': 'Medien ({n})',
'device.assign.tab.widgets': 'Widgets ({n})',
'device.assign.tab.kiosk': 'Kiosk ({n})',
'device.assign.no_media': 'Noch keine Medien hochgeladen',
'device.assign.no_widgets': 'Noch keine Widgets erstellt.',
'device.assign.no_kiosk': 'Noch keine Kioskseiten.',
'device.assign.create_one': 'Eines erstellen',
'device.assign.add_selected': 'Auswahl hinzufügen',
'device.assign.select_first': 'Erst etwas auswählen',
'device.assign.kiosk_widget_name': 'Kiosk: {name}',
'device.toast.screenshot_requested': 'Screenshot angefordert',
'device.toast.renamed': 'Bildschirm umbenannt',
'device.toast.removing': 'Wird entfernt...',
'device.toast.removed': 'Bildschirm entfernt',
'device.toast.settings_saved': 'Einstellungen gespeichert',
'device.toast.published': 'Playlist veröffentlicht — Geräte aktualisiert',
'device.toast.draft_discarded': 'Entwurfsänderungen verworfen',
'device.toast.playlist_changed': 'Playlist geändert',
'device.toast.layout_applied': 'Layout angewendet',
'device.toast.switched_to_fullscreen': 'Auf Vollbild umgestellt',
'device.toast.added_to_playlist': 'Zur Playlist hinzugefügt',
'device.toast.unmuted': 'Stummschaltung aufgehoben',
'device.toast.muted': 'Stummgeschaltet',
'device.toast.zone_updated': 'Zone aktualisiert',
'device.toast.removed_from_playlist': 'Inhalt aus Playlist entfernt',
'device.toast.playlist_reordered': 'Playlist neu sortiert',
'device.toast.reboot_sent': 'Neustart-Befehl gesendet',
'device.toast.shutdown_sent': 'Herunterfahren-Befehl gesendet',
'device.toast.screen_off_sent': 'Befehl Bildschirm aus gesendet',
'device.toast.screen_on_sent': 'Befehl Bildschirm an gesendet',
'device.toast.launch_sent': 'Start-Befehl gesendet',
'device.toast.update_triggered': 'Update-Prüfung ausgelöst',
'device.toast.remote_started': 'Fernsteuerungssitzung gestartet',
// Settings
'settings.title': 'Einstellungen',
'settings.subtitle': 'Serverkonfiguration und Setup-Informationen',
'settings.account': 'Konto',
'settings.save_profile': 'Profil speichern',
'settings.change_password': 'Passwort ändern',
'settings.password_min_8': 'Muss mindestens 8 Zeichen lang sein.',
'settings.current_password': 'Aktuelles Passwort',
'settings.new_password': 'Neues Passwort',
'settings.confirm_new_password': 'Neues Passwort bestätigen',
'settings.sso_note': 'Sie melden sich über {provider} an. Verwalten Sie Ihr Passwort dort.',
'settings.license': 'Lizenz',
'settings.license_mit': 'MIT-Lizenz - alle Funktionen enthalten.',
'settings.platform_admin_link': 'Plattform-Admin-Tools sind auf der Seite',
'settings.platform_admin_page_suffix': '.',
'settings.user_management': 'Benutzerverwaltung',
'settings.loading_users': 'Benutzer werden geladen...',
'settings.white_label': 'White Label / Branding',
'settings.white_label_desc': 'Passen Sie das Erscheinungsbild des Dashboards und Players für Ihre Kunden an.',
'settings.brand_name': 'Markenname',
'settings.logo_url': 'Logo-URL',
'settings.primary_color': 'Primärfarbe',
'settings.bg_color': 'Hintergrundfarbe',
'settings.custom_domain': 'Benutzerdefinierte Domain',
'settings.favicon_url': 'Favicon-URL',
'settings.custom_css': 'Benutzerdefiniertes CSS (optional)',
'settings.hide_branding': '„ScreenTinker"-Branding ausblenden',
'settings.save_branding': 'Branding speichern',
'settings.preview': 'Vorschau',
'settings.white_label_enterprise_only': 'Benutzerdefiniertes Branding ist im Enterprise-Plan verfügbar',
'settings.view_plans': 'Pläne ansehen',
'settings.server_info': 'Serverinformationen',
'settings.server_url': 'Server-URL',
'settings.api_endpoint': 'API-Endpunkt',
'settings.server_url_hint': 'Verwenden Sie diese URL beim Einrichten der Android-App',
'settings.setup_guide': 'Setup-Anleitung',
'settings.setup_step_1': 'Installieren Sie die ScreenTinker APK auf Ihrem TV via Sideloading',
'settings.setup_step_2_prefix': 'Öffnen Sie die App und geben Sie diese Server-URL ein:',
'settings.setup_step_3': 'Die App zeigt einen 6-stelligen Kopplungscode an',
'settings.setup_step_4': 'Klicken Sie auf „Bildschirm hinzufügen" und geben Sie den Code ein',
'settings.setup_step_5': 'Inhalte in die Inhaltsbibliothek hochladen',
'settings.setup_step_6': 'Inhalte der Playlist des Bildschirms zuweisen',
'settings.your_data': 'Ihre Daten',
'settings.your_data_desc': 'Exportieren oder importieren Sie Ihre Geräte, Inhalte, Layouts, Zeitpläne und alle Einstellungen. Nutzen Sie dies zur Migration zwischen Cloud und Selfhosted.',
'settings.export_my_data': 'Meine Daten exportieren',
'settings.include_media_zip': 'Mediendateien einschließen (ZIP)',
'settings.import_data': 'Daten importieren',
'settings.language': 'Sprache',
'settings.about': 'Über',
'settings.about_tagline': 'Digital-Signage-Verwaltungssystem.',
'settings.third_party_licenses': 'Drittanbieter-Lizenzen',
'settings.import.reading_file': 'Datei wird gelesen...',
'settings.import.zip_detected': 'ZIP-Export erkannt: <strong>{name}</strong> ({size} MB)<br>Enthält Daten + Mediendateien.',
'settings.import.confirm': 'Import bestätigen',
'settings.import.invalid_file': 'Ungültige Datei. Muss ein ScreenTinker-Export-JSON oder ZIP sein.',
'settings.import.summary_devices': '{n} Geräte',
'settings.import.summary_content': '{n} Inhalte',
'settings.import.summary_widgets': '{n} Widgets',
'settings.import.summary_layouts': '{n} Layouts',
'settings.import.summary_schedules': '{n} Zeitpläne',
'settings.import.summary_walls': '{n} Videowände',
'settings.import.summary_kiosk': '{n} Kioskseiten',
'settings.import.found_summary': 'Gefunden: {summary}.<br>Von: {email} (exportiert {date})',
'settings.import.empty_export': 'leerer Export',
'settings.import.uploading_zip': 'Wird hochgeladen und importiert... Bei großen Dateien kann dies einen Moment dauern.',
'settings.import.importing': 'Wird importiert...',
'settings.import.complete': 'Import abgeschlossen: {imported}.',
'settings.import.pairing_codes_title': 'Geräte-Kopplungscodes:',
'settings.import.pairing_codes_hint': 'Geben Sie diese Codes an jedem Gerät ein, um sie neu zu verknüpfen. Zuweisungen und Zeitpläne bleiben erhalten.',
'settings.import.failed': 'Import fehlgeschlagen',
'settings.import.failed_with_error': 'Import fehlgeschlagen: {error}',
'settings.import.read_failed': 'Datei konnte nicht gelesen werden: {error}',
'settings.toast.support_token_generated': 'Support-Token erstellt (gültig {hours}h)',
'settings.toast.import_success': 'Daten erfolgreich importiert',
'settings.toast.name_required': 'Name darf nicht leer sein',
'settings.toast.profile_saved': 'Profil gespeichert',
'settings.toast.current_password_required': 'Geben Sie Ihr aktuelles Passwort ein',
'settings.toast.new_password_min_8': 'Neues Passwort muss mindestens 8 Zeichen lang sein',
'settings.toast.passwords_dont_match': 'Neue Passwörter stimmen nicht überein',
'settings.toast.password_changed': 'Passwort geändert',
'settings.toast.branding_saved': 'Branding gespeichert',
'settings.toast.preview_applied': 'Vorschau angewendet (zum Zurücksetzen neu laden)',
'settings.toast.plan_updated': 'Plan aktualisiert',
'settings.toast.user_removed': 'Benutzer entfernt',
'settings.user.col_user': 'Benutzer',
'settings.user.col_auth': 'Auth',
'settings.user.col_role': 'Rolle',
'settings.user.col_plan': 'Plan',
'settings.user.col_actions': 'Aktionen',
'settings.user.remove': 'Entfernen',
'settings.user.you': 'Sie',
'settings.user.confirm': 'Bestätigen?',
'settings.user.count_one': '1 Benutzer registriert',
'settings.user.count_other': '{n} Benutzer registriert',
}; };

View file

@ -205,4 +205,266 @@ export default {
'content.toast.folder_deleted': 'Folder deleted', 'content.toast.folder_deleted': 'Folder deleted',
'content.toast.moved': 'Moved', 'content.toast.moved': 'Moved',
'content.toast.moved_to_root': 'Moved to root', 'content.toast.moved_to_root': 'Moved to root',
// Device detail
'device.back': 'Back to Displays',
'device.owner_label': 'Owner: {owner}',
'device.rename': 'Rename',
'device.screenshot_btn': 'Screenshot',
'device.remove': 'Remove',
'device.click_to_confirm': 'Click again to confirm',
'device.prompt_new_name': 'Enter new name:',
'device.confirm_discard_draft': 'Discard all unpublished changes and revert to the last published version?',
'device.failed_load': 'Failed to load device',
'device.no_screenshot': 'No screenshot available. Click "Screenshot" to capture one.',
'device.no_content_assigned': 'No content assigned',
'device.now_playing_id': 'Playing: {id}',
'device.playlist_count_one': '1 item in playlist',
'device.playlist_count_other': '{n} items in playlist',
// Tabs
'device.tab.now_playing': 'Now Playing',
'device.tab.now_playing_tip': "Live screenshot of what's currently displaying on this device.",
'device.tab.playlist': 'Playlist',
'device.tab.playlist_tip': 'Content assigned to this device. Drag items to reorder. Add media, widgets, or kiosk pages.',
'device.tab.info': 'Device Info',
'device.tab.info_tip': 'Hardware telemetry, orientation settings, notes, and device controls.',
'device.tab.remote': 'Remote Control',
'device.tab.remote_tip': 'View the device screen in real-time and send key presses. Works on Android APK and web player.',
// Draft banner
'device.draft.banner_title': 'Unpublished changes',
'device.draft.devices_showing_published': 'Devices are still showing the last published version.',
'device.draft.never_published': 'This playlist has never been published. Devices will show nothing until you publish.',
'device.draft.discard': 'Discard',
'device.draft.publish': 'Publish',
'device.draft.publishing': 'Publishing...',
// Layout selector
'device.layout.label': 'Screen Layout',
'device.layout.fullscreen_default': 'Fullscreen (default)',
'device.layout.zones_count': '{name} ({n} zones)',
'device.layout.template_zones_count': '[Template] {name} ({n} zones)',
'device.layout.apply': 'Apply',
// Playlist tab
'device.playlist.label': 'Playlist',
'device.playlist.no_playlist': 'No playlist',
'device.playlist.copy_to_btn': 'Copy To...',
'device.playlist.add_content_btn': 'Add Content',
'device.playlist.empty_title': 'No content assigned',
'device.playlist.empty_desc': "Add content from your library to this display's playlist.",
'device.playlist_picker.with_count': '{name} — {n} items',
'device.playlist_picker.with_auto': '{name} (auto) — {n} items',
// Info cards
'device.info.status': 'Status',
'device.info.ip_address': 'IP Address',
'device.info.battery': 'Battery',
'device.info.storage': 'Storage',
'device.info.size_free': '{size} free',
'device.info.player_type': 'Player Type',
'device.info.web_player': 'Web Player',
'device.info.wifi': 'WiFi',
'device.info.uptime': 'Uptime',
'device.info.android_version': 'Android Version',
'device.info.app_version': 'App Version',
'device.info.screen_resolution': 'Screen Resolution',
'device.info.ram': 'RAM',
'device.info.cpu_usage': 'CPU Usage',
// Uptime timeline
'device.timeline.title': 'Uptime Timeline (Last 24 Hours)',
'device.timeline.h24_ago': '24h ago',
'device.timeline.now': 'Now',
'device.timeline.online': 'Online',
'device.timeline.offline': 'Offline',
'device.timeline.no_data': 'No data',
'device.timeline.uptime_pct_tracked': '{pct}% uptime ({n}min tracked)',
'device.timeline.uptime_pct_no_data': '{pct}% uptime (no data)',
// Form
'device.form.orientation_label': 'Orientation / Rotation',
'device.form.orientation.landscape': 'Landscape (0°)',
'device.form.orientation.portrait': 'Portrait (90° CW)',
'device.form.orientation.landscape_flipped': 'Landscape Flipped (180°)',
'device.form.orientation.portrait_flipped': 'Portrait Flipped (270° CW)',
'device.form.default_content_label': 'Default Content',
'device.form.default_content_none': 'None (show "Waiting...")',
'device.form.notes_label': 'Notes',
'device.form.notes_placeholder': 'Location, setup details, etc.',
'device.form.save_settings': 'Save Settings',
// Control buttons
'device.ctl.reboot_device': 'Reboot Device',
'device.ctl.screen_off': 'Screen Off',
'device.ctl.screen_on': 'Screen On',
'device.ctl.launch_player': 'Launch Player',
'device.ctl.force_update': 'Force Update',
'device.ctl.shutdown': 'Shutdown',
// Remote tab
'device.remote.start_prompt': 'Click "Start Remote" to begin',
'device.remote.start': 'Start Remote',
'device.remote.stop': 'Stop Remote',
'device.remote.vol_up': 'Vol +',
'device.remote.vol_down': 'Vol -',
'device.remote.home': 'Home',
'device.remote.back': 'Back',
'device.remote.recents': 'Recents',
'device.remote.power': 'Power',
'device.remote.ok': 'OK',
'device.remote.settings': 'Settings',
'device.remote.scrn_off': 'Scrn Off',
'device.remote.scrn_on': 'Scrn On',
'device.remote.enable_system_view': 'Enable System View',
'device.remote.system_view_tooltip': 'Prompts the device user to allow full screen capture - enables remote view of home screen, settings, and other apps',
'device.remote.system_view_hint': 'Requires one-time approval on device',
'device.remote.waiting_for_approval': 'Waiting for device approval...',
'device.remote.system_view_enabled': 'System View Enabled',
'device.remote.unlocked_hint': 'Navigation and system controls unlocked',
// Playlist item
'device.pl_item.widget_with_type': 'Widget ({type})',
'device.pl_item.youtube': 'YouTube',
'device.pl_item.video': 'Video',
'device.pl_item.image': 'Image',
'device.pl_item.zone_label': 'Zone: {id}',
'device.pl_item.no_zone': 'No zone',
'device.pl_item.mute': 'Mute',
'device.pl_item.unmute': 'Unmute',
'device.pl_item.remove': 'Remove',
// Copy playlist
'device.copy.no_other_devices': 'No other devices to copy to',
'device.copy.prompt': 'Copy playlist to which device?\n\n{list}\n\nEnter number:',
'device.copy.invalid_selection': 'Invalid selection',
'device.copy.toast': 'Copied {n} items to {device}',
// Add-content modal
'device.assign.empty_all': 'No content, widgets, or kiosk pages yet. Create something first!',
'device.assign.modal_title': 'Add to Playlist',
'device.assign.zone_label': 'Zone',
'device.assign.zone_default': 'Default (fullscreen)',
'device.assign.duration_label': 'Display Duration (seconds, for images/widgets)',
'device.assign.tab.media': 'Media ({n})',
'device.assign.tab.widgets': 'Widgets ({n})',
'device.assign.tab.kiosk': 'Kiosk ({n})',
'device.assign.no_media': 'No media uploaded yet',
'device.assign.no_widgets': 'No widgets created yet.',
'device.assign.no_kiosk': 'No kiosk pages yet.',
'device.assign.create_one': 'Create one',
'device.assign.add_selected': 'Add Selected',
'device.assign.select_first': 'Select something first',
'device.assign.kiosk_widget_name': 'Kiosk: {name}',
// Toasts
'device.toast.screenshot_requested': 'Screenshot requested',
'device.toast.renamed': 'Display renamed',
'device.toast.removing': 'Removing...',
'device.toast.removed': 'Display removed',
'device.toast.settings_saved': 'Settings saved',
'device.toast.published': 'Playlist published — devices updated',
'device.toast.draft_discarded': 'Draft changes discarded',
'device.toast.playlist_changed': 'Playlist changed',
'device.toast.layout_applied': 'Layout applied',
'device.toast.switched_to_fullscreen': 'Switched to fullscreen',
'device.toast.added_to_playlist': 'Added to playlist',
'device.toast.unmuted': 'Unmuted',
'device.toast.muted': 'Muted',
'device.toast.zone_updated': 'Zone updated',
'device.toast.removed_from_playlist': 'Content removed from playlist',
'device.toast.playlist_reordered': 'Playlist reordered',
'device.toast.reboot_sent': 'Reboot command sent',
'device.toast.shutdown_sent': 'Shutdown command sent',
'device.toast.screen_off_sent': 'Screen off command sent',
'device.toast.screen_on_sent': 'Screen on command sent',
'device.toast.launch_sent': 'Launch command sent',
'device.toast.update_triggered': 'Update check triggered',
'device.toast.remote_started': 'Remote session started',
// Settings
'settings.title': 'Settings',
'settings.subtitle': 'Server configuration and setup information',
'settings.account': 'Account',
'settings.save_profile': 'Save Profile',
'settings.change_password': 'Change Password',
'settings.password_min_8': 'Must be at least 8 characters.',
'settings.current_password': 'Current Password',
'settings.new_password': 'New Password',
'settings.confirm_new_password': 'Confirm New Password',
'settings.sso_note': 'You sign in via {provider}. Manage your password there.',
'settings.license': 'License',
'settings.license_mit': 'MIT License - all features included.',
'settings.platform_admin_link': 'Platform admin tools are in the',
'settings.platform_admin_page_suffix': 'page.',
'settings.user_management': 'User Management',
'settings.loading_users': 'Loading users...',
'settings.white_label': 'White Label / Branding',
'settings.white_label_desc': 'Customize the look of your dashboard and player for your clients.',
'settings.brand_name': 'Brand Name',
'settings.logo_url': 'Logo URL',
'settings.primary_color': 'Primary Color',
'settings.bg_color': 'Background Color',
'settings.custom_domain': 'Custom Domain',
'settings.favicon_url': 'Favicon URL',
'settings.custom_css': 'Custom CSS (optional)',
'settings.hide_branding': 'Hide "ScreenTinker" branding',
'settings.save_branding': 'Save Branding',
'settings.preview': 'Preview',
'settings.white_label_enterprise_only': 'Custom branding is available on the Enterprise plan',
'settings.view_plans': 'View Plans',
'settings.server_info': 'Server Information',
'settings.server_url': 'Server URL',
'settings.api_endpoint': 'API Endpoint',
'settings.server_url_hint': 'Use this URL when setting up the Android app',
'settings.setup_guide': 'Setup Guide',
'settings.setup_step_1': 'Install the ScreenTinker APK on your TV via sideloading',
'settings.setup_step_2_prefix': 'Open the app and enter this server URL:',
'settings.setup_step_3': 'The app will display a 6-digit pairing code',
'settings.setup_step_4': 'Click "Add Display" on the dashboard and enter the pairing code',
'settings.setup_step_5': 'Upload content in the Content Library',
'settings.setup_step_6': "Assign content to the display's Playlist",
'settings.your_data': 'Your Data',
'settings.your_data_desc': 'Export or import your devices, content, layouts, schedules, and all settings. Use this to migrate between cloud and self-hosted instances.',
'settings.export_my_data': 'Export My Data',
'settings.include_media_zip': 'Include media files (ZIP)',
'settings.import_data': 'Import Data',
'settings.language': 'Language',
'settings.about': 'About',
'settings.about_tagline': 'Digital signage management system.',
'settings.third_party_licenses': 'Third-Party Licenses',
// Import flow
'settings.import.reading_file': 'Reading file...',
'settings.import.zip_detected': 'ZIP export detected: <strong>{name}</strong> ({size} MB)<br>Contains data + media files.',
'settings.import.confirm': 'Confirm Import',
'settings.import.invalid_file': 'Invalid file. Must be a ScreenTinker export JSON or ZIP.',
'settings.import.summary_devices': '{n} devices',
'settings.import.summary_content': '{n} content items',
'settings.import.summary_widgets': '{n} widgets',
'settings.import.summary_layouts': '{n} layouts',
'settings.import.summary_schedules': '{n} schedules',
'settings.import.summary_walls': '{n} video walls',
'settings.import.summary_kiosk': '{n} kiosk pages',
'settings.import.found_summary': 'Found: {summary}.<br>From: {email} (exported {date})',
'settings.import.empty_export': 'empty export',
'settings.import.uploading_zip': 'Uploading and importing... This may take a moment for large files.',
'settings.import.importing': 'Importing...',
'settings.import.complete': 'Import complete: {imported}.',
'settings.import.pairing_codes_title': 'Device Pairing Codes:',
'settings.import.pairing_codes_hint': 'Enter these codes on each device to re-link them. All assignments and schedules will be preserved.',
'settings.import.failed': 'Import failed',
'settings.import.failed_with_error': 'Import failed: {error}',
'settings.import.read_failed': 'Failed to read file: {error}',
// Settings toasts
'settings.toast.support_token_generated': 'Support token generated (valid {hours}h)',
'settings.toast.import_success': 'Data imported successfully',
'settings.toast.name_required': 'Name cannot be empty',
'settings.toast.profile_saved': 'Profile saved',
'settings.toast.current_password_required': 'Enter your current password',
'settings.toast.new_password_min_8': 'New password must be at least 8 characters',
'settings.toast.passwords_dont_match': 'New passwords do not match',
'settings.toast.password_changed': 'Password changed',
'settings.toast.branding_saved': 'Branding saved',
'settings.toast.preview_applied': 'Preview applied (refresh to reset)',
'settings.toast.plan_updated': 'Plan updated',
'settings.toast.user_removed': 'User removed',
// User management table
'settings.user.col_user': 'User',
'settings.user.col_auth': 'Auth',
'settings.user.col_role': 'Role',
'settings.user.col_plan': 'Plan',
'settings.user.col_actions': 'Actions',
'settings.user.remove': 'Remove',
'settings.user.you': 'You',
'settings.user.confirm': 'Confirm?',
'settings.user.count_one': '1 user registered',
'settings.user.count_other': '{n} users registered',
}; };

View file

@ -193,4 +193,250 @@ export default {
'content.toast.folder_deleted': 'Carpeta eliminada', 'content.toast.folder_deleted': 'Carpeta eliminada',
'content.toast.moved': 'Movido', 'content.toast.moved': 'Movido',
'content.toast.moved_to_root': 'Movido a la raíz', 'content.toast.moved_to_root': 'Movido a la raíz',
// Device detail
'device.back': 'Volver a Pantallas',
'device.owner_label': 'Propietario: {owner}',
'device.rename': 'Renombrar',
'device.screenshot_btn': 'Captura',
'device.remove': 'Eliminar',
'device.click_to_confirm': 'Haz clic de nuevo para confirmar',
'device.prompt_new_name': 'Ingresa el nuevo nombre:',
'device.confirm_discard_draft': '¿Descartar todos los cambios no publicados y volver a la última versión publicada?',
'device.failed_load': 'Error al cargar el dispositivo',
'device.no_screenshot': 'No hay captura disponible. Haz clic en "Captura" para tomar una.',
'device.no_content_assigned': 'Sin contenido asignado',
'device.now_playing_id': 'Reproduciendo: {id}',
'device.playlist_count_one': '1 elemento en la lista',
'device.playlist_count_other': '{n} elementos en la lista',
'device.tab.now_playing': 'Reproducción actual',
'device.tab.now_playing_tip': 'Captura en vivo de lo que se muestra ahora en este dispositivo.',
'device.tab.playlist': 'Lista',
'device.tab.playlist_tip': 'Contenido asignado a este dispositivo. Arrastra para reordenar. Agrega medios, widgets o páginas de kiosco.',
'device.tab.info': 'Información del dispositivo',
'device.tab.info_tip': 'Telemetría de hardware, orientación, notas y controles del dispositivo.',
'device.tab.remote': 'Control remoto',
'device.tab.remote_tip': 'Visualiza la pantalla del dispositivo en tiempo real y envía pulsaciones. Funciona en la APK de Android y el reproductor web.',
'device.draft.banner_title': 'Cambios sin publicar',
'device.draft.devices_showing_published': 'Los dispositivos siguen mostrando la última versión publicada.',
'device.draft.never_published': 'Esta lista nunca se ha publicado. Los dispositivos no mostrarán nada hasta que la publiques.',
'device.draft.discard': 'Descartar',
'device.draft.publish': 'Publicar',
'device.draft.publishing': 'Publicando...',
'device.layout.label': 'Diseño de pantalla',
'device.layout.fullscreen_default': 'Pantalla completa (predeterminado)',
'device.layout.zones_count': '{name} ({n} zonas)',
'device.layout.template_zones_count': '[Plantilla] {name} ({n} zonas)',
'device.layout.apply': 'Aplicar',
'device.playlist.label': 'Lista',
'device.playlist.no_playlist': 'Sin lista',
'device.playlist.copy_to_btn': 'Copiar a...',
'device.playlist.add_content_btn': 'Agregar contenido',
'device.playlist.empty_title': 'Sin contenido asignado',
'device.playlist.empty_desc': 'Agrega contenido de tu biblioteca a la lista de esta pantalla.',
'device.playlist_picker.with_count': '{name} — {n} elementos',
'device.playlist_picker.with_auto': '{name} (auto) — {n} elementos',
'device.info.status': 'Estado',
'device.info.ip_address': 'Dirección IP',
'device.info.battery': 'Batería',
'device.info.storage': 'Almacenamiento',
'device.info.size_free': '{size} libres',
'device.info.player_type': 'Tipo de reproductor',
'device.info.web_player': 'Reproductor web',
'device.info.wifi': 'WiFi',
'device.info.uptime': 'Tiempo activo',
'device.info.android_version': 'Versión de Android',
'device.info.app_version': 'Versión de la app',
'device.info.screen_resolution': 'Resolución de pantalla',
'device.info.ram': 'RAM',
'device.info.cpu_usage': 'Uso de CPU',
'device.timeline.title': 'Línea de tiempo (últimas 24 horas)',
'device.timeline.h24_ago': 'hace 24h',
'device.timeline.now': 'Ahora',
'device.timeline.online': 'En línea',
'device.timeline.offline': 'Desconectado',
'device.timeline.no_data': 'Sin datos',
'device.timeline.uptime_pct_tracked': '{pct}% activo ({n}min registrados)',
'device.timeline.uptime_pct_no_data': '{pct}% activo (sin datos)',
'device.form.orientation_label': 'Orientación / Rotación',
'device.form.orientation.landscape': 'Horizontal (0°)',
'device.form.orientation.portrait': 'Vertical (90° SR)',
'device.form.orientation.landscape_flipped': 'Horizontal invertido (180°)',
'device.form.orientation.portrait_flipped': 'Vertical invertido (270° SR)',
'device.form.default_content_label': 'Contenido predeterminado',
'device.form.default_content_none': 'Ninguno (mostrar "Esperando...")',
'device.form.notes_label': 'Notas',
'device.form.notes_placeholder': 'Ubicación, detalles de instalación, etc.',
'device.form.save_settings': 'Guardar configuración',
'device.ctl.reboot_device': 'Reiniciar dispositivo',
'device.ctl.screen_off': 'Apagar pantalla',
'device.ctl.screen_on': 'Encender pantalla',
'device.ctl.launch_player': 'Iniciar reproductor',
'device.ctl.force_update': 'Forzar actualización',
'device.ctl.shutdown': 'Apagar',
'device.remote.start_prompt': 'Haz clic en "Iniciar control remoto" para comenzar',
'device.remote.start': 'Iniciar control remoto',
'device.remote.stop': 'Detener control remoto',
'device.remote.vol_up': 'Vol +',
'device.remote.vol_down': 'Vol -',
'device.remote.home': 'Inicio',
'device.remote.back': 'Atrás',
'device.remote.recents': 'Recientes',
'device.remote.power': 'Encendido',
'device.remote.ok': 'OK',
'device.remote.settings': 'Ajustes',
'device.remote.scrn_off': 'Pant. off',
'device.remote.scrn_on': 'Pant. on',
'device.remote.enable_system_view': 'Habilitar vista de sistema',
'device.remote.system_view_tooltip': 'Solicita al usuario del dispositivo permitir captura de pantalla completa - habilita ver pantalla de inicio, ajustes y otras apps',
'device.remote.system_view_hint': 'Requiere aprobación única en el dispositivo',
'device.remote.waiting_for_approval': 'Esperando aprobación del dispositivo...',
'device.remote.system_view_enabled': 'Vista de sistema habilitada',
'device.remote.unlocked_hint': 'Navegación y controles del sistema desbloqueados',
'device.pl_item.widget_with_type': 'Widget ({type})',
'device.pl_item.youtube': 'YouTube',
'device.pl_item.video': 'Video',
'device.pl_item.image': 'Imagen',
'device.pl_item.zone_label': 'Zona: {id}',
'device.pl_item.no_zone': 'Sin zona',
'device.pl_item.mute': 'Silenciar',
'device.pl_item.unmute': 'Activar audio',
'device.pl_item.remove': 'Eliminar',
'device.copy.no_other_devices': 'No hay otros dispositivos a los que copiar',
'device.copy.prompt': '¿A qué dispositivo copiar la lista?\n\n{list}\n\nIngresa el número:',
'device.copy.invalid_selection': 'Selección no válida',
'device.copy.toast': 'Se copiaron {n} elementos a {device}',
'device.assign.empty_all': 'Aún no hay contenido, widgets ni páginas de kiosco. ¡Crea algo primero!',
'device.assign.modal_title': 'Agregar a la lista',
'device.assign.zone_label': 'Zona',
'device.assign.zone_default': 'Predeterminada (pantalla completa)',
'device.assign.duration_label': 'Duración (segundos, para imágenes/widgets)',
'device.assign.tab.media': 'Medios ({n})',
'device.assign.tab.widgets': 'Widgets ({n})',
'device.assign.tab.kiosk': 'Kiosco ({n})',
'device.assign.no_media': 'Aún no se ha subido ningún medio',
'device.assign.no_widgets': 'Aún no hay widgets creados.',
'device.assign.no_kiosk': 'Aún no hay páginas de kiosco.',
'device.assign.create_one': 'Crea uno',
'device.assign.add_selected': 'Agregar selección',
'device.assign.select_first': 'Primero selecciona algo',
'device.assign.kiosk_widget_name': 'Kiosco: {name}',
'device.toast.screenshot_requested': 'Captura solicitada',
'device.toast.renamed': 'Pantalla renombrada',
'device.toast.removing': 'Eliminando...',
'device.toast.removed': 'Pantalla eliminada',
'device.toast.settings_saved': 'Configuración guardada',
'device.toast.published': 'Lista publicada — dispositivos actualizados',
'device.toast.draft_discarded': 'Cambios del borrador descartados',
'device.toast.playlist_changed': 'Lista cambiada',
'device.toast.layout_applied': 'Diseño aplicado',
'device.toast.switched_to_fullscreen': 'Cambiado a pantalla completa',
'device.toast.added_to_playlist': 'Agregado a la lista',
'device.toast.unmuted': 'Audio activado',
'device.toast.muted': 'Silenciado',
'device.toast.zone_updated': 'Zona actualizada',
'device.toast.removed_from_playlist': 'Contenido eliminado de la lista',
'device.toast.playlist_reordered': 'Lista reordenada',
'device.toast.reboot_sent': 'Comando de reinicio enviado',
'device.toast.shutdown_sent': 'Comando de apagado enviado',
'device.toast.screen_off_sent': 'Comando para apagar pantalla enviado',
'device.toast.screen_on_sent': 'Comando para encender pantalla enviado',
'device.toast.launch_sent': 'Comando de inicio enviado',
'device.toast.update_triggered': 'Verificación de actualización iniciada',
'device.toast.remote_started': 'Sesión de control remoto iniciada',
// Settings
'settings.title': 'Configuración',
'settings.subtitle': 'Configuración del servidor e información de instalación',
'settings.account': 'Cuenta',
'settings.save_profile': 'Guardar perfil',
'settings.change_password': 'Cambiar contraseña',
'settings.password_min_8': 'Debe tener al menos 8 caracteres.',
'settings.current_password': 'Contraseña actual',
'settings.new_password': 'Contraseña nueva',
'settings.confirm_new_password': 'Confirmar contraseña nueva',
'settings.sso_note': 'Inicias sesión con {provider}. Gestiona tu contraseña ahí.',
'settings.license': 'Licencia',
'settings.license_mit': 'Licencia MIT - todas las funciones incluidas.',
'settings.platform_admin_link': 'Las herramientas de administración están en la',
'settings.platform_admin_page_suffix': '.',
'settings.user_management': 'Gestión de usuarios',
'settings.loading_users': 'Cargando usuarios...',
'settings.white_label': 'Marca blanca / Branding',
'settings.white_label_desc': 'Personaliza la apariencia del panel y el reproductor para tus clientes.',
'settings.brand_name': 'Nombre de la marca',
'settings.logo_url': 'URL del logotipo',
'settings.primary_color': 'Color principal',
'settings.bg_color': 'Color de fondo',
'settings.custom_domain': 'Dominio personalizado',
'settings.favicon_url': 'URL del favicon',
'settings.custom_css': 'CSS personalizado (opcional)',
'settings.hide_branding': 'Ocultar la marca "ScreenTinker"',
'settings.save_branding': 'Guardar branding',
'settings.preview': 'Previsualizar',
'settings.white_label_enterprise_only': 'El branding personalizado está disponible en el plan Enterprise',
'settings.view_plans': 'Ver planes',
'settings.server_info': 'Información del servidor',
'settings.server_url': 'URL del servidor',
'settings.api_endpoint': 'Endpoint de la API',
'settings.server_url_hint': 'Usa esta URL al configurar la app de Android',
'settings.setup_guide': 'Guía de instalación',
'settings.setup_step_1': 'Instala el APK de ScreenTinker en tu TV mediante sideloading',
'settings.setup_step_2_prefix': 'Abre la app e ingresa esta URL del servidor:',
'settings.setup_step_3': 'La app mostrará un código de vinculación de 6 dígitos',
'settings.setup_step_4': 'Haz clic en "Agregar pantalla" en el panel e ingresa el código',
'settings.setup_step_5': 'Sube contenido en la Biblioteca de contenido',
'settings.setup_step_6': 'Asigna contenido a la lista de la pantalla',
'settings.your_data': 'Tus datos',
'settings.your_data_desc': 'Exporta o importa tus dispositivos, contenido, diseños, horarios y toda la configuración. Úsalo para migrar entre instancias en la nube y autoalojadas.',
'settings.export_my_data': 'Exportar mis datos',
'settings.include_media_zip': 'Incluir archivos multimedia (ZIP)',
'settings.import_data': 'Importar datos',
'settings.language': 'Idioma',
'settings.about': 'Acerca de',
'settings.about_tagline': 'Sistema de gestión de señalización digital.',
'settings.third_party_licenses': 'Licencias de terceros',
'settings.import.reading_file': 'Leyendo archivo...',
'settings.import.zip_detected': 'Exportación ZIP detectada: <strong>{name}</strong> ({size} MB)<br>Contiene datos + archivos multimedia.',
'settings.import.confirm': 'Confirmar importación',
'settings.import.invalid_file': 'Archivo no válido. Debe ser un JSON o ZIP de exportación de ScreenTinker.',
'settings.import.summary_devices': '{n} dispositivos',
'settings.import.summary_content': '{n} elementos de contenido',
'settings.import.summary_widgets': '{n} widgets',
'settings.import.summary_layouts': '{n} diseños',
'settings.import.summary_schedules': '{n} horarios',
'settings.import.summary_walls': '{n} muros de video',
'settings.import.summary_kiosk': '{n} páginas de kiosco',
'settings.import.found_summary': 'Encontrado: {summary}.<br>De: {email} (exportado {date})',
'settings.import.empty_export': 'exportación vacía',
'settings.import.uploading_zip': 'Subiendo e importando... Esto puede tardar para archivos grandes.',
'settings.import.importing': 'Importando...',
'settings.import.complete': 'Importación completa: {imported}.',
'settings.import.pairing_codes_title': 'Códigos de vinculación:',
'settings.import.pairing_codes_hint': 'Ingresa estos códigos en cada dispositivo para volver a vincularlos. Las asignaciones y horarios se conservarán.',
'settings.import.failed': 'Error al importar',
'settings.import.failed_with_error': 'Error al importar: {error}',
'settings.import.read_failed': 'Error al leer el archivo: {error}',
'settings.toast.support_token_generated': 'Token de soporte generado (válido {hours}h)',
'settings.toast.import_success': 'Datos importados correctamente',
'settings.toast.name_required': 'El nombre no puede estar vacío',
'settings.toast.profile_saved': 'Perfil guardado',
'settings.toast.current_password_required': 'Ingresa tu contraseña actual',
'settings.toast.new_password_min_8': 'La nueva contraseña debe tener al menos 8 caracteres',
'settings.toast.passwords_dont_match': 'Las nuevas contraseñas no coinciden',
'settings.toast.password_changed': 'Contraseña cambiada',
'settings.toast.branding_saved': 'Branding guardado',
'settings.toast.preview_applied': 'Previsualización aplicada (recarga para restablecer)',
'settings.toast.plan_updated': 'Plan actualizado',
'settings.toast.user_removed': 'Usuario eliminado',
'settings.user.col_user': 'Usuario',
'settings.user.col_auth': 'Auth',
'settings.user.col_role': 'Rol',
'settings.user.col_plan': 'Plan',
'settings.user.col_actions': 'Acciones',
'settings.user.remove': 'Eliminar',
'settings.user.you': 'Tú',
'settings.user.confirm': '¿Confirmar?',
'settings.user.count_one': '1 usuario registrado',
'settings.user.count_other': '{n} usuarios registrados',
}; };

View file

@ -194,4 +194,250 @@ export default {
'content.toast.folder_deleted': 'Dossier supprimé', 'content.toast.folder_deleted': 'Dossier supprimé',
'content.toast.moved': 'Déplacé', 'content.toast.moved': 'Déplacé',
'content.toast.moved_to_root': 'Déplacé à la racine', 'content.toast.moved_to_root': 'Déplacé à la racine',
// Device detail
'device.back': 'Retour aux écrans',
'device.owner_label': 'Propriétaire : {owner}',
'device.rename': 'Renommer',
'device.screenshot_btn': 'Capture',
'device.remove': 'Retirer',
'device.click_to_confirm': 'Cliquez à nouveau pour confirmer',
'device.prompt_new_name': 'Saisissez le nouveau nom :',
'device.confirm_discard_draft': 'Annuler toutes les modifications non publiées et revenir à la dernière version publiée ?',
'device.failed_load': 'Échec du chargement de l\'appareil',
'device.no_screenshot': 'Aucune capture disponible. Cliquez sur « Capture » pour en prendre une.',
'device.no_content_assigned': 'Aucun contenu attribué',
'device.now_playing_id': 'En lecture : {id}',
'device.playlist_count_one': '1 élément dans la liste',
'device.playlist_count_other': '{n} éléments dans la liste',
'device.tab.now_playing': 'En cours de lecture',
'device.tab.now_playing_tip': 'Capture en direct de ce qui s\'affiche sur cet appareil.',
'device.tab.playlist': 'Liste de lecture',
'device.tab.playlist_tip': 'Contenu attribué à cet appareil. Glissez pour réorganiser. Ajoutez des médias, widgets ou pages kiosque.',
'device.tab.info': 'Infos appareil',
'device.tab.info_tip': 'Télémétrie matérielle, orientation, notes et contrôles de l\'appareil.',
'device.tab.remote': 'Contrôle à distance',
'device.tab.remote_tip': 'Visualisez l\'écran en temps réel et envoyez des touches. Fonctionne sur l\'APK Android et le lecteur web.',
'device.draft.banner_title': 'Modifications non publiées',
'device.draft.devices_showing_published': 'Les appareils affichent encore la dernière version publiée.',
'device.draft.never_published': 'Cette liste n\'a jamais été publiée. Les appareils n\'afficheront rien jusqu\'à publication.',
'device.draft.discard': 'Annuler',
'device.draft.publish': 'Publier',
'device.draft.publishing': 'Publication...',
'device.layout.label': 'Mise en page',
'device.layout.fullscreen_default': 'Plein écran (par défaut)',
'device.layout.zones_count': '{name} ({n} zones)',
'device.layout.template_zones_count': '[Modèle] {name} ({n} zones)',
'device.layout.apply': 'Appliquer',
'device.playlist.label': 'Liste de lecture',
'device.playlist.no_playlist': 'Aucune liste',
'device.playlist.copy_to_btn': 'Copier vers...',
'device.playlist.add_content_btn': 'Ajouter du contenu',
'device.playlist.empty_title': 'Aucun contenu attribué',
'device.playlist.empty_desc': 'Ajoutez du contenu de votre bibliothèque à la liste de cet écran.',
'device.playlist_picker.with_count': '{name} — {n} éléments',
'device.playlist_picker.with_auto': '{name} (auto) — {n} éléments',
'device.info.status': 'Statut',
'device.info.ip_address': 'Adresse IP',
'device.info.battery': 'Batterie',
'device.info.storage': 'Stockage',
'device.info.size_free': '{size} libres',
'device.info.player_type': 'Type de lecteur',
'device.info.web_player': 'Lecteur web',
'device.info.wifi': 'WiFi',
'device.info.uptime': 'Disponibilité',
'device.info.android_version': 'Version d\'Android',
'device.info.app_version': 'Version de l\'app',
'device.info.screen_resolution': 'Résolution',
'device.info.ram': 'RAM',
'device.info.cpu_usage': 'Utilisation CPU',
'device.timeline.title': 'Disponibilité (24 dernières heures)',
'device.timeline.h24_ago': 'il y a 24h',
'device.timeline.now': 'Maintenant',
'device.timeline.online': 'En ligne',
'device.timeline.offline': 'Hors ligne',
'device.timeline.no_data': 'Aucune donnée',
'device.timeline.uptime_pct_tracked': '{pct}% disponible ({n}min suivies)',
'device.timeline.uptime_pct_no_data': '{pct}% disponible (aucune donnée)',
'device.form.orientation_label': 'Orientation / Rotation',
'device.form.orientation.landscape': 'Paysage (0°)',
'device.form.orientation.portrait': 'Portrait (90° SH)',
'device.form.orientation.landscape_flipped': 'Paysage retourné (180°)',
'device.form.orientation.portrait_flipped': 'Portrait retourné (270° SH)',
'device.form.default_content_label': 'Contenu par défaut',
'device.form.default_content_none': 'Aucun (afficher « En attente... »)',
'device.form.notes_label': 'Notes',
'device.form.notes_placeholder': 'Emplacement, détails d\'installation, etc.',
'device.form.save_settings': 'Enregistrer les paramètres',
'device.ctl.reboot_device': 'Redémarrer l\'appareil',
'device.ctl.screen_off': 'Éteindre l\'écran',
'device.ctl.screen_on': 'Allumer l\'écran',
'device.ctl.launch_player': 'Lancer le lecteur',
'device.ctl.force_update': 'Forcer la mise à jour',
'device.ctl.shutdown': 'Arrêter',
'device.remote.start_prompt': 'Cliquez sur « Démarrer » pour commencer',
'device.remote.start': 'Démarrer',
'device.remote.stop': 'Arrêter',
'device.remote.vol_up': 'Vol +',
'device.remote.vol_down': 'Vol -',
'device.remote.home': 'Accueil',
'device.remote.back': 'Retour',
'device.remote.recents': 'Récents',
'device.remote.power': 'Marche',
'device.remote.ok': 'OK',
'device.remote.settings': 'Paramètres',
'device.remote.scrn_off': 'Écr. off',
'device.remote.scrn_on': 'Écr. on',
'device.remote.enable_system_view': 'Activer la vue système',
'device.remote.system_view_tooltip': 'Demande à l\'utilisateur d\'autoriser la capture plein écran - permet de voir l\'écran d\'accueil, les paramètres et d\'autres apps',
'device.remote.system_view_hint': 'Approbation unique requise sur l\'appareil',
'device.remote.waiting_for_approval': 'Attente de l\'approbation de l\'appareil...',
'device.remote.system_view_enabled': 'Vue système activée',
'device.remote.unlocked_hint': 'Navigation et contrôles système débloqués',
'device.pl_item.widget_with_type': 'Widget ({type})',
'device.pl_item.youtube': 'YouTube',
'device.pl_item.video': 'Vidéo',
'device.pl_item.image': 'Image',
'device.pl_item.zone_label': 'Zone : {id}',
'device.pl_item.no_zone': 'Aucune zone',
'device.pl_item.mute': 'Muet',
'device.pl_item.unmute': 'Réactiver le son',
'device.pl_item.remove': 'Retirer',
'device.copy.no_other_devices': 'Aucun autre appareil vers lequel copier',
'device.copy.prompt': 'Copier la liste vers quel appareil ?\n\n{list}\n\nSaisissez le numéro :',
'device.copy.invalid_selection': 'Sélection invalide',
'device.copy.toast': '{n} éléments copiés vers {device}',
'device.assign.empty_all': 'Pas encore de contenu, widgets ou pages kiosque. Créez-en un d\'abord !',
'device.assign.modal_title': 'Ajouter à la liste',
'device.assign.zone_label': 'Zone',
'device.assign.zone_default': 'Par défaut (plein écran)',
'device.assign.duration_label': 'Durée (secondes, pour images/widgets)',
'device.assign.tab.media': 'Médias ({n})',
'device.assign.tab.widgets': 'Widgets ({n})',
'device.assign.tab.kiosk': 'Kiosque ({n})',
'device.assign.no_media': 'Aucun média téléversé',
'device.assign.no_widgets': 'Aucun widget créé.',
'device.assign.no_kiosk': 'Aucune page kiosque.',
'device.assign.create_one': 'Créez-en un',
'device.assign.add_selected': 'Ajouter la sélection',
'device.assign.select_first': 'Sélectionnez d\'abord un élément',
'device.assign.kiosk_widget_name': 'Kiosque : {name}',
'device.toast.screenshot_requested': 'Capture demandée',
'device.toast.renamed': 'Écran renommé',
'device.toast.removing': 'Suppression...',
'device.toast.removed': 'Écran retiré',
'device.toast.settings_saved': 'Paramètres enregistrés',
'device.toast.published': 'Liste publiée — appareils mis à jour',
'device.toast.draft_discarded': 'Modifications du brouillon annulées',
'device.toast.playlist_changed': 'Liste modifiée',
'device.toast.layout_applied': 'Mise en page appliquée',
'device.toast.switched_to_fullscreen': 'Passé en plein écran',
'device.toast.added_to_playlist': 'Ajouté à la liste',
'device.toast.unmuted': 'Son réactivé',
'device.toast.muted': 'Son coupé',
'device.toast.zone_updated': 'Zone mise à jour',
'device.toast.removed_from_playlist': 'Contenu retiré de la liste',
'device.toast.playlist_reordered': 'Liste réorganisée',
'device.toast.reboot_sent': 'Commande de redémarrage envoyée',
'device.toast.shutdown_sent': 'Commande d\'arrêt envoyée',
'device.toast.screen_off_sent': 'Commande d\'extinction de l\'écran envoyée',
'device.toast.screen_on_sent': 'Commande d\'allumage de l\'écran envoyée',
'device.toast.launch_sent': 'Commande de lancement envoyée',
'device.toast.update_triggered': 'Vérification de mise à jour déclenchée',
'device.toast.remote_started': 'Session de contrôle à distance démarrée',
// Settings
'settings.title': 'Paramètres',
'settings.subtitle': 'Configuration du serveur et informations d\'installation',
'settings.account': 'Compte',
'settings.save_profile': 'Enregistrer le profil',
'settings.change_password': 'Changer le mot de passe',
'settings.password_min_8': 'Doit contenir au moins 8 caractères.',
'settings.current_password': 'Mot de passe actuel',
'settings.new_password': 'Nouveau mot de passe',
'settings.confirm_new_password': 'Confirmer le nouveau mot de passe',
'settings.sso_note': 'Vous vous connectez via {provider}. Gérez votre mot de passe là-bas.',
'settings.license': 'Licence',
'settings.license_mit': 'Licence MIT - toutes les fonctionnalités incluses.',
'settings.platform_admin_link': 'Les outils d\'administration sont sur la page',
'settings.platform_admin_page_suffix': '.',
'settings.user_management': 'Gestion des utilisateurs',
'settings.loading_users': 'Chargement des utilisateurs...',
'settings.white_label': 'Marque blanche / Branding',
'settings.white_label_desc': 'Personnalisez l\'apparence du tableau de bord et du lecteur pour vos clients.',
'settings.brand_name': 'Nom de la marque',
'settings.logo_url': 'URL du logo',
'settings.primary_color': 'Couleur principale',
'settings.bg_color': 'Couleur de fond',
'settings.custom_domain': 'Domaine personnalisé',
'settings.favicon_url': 'URL du favicon',
'settings.custom_css': 'CSS personnalisé (facultatif)',
'settings.hide_branding': 'Masquer la marque « ScreenTinker »',
'settings.save_branding': 'Enregistrer le branding',
'settings.preview': 'Aperçu',
'settings.white_label_enterprise_only': 'Le branding personnalisé est disponible sur le plan Enterprise',
'settings.view_plans': 'Voir les plans',
'settings.server_info': 'Informations du serveur',
'settings.server_url': 'URL du serveur',
'settings.api_endpoint': 'Point de terminaison API',
'settings.server_url_hint': 'Utilisez cette URL pour configurer l\'app Android',
'settings.setup_guide': 'Guide d\'installation',
'settings.setup_step_1': 'Installez l\'APK ScreenTinker sur votre TV via le sideloading',
'settings.setup_step_2_prefix': 'Ouvrez l\'app et saisissez cette URL du serveur :',
'settings.setup_step_3': 'L\'app affichera un code d\'appairage à 6 chiffres',
'settings.setup_step_4': 'Cliquez sur « Ajouter un écran » et saisissez le code',
'settings.setup_step_5': 'Téléversez du contenu dans la Bibliothèque',
'settings.setup_step_6': 'Attribuez du contenu à la liste de l\'écran',
'settings.your_data': 'Vos données',
'settings.your_data_desc': 'Exportez ou importez vos appareils, contenu, mises en page, calendriers et tous les paramètres. Utilisez cela pour migrer entre cloud et auto-hébergé.',
'settings.export_my_data': 'Exporter mes données',
'settings.include_media_zip': 'Inclure les fichiers médias (ZIP)',
'settings.import_data': 'Importer des données',
'settings.language': 'Langue',
'settings.about': 'À propos',
'settings.about_tagline': 'Système de gestion d\'affichage dynamique.',
'settings.third_party_licenses': 'Licences tierces',
'settings.import.reading_file': 'Lecture du fichier...',
'settings.import.zip_detected': 'Export ZIP détecté : <strong>{name}</strong> ({size} Mo)<br>Contient données + fichiers médias.',
'settings.import.confirm': 'Confirmer l\'import',
'settings.import.invalid_file': 'Fichier invalide. Doit être un JSON ou ZIP d\'export ScreenTinker.',
'settings.import.summary_devices': '{n} appareils',
'settings.import.summary_content': '{n} contenus',
'settings.import.summary_widgets': '{n} widgets',
'settings.import.summary_layouts': '{n} mises en page',
'settings.import.summary_schedules': '{n} calendriers',
'settings.import.summary_walls': '{n} murs vidéo',
'settings.import.summary_kiosk': '{n} pages kiosque',
'settings.import.found_summary': 'Trouvé : {summary}.<br>De : {email} (exporté {date})',
'settings.import.empty_export': 'export vide',
'settings.import.uploading_zip': 'Téléversement et import... Cela peut prendre du temps pour les gros fichiers.',
'settings.import.importing': 'Import...',
'settings.import.complete': 'Import terminé : {imported}.',
'settings.import.pairing_codes_title': 'Codes d\'appairage :',
'settings.import.pairing_codes_hint': 'Saisissez ces codes sur chaque appareil pour les relier. Les attributions et calendriers seront préservés.',
'settings.import.failed': 'Échec de l\'import',
'settings.import.failed_with_error': 'Échec de l\'import : {error}',
'settings.import.read_failed': 'Échec de lecture du fichier : {error}',
'settings.toast.support_token_generated': 'Jeton de support généré (valide {hours}h)',
'settings.toast.import_success': 'Données importées avec succès',
'settings.toast.name_required': 'Le nom ne peut pas être vide',
'settings.toast.profile_saved': 'Profil enregistré',
'settings.toast.current_password_required': 'Saisissez votre mot de passe actuel',
'settings.toast.new_password_min_8': 'Le nouveau mot de passe doit comporter au moins 8 caractères',
'settings.toast.passwords_dont_match': 'Les nouveaux mots de passe ne correspondent pas',
'settings.toast.password_changed': 'Mot de passe changé',
'settings.toast.branding_saved': 'Branding enregistré',
'settings.toast.preview_applied': 'Aperçu appliqué (rechargez pour réinitialiser)',
'settings.toast.plan_updated': 'Plan mis à jour',
'settings.toast.user_removed': 'Utilisateur retiré',
'settings.user.col_user': 'Utilisateur',
'settings.user.col_auth': 'Auth',
'settings.user.col_role': 'Rôle',
'settings.user.col_plan': 'Plan',
'settings.user.col_actions': 'Actions',
'settings.user.remove': 'Retirer',
'settings.user.you': 'Vous',
'settings.user.confirm': 'Confirmer ?',
'settings.user.count_one': '1 utilisateur inscrit',
'settings.user.count_other': '{n} utilisateurs inscrits',
}; };

View file

@ -194,4 +194,250 @@ export default {
'content.toast.folder_deleted': 'Pasta excluída', 'content.toast.folder_deleted': 'Pasta excluída',
'content.toast.moved': 'Movido', 'content.toast.moved': 'Movido',
'content.toast.moved_to_root': 'Movido para a raiz', 'content.toast.moved_to_root': 'Movido para a raiz',
// Device detail
'device.back': 'Voltar para Telas',
'device.owner_label': 'Proprietário: {owner}',
'device.rename': 'Renomear',
'device.screenshot_btn': 'Captura',
'device.remove': 'Remover',
'device.click_to_confirm': 'Clique novamente para confirmar',
'device.prompt_new_name': 'Digite o novo nome:',
'device.confirm_discard_draft': 'Descartar todas as alterações não publicadas e voltar à última versão publicada?',
'device.failed_load': 'Falha ao carregar o dispositivo',
'device.no_screenshot': 'Sem captura disponível. Clique em "Captura" para tirar uma.',
'device.no_content_assigned': 'Sem conteúdo atribuído',
'device.now_playing_id': 'Reproduzindo: {id}',
'device.playlist_count_one': '1 item na playlist',
'device.playlist_count_other': '{n} itens na playlist',
'device.tab.now_playing': 'Reproduzindo agora',
'device.tab.now_playing_tip': 'Captura ao vivo do que está sendo exibido neste dispositivo.',
'device.tab.playlist': 'Playlist',
'device.tab.playlist_tip': 'Conteúdo atribuído a este dispositivo. Arraste para reordenar. Adicione mídia, widgets ou páginas de quiosque.',
'device.tab.info': 'Informações do dispositivo',
'device.tab.info_tip': 'Telemetria de hardware, orientação, notas e controles do dispositivo.',
'device.tab.remote': 'Controle remoto',
'device.tab.remote_tip': 'Visualize a tela em tempo real e envie pressionamentos de tecla. Funciona no APK do Android e no player web.',
'device.draft.banner_title': 'Alterações não publicadas',
'device.draft.devices_showing_published': 'Os dispositivos ainda exibem a última versão publicada.',
'device.draft.never_published': 'Esta playlist nunca foi publicada. Os dispositivos não exibirão nada até você publicar.',
'device.draft.discard': 'Descartar',
'device.draft.publish': 'Publicar',
'device.draft.publishing': 'Publicando...',
'device.layout.label': 'Layout da tela',
'device.layout.fullscreen_default': 'Tela cheia (padrão)',
'device.layout.zones_count': '{name} ({n} zonas)',
'device.layout.template_zones_count': '[Modelo] {name} ({n} zonas)',
'device.layout.apply': 'Aplicar',
'device.playlist.label': 'Playlist',
'device.playlist.no_playlist': 'Sem playlist',
'device.playlist.copy_to_btn': 'Copiar para...',
'device.playlist.add_content_btn': 'Adicionar conteúdo',
'device.playlist.empty_title': 'Sem conteúdo atribuído',
'device.playlist.empty_desc': 'Adicione conteúdo da sua biblioteca à playlist desta tela.',
'device.playlist_picker.with_count': '{name} — {n} itens',
'device.playlist_picker.with_auto': '{name} (auto) — {n} itens',
'device.info.status': 'Status',
'device.info.ip_address': 'Endereço IP',
'device.info.battery': 'Bateria',
'device.info.storage': 'Armazenamento',
'device.info.size_free': '{size} livres',
'device.info.player_type': 'Tipo de player',
'device.info.web_player': 'Player web',
'device.info.wifi': 'Wi-Fi',
'device.info.uptime': 'Tempo ativo',
'device.info.android_version': 'Versão do Android',
'device.info.app_version': 'Versão do app',
'device.info.screen_resolution': 'Resolução',
'device.info.ram': 'RAM',
'device.info.cpu_usage': 'Uso de CPU',
'device.timeline.title': 'Linha do tempo (últimas 24 horas)',
'device.timeline.h24_ago': 'há 24h',
'device.timeline.now': 'Agora',
'device.timeline.online': 'Online',
'device.timeline.offline': 'Offline',
'device.timeline.no_data': 'Sem dados',
'device.timeline.uptime_pct_tracked': '{pct}% ativo ({n}min monitorados)',
'device.timeline.uptime_pct_no_data': '{pct}% ativo (sem dados)',
'device.form.orientation_label': 'Orientação / Rotação',
'device.form.orientation.landscape': 'Paisagem (0°)',
'device.form.orientation.portrait': 'Retrato (90° SH)',
'device.form.orientation.landscape_flipped': 'Paisagem invertida (180°)',
'device.form.orientation.portrait_flipped': 'Retrato invertido (270° SH)',
'device.form.default_content_label': 'Conteúdo padrão',
'device.form.default_content_none': 'Nenhum (mostrar "Aguardando...")',
'device.form.notes_label': 'Notas',
'device.form.notes_placeholder': 'Localização, detalhes de instalação, etc.',
'device.form.save_settings': 'Salvar configurações',
'device.ctl.reboot_device': 'Reiniciar dispositivo',
'device.ctl.screen_off': 'Desligar tela',
'device.ctl.screen_on': 'Ligar tela',
'device.ctl.launch_player': 'Iniciar player',
'device.ctl.force_update': 'Forçar atualização',
'device.ctl.shutdown': 'Desligar',
'device.remote.start_prompt': 'Clique em "Iniciar controle remoto" para começar',
'device.remote.start': 'Iniciar controle remoto',
'device.remote.stop': 'Parar controle remoto',
'device.remote.vol_up': 'Vol +',
'device.remote.vol_down': 'Vol -',
'device.remote.home': 'Início',
'device.remote.back': 'Voltar',
'device.remote.recents': 'Recentes',
'device.remote.power': 'Energia',
'device.remote.ok': 'OK',
'device.remote.settings': 'Configurações',
'device.remote.scrn_off': 'Tela off',
'device.remote.scrn_on': 'Tela on',
'device.remote.enable_system_view': 'Ativar visão do sistema',
'device.remote.system_view_tooltip': 'Solicita ao usuário do dispositivo permitir captura de tela cheia - habilita visualização remota da tela inicial, configurações e outros apps',
'device.remote.system_view_hint': 'Aprovação única necessária no dispositivo',
'device.remote.waiting_for_approval': 'Aguardando aprovação do dispositivo...',
'device.remote.system_view_enabled': 'Visão do sistema ativada',
'device.remote.unlocked_hint': 'Navegação e controles do sistema desbloqueados',
'device.pl_item.widget_with_type': 'Widget ({type})',
'device.pl_item.youtube': 'YouTube',
'device.pl_item.video': 'Vídeo',
'device.pl_item.image': 'Imagem',
'device.pl_item.zone_label': 'Zona: {id}',
'device.pl_item.no_zone': 'Sem zona',
'device.pl_item.mute': 'Silenciar',
'device.pl_item.unmute': 'Ativar áudio',
'device.pl_item.remove': 'Remover',
'device.copy.no_other_devices': 'Não há outros dispositivos para copiar',
'device.copy.prompt': 'Copiar playlist para qual dispositivo?\n\n{list}\n\nDigite o número:',
'device.copy.invalid_selection': 'Seleção inválida',
'device.copy.toast': '{n} itens copiados para {device}',
'device.assign.empty_all': 'Ainda não há conteúdo, widgets ou páginas de quiosque. Crie algo primeiro!',
'device.assign.modal_title': 'Adicionar à playlist',
'device.assign.zone_label': 'Zona',
'device.assign.zone_default': 'Padrão (tela cheia)',
'device.assign.duration_label': 'Duração (segundos, para imagens/widgets)',
'device.assign.tab.media': 'Mídia ({n})',
'device.assign.tab.widgets': 'Widgets ({n})',
'device.assign.tab.kiosk': 'Quiosque ({n})',
'device.assign.no_media': 'Nenhuma mídia enviada ainda',
'device.assign.no_widgets': 'Nenhum widget criado ainda.',
'device.assign.no_kiosk': 'Nenhuma página de quiosque ainda.',
'device.assign.create_one': 'Crie um',
'device.assign.add_selected': 'Adicionar selecionados',
'device.assign.select_first': 'Selecione algo primeiro',
'device.assign.kiosk_widget_name': 'Quiosque: {name}',
'device.toast.screenshot_requested': 'Captura solicitada',
'device.toast.renamed': 'Tela renomeada',
'device.toast.removing': 'Removendo...',
'device.toast.removed': 'Tela removida',
'device.toast.settings_saved': 'Configurações salvas',
'device.toast.published': 'Playlist publicada — dispositivos atualizados',
'device.toast.draft_discarded': 'Alterações do rascunho descartadas',
'device.toast.playlist_changed': 'Playlist alterada',
'device.toast.layout_applied': 'Layout aplicado',
'device.toast.switched_to_fullscreen': 'Alterado para tela cheia',
'device.toast.added_to_playlist': 'Adicionado à playlist',
'device.toast.unmuted': 'Áudio ativado',
'device.toast.muted': 'Silenciado',
'device.toast.zone_updated': 'Zona atualizada',
'device.toast.removed_from_playlist': 'Conteúdo removido da playlist',
'device.toast.playlist_reordered': 'Playlist reordenada',
'device.toast.reboot_sent': 'Comando de reinício enviado',
'device.toast.shutdown_sent': 'Comando de desligamento enviado',
'device.toast.screen_off_sent': 'Comando para desligar tela enviado',
'device.toast.screen_on_sent': 'Comando para ligar tela enviado',
'device.toast.launch_sent': 'Comando de início enviado',
'device.toast.update_triggered': 'Verificação de atualização disparada',
'device.toast.remote_started': 'Sessão de controle remoto iniciada',
// Settings
'settings.title': 'Configurações',
'settings.subtitle': 'Configuração do servidor e informações de instalação',
'settings.account': 'Conta',
'settings.save_profile': 'Salvar perfil',
'settings.change_password': 'Alterar senha',
'settings.password_min_8': 'Deve ter no mínimo 8 caracteres.',
'settings.current_password': 'Senha atual',
'settings.new_password': 'Nova senha',
'settings.confirm_new_password': 'Confirmar nova senha',
'settings.sso_note': 'Você entra via {provider}. Gerencie sua senha lá.',
'settings.license': 'Licença',
'settings.license_mit': 'Licença MIT - todos os recursos incluídos.',
'settings.platform_admin_link': 'As ferramentas de admin da plataforma estão na página',
'settings.platform_admin_page_suffix': '.',
'settings.user_management': 'Gestão de usuários',
'settings.loading_users': 'Carregando usuários...',
'settings.white_label': 'Marca branca / Branding',
'settings.white_label_desc': 'Personalize a aparência do painel e do player para seus clientes.',
'settings.brand_name': 'Nome da marca',
'settings.logo_url': 'URL do logotipo',
'settings.primary_color': 'Cor primária',
'settings.bg_color': 'Cor de fundo',
'settings.custom_domain': 'Domínio personalizado',
'settings.favicon_url': 'URL do favicon',
'settings.custom_css': 'CSS personalizado (opcional)',
'settings.hide_branding': 'Ocultar marca "ScreenTinker"',
'settings.save_branding': 'Salvar branding',
'settings.preview': 'Pré-visualizar',
'settings.white_label_enterprise_only': 'Branding personalizado disponível no plano Enterprise',
'settings.view_plans': 'Ver planos',
'settings.server_info': 'Informações do servidor',
'settings.server_url': 'URL do servidor',
'settings.api_endpoint': 'Endpoint da API',
'settings.server_url_hint': 'Use esta URL ao configurar o app Android',
'settings.setup_guide': 'Guia de instalação',
'settings.setup_step_1': 'Instale o APK do ScreenTinker na sua TV via sideloading',
'settings.setup_step_2_prefix': 'Abra o app e digite esta URL do servidor:',
'settings.setup_step_3': 'O app exibirá um código de pareamento de 6 dígitos',
'settings.setup_step_4': 'Clique em "Adicionar tela" no painel e digite o código',
'settings.setup_step_5': 'Envie conteúdo na Biblioteca de conteúdo',
'settings.setup_step_6': 'Atribua conteúdo à playlist da tela',
'settings.your_data': 'Seus dados',
'settings.your_data_desc': 'Exporte ou importe seus dispositivos, conteúdo, layouts, agendas e todas as configurações. Use para migrar entre nuvem e auto-hospedado.',
'settings.export_my_data': 'Exportar meus dados',
'settings.include_media_zip': 'Incluir arquivos de mídia (ZIP)',
'settings.import_data': 'Importar dados',
'settings.language': 'Idioma',
'settings.about': 'Sobre',
'settings.about_tagline': 'Sistema de gerenciamento de sinalização digital.',
'settings.third_party_licenses': 'Licenças de terceiros',
'settings.import.reading_file': 'Lendo arquivo...',
'settings.import.zip_detected': 'Exportação ZIP detectada: <strong>{name}</strong> ({size} MB)<br>Contém dados + arquivos de mídia.',
'settings.import.confirm': 'Confirmar importação',
'settings.import.invalid_file': 'Arquivo inválido. Deve ser JSON ou ZIP de exportação do ScreenTinker.',
'settings.import.summary_devices': '{n} dispositivos',
'settings.import.summary_content': '{n} itens de conteúdo',
'settings.import.summary_widgets': '{n} widgets',
'settings.import.summary_layouts': '{n} layouts',
'settings.import.summary_schedules': '{n} agendas',
'settings.import.summary_walls': '{n} paredes de vídeo',
'settings.import.summary_kiosk': '{n} páginas de quiosque',
'settings.import.found_summary': 'Encontrado: {summary}.<br>De: {email} (exportado {date})',
'settings.import.empty_export': 'exportação vazia',
'settings.import.uploading_zip': 'Enviando e importando... Pode demorar para arquivos grandes.',
'settings.import.importing': 'Importando...',
'settings.import.complete': 'Importação concluída: {imported}.',
'settings.import.pairing_codes_title': 'Códigos de pareamento:',
'settings.import.pairing_codes_hint': 'Digite estes códigos em cada dispositivo para revinculá-los. Atribuições e agendas serão preservadas.',
'settings.import.failed': 'Falha na importação',
'settings.import.failed_with_error': 'Falha na importação: {error}',
'settings.import.read_failed': 'Falha ao ler o arquivo: {error}',
'settings.toast.support_token_generated': 'Token de suporte gerado (válido {hours}h)',
'settings.toast.import_success': 'Dados importados com sucesso',
'settings.toast.name_required': 'O nome não pode ficar em branco',
'settings.toast.profile_saved': 'Perfil salvo',
'settings.toast.current_password_required': 'Digite sua senha atual',
'settings.toast.new_password_min_8': 'A nova senha deve ter no mínimo 8 caracteres',
'settings.toast.passwords_dont_match': 'As novas senhas não conferem',
'settings.toast.password_changed': 'Senha alterada',
'settings.toast.branding_saved': 'Branding salvo',
'settings.toast.preview_applied': 'Pré-visualização aplicada (recarregue para redefinir)',
'settings.toast.plan_updated': 'Plano atualizado',
'settings.toast.user_removed': 'Usuário removido',
'settings.user.col_user': 'Usuário',
'settings.user.col_auth': 'Auth',
'settings.user.col_role': 'Função',
'settings.user.col_plan': 'Plano',
'settings.user.col_actions': 'Ações',
'settings.user.remove': 'Remover',
'settings.user.you': 'Você',
'settings.user.confirm': 'Confirmar?',
'settings.user.count_one': '1 usuário registrado',
'settings.user.count_other': '{n} usuários registrados',
}; };

View file

@ -2,6 +2,7 @@ import { api } from '../api.js';
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js'; import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
import { t, tn } from '../i18n.js';
let currentDevice = null; let currentDevice = null;
let statusHandler = null; let statusHandler = null;
@ -33,10 +34,10 @@ export function render(container, deviceId) {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/> <line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>
</svg> </svg>
Back to Displays ${t('device.back')}
</a> </a>
<div id="deviceContent"> <div id="deviceContent">
<div class="empty-state"><h3>Loading...</h3></div> <div class="empty-state"><h3>${t('common.loading')}</h3></div>
</div> </div>
</div> </div>
`; `;
@ -94,7 +95,7 @@ export function render(container, deviceId) {
if (data.device_id !== deviceId) return; if (data.device_id !== deviceId) return;
const el = document.getElementById('nowPlayingInfo'); const el = document.getElementById('nowPlayingInfo');
if (el && data.current_content_id) { if (el && data.current_content_id) {
el.textContent = `Playing: ${data.current_content_id}`; el.textContent = t('device.now_playing_id', { id: data.current_content_id });
} }
}; };
@ -115,26 +116,26 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="device-header-left"> <div class="device-header-left">
<h1 id="deviceName">${device.name}</h1> <h1 id="deviceName">${device.name}</h1>
<span class="device-status-badge ${device.status}">${device.status}</span> <span class="device-status-badge ${device.status}">${device.status}</span>
${device.owner_name || device.owner_email ? `<span style="font-size:12px;color:var(--text-muted)">Owner: ${device.owner_name || device.owner_email}</span>` : ''} ${device.owner_name || device.owner_email ? `<span style="font-size:12px;color:var(--text-muted)">${t('device.owner_label', { owner: device.owner_name || device.owner_email })}</span>` : ''}
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="renameBtn">Rename</button> <button class="btn btn-secondary btn-sm" id="renameBtn">${t('device.rename')}</button>
<button class="btn btn-secondary btn-sm" id="screenshotBtn"> <button class="btn btn-secondary btn-sm" id="screenshotBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/> <polyline points="21 15 16 10 5 21"/>
</svg> </svg>
Screenshot ${t('device.screenshot_btn')}
</button> </button>
<button class="btn btn-danger btn-sm" id="deleteDeviceBtn">Remove</button> <button class="btn btn-danger btn-sm" id="deleteDeviceBtn">${t('device.remove')}</button>
</div> </div>
</div> </div>
<div class="tabs"> <div class="tabs">
<div class="tab active" data-tab="nowplaying">Now Playing <span class="help-tip" data-tip="Live screenshot of what's currently displaying on this device.">?</span></div> <div class="tab active" data-tab="nowplaying">${t('device.tab.now_playing')} <span class="help-tip" data-tip="${t('device.tab.now_playing_tip')}">?</span></div>
<div class="tab" data-tab="playlist">Playlist <span class="help-tip" data-tip="Content assigned to this device. Drag items to reorder. Add media, widgets, or kiosk pages.">?</span></div> <div class="tab" data-tab="playlist">${t('device.tab.playlist')} <span class="help-tip" data-tip="${t('device.tab.playlist_tip')}">?</span></div>
<div class="tab" data-tab="info">Device Info <span class="help-tip" data-tip="Hardware telemetry, orientation settings, notes, and device controls.">?</span></div> <div class="tab" data-tab="info">${t('device.tab.info')} <span class="help-tip" data-tip="${t('device.tab.info_tip')}">?</span></div>
<div class="tab" data-tab="remote">Remote Control <span class="help-tip" data-tip="View the device screen in real-time and send key presses. Works on Android APK and web player.">?</span></div> <div class="tab" data-tab="remote">${t('device.tab.remote')} <span class="help-tip" data-tip="${t('device.tab.remote_tip')}">?</span></div>
</div> </div>
<!-- Now Playing Tab --> <!-- Now Playing Tab -->
@ -148,12 +149,12 @@ async function loadDevice(deviceId, activeTab = null) {
<line x1="8" y1="21" x2="16" y2="21"/> <line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/> <line x1="12" y1="17" x2="12" y2="21"/>
</svg> </svg>
<span>No screenshot available. Click "Screenshot" to capture one.</span> <span>${t('device.no_screenshot')}</span>
</div>` </div>`
} }
</div> </div>
<p id="nowPlayingInfo" style="color:var(--text-secondary);font-size:13px;"> <p id="nowPlayingInfo" style="color:var(--text-secondary);font-size:13px;">
${device.assignments?.length ? `${device.assignments.length} item(s) in playlist` : 'No content assigned'} ${device.assignments?.length ? tn('device.playlist_count', device.assignments.length) : t('device.no_content_assigned')}
</p> </p>
</div> </div>
@ -164,13 +165,13 @@ async function loadDevice(deviceId, activeTab = null) {
<div style="display:flex;align-items:center;gap:10px;color:#fbbf24"> <div style="display:flex;align-items:center;gap:10px;color:#fbbf24">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<div> <div>
<div style="font-weight:600;font-size:14px">Unpublished changes</div> <div style="font-weight:600;font-size:14px">${t('device.draft.banner_title')}</div>
<div style="font-size:12px;color:#fcd34d;opacity:0.85">${device.playlist_has_published ? 'Devices are still showing the last published version.' : 'This playlist has never been published. Devices will show nothing until you publish.'}</div> <div style="font-size:12px;color:#fcd34d;opacity:0.85">${device.playlist_has_published ? t('device.draft.devices_showing_published') : t('device.draft.never_published')}</div>
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;flex-shrink:0"> <div style="display:flex;gap:8px;flex-shrink:0">
${device.playlist_has_published ? '<button class="btn btn-secondary btn-sm" id="deviceDiscardDraftBtn" style="color:#fbbf24;border-color:#92400e">Discard</button>' : ''} ${device.playlist_has_published ? `<button class="btn btn-secondary btn-sm" id="deviceDiscardDraftBtn" style="color:#fbbf24;border-color:#92400e">${t('device.draft.discard')}</button>` : ''}
<button class="btn btn-sm" id="devicePublishBtn" style="background:#f59e0b;color:#000;font-weight:600;border:none">Publish</button> <button class="btn btn-sm" id="devicePublishBtn" style="background:#f59e0b;color:#000;font-weight:600;border:none">${t('device.draft.publish')}</button>
</div> </div>
</div> </div>
` : ''} ` : ''}
@ -180,28 +181,28 @@ async function loadDevice(deviceId, activeTab = null) {
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>
</svg> </svg>
<div style="flex:1"> <div style="flex:1">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">Screen Layout</div> <div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">${t('device.layout.label')}</div>
<select id="deviceLayoutSelect" class="input" style="background:var(--bg-input);padding:4px 8px;font-size:13px"> <select id="deviceLayoutSelect" class="input" style="background:var(--bg-input);padding:4px 8px;font-size:13px">
<option value="">Fullscreen (default)</option> <option value="">${t('device.layout.fullscreen_default')}</option>
</select> </select>
</div> </div>
<button class="btn btn-secondary btn-sm" id="applyLayoutBtn">Apply</button> <button class="btn btn-secondary btn-sm" id="applyLayoutBtn">${t('device.layout.apply')}</button>
</div> </div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:12px"> <div style="display:flex;align-items:center;gap:12px">
<h3 style="font-size:16px">Playlist</h3> <h3 style="font-size:16px">${t('device.playlist.label')}</h3>
<select class="input" id="playlistPicker" style="font-size:12px;padding:4px 8px;width:200px"> <select class="input" id="playlistPicker" style="font-size:12px;padding:4px 8px;width:200px">
<option value="">No playlist</option> <option value="">${t('device.playlist.no_playlist')}</option>
</select> </select>
</div> </div>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm" id="copyPlaylistBtn">Copy To...</button> <button class="btn btn-secondary btn-sm" id="copyPlaylistBtn">${t('device.playlist.copy_to_btn')}</button>
<button class="btn btn-primary btn-sm" id="addContentBtn"> <button class="btn btn-primary btn-sm" id="addContentBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" 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"/> <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg> </svg>
Add Content ${t('device.playlist.add_content_btn')}
</button> </button>
</div> </div>
</div> </div>
@ -214,16 +215,16 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="tab-content" id="tab-info"> <div class="tab-content" id="tab-info">
<div class="info-grid"> <div class="info-grid">
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Status</div> <div class="info-card-label">${t('device.info.status')}</div>
<div class="info-card-value" style="color:var(--${device.status === 'online' ? 'success' : 'danger'})">${device.status}</div> <div class="info-card-value" style="color:var(--${device.status === 'online' ? 'success' : 'danger'})">${device.status}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">IP Address</div> <div class="info-card-label">${t('device.info.ip_address')}</div>
<div class="info-card-value small">${device.ip_address || '--'}</div> <div class="info-card-value small">${device.ip_address || '--'}</div>
</div> </div>
${device.android_version && !device.android_version.startsWith('Web/') ? ` ${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Battery</div> <div class="info-card-label">${t('device.info.battery')}</div>
<div class="info-card-value" id="telBattery">${latestTelemetry.battery_level != null ? latestTelemetry.battery_level + '%' : '--'}</div> <div class="info-card-value" id="telBattery">${latestTelemetry.battery_level != null ? latestTelemetry.battery_level + '%' : '--'}</div>
${latestTelemetry.battery_level != null ? ` ${latestTelemetry.battery_level != null ? `
<div class="progress-bar"> <div class="progress-bar">
@ -232,8 +233,8 @@ async function loadDevice(deviceId, activeTab = null) {
</div>` : ''} </div>` : ''}
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Storage</div> <div class="info-card-label">${t('device.info.storage')}</div>
<div class="info-card-value small" id="telStorage">${latestTelemetry.storage_free_mb ? formatBytes(latestTelemetry.storage_free_mb) + ' free' : '--'}</div> <div class="info-card-value small" id="telStorage">${latestTelemetry.storage_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.storage_free_mb) }) : '--'}</div>
${latestTelemetry.storage_total_mb ? ` ${latestTelemetry.storage_total_mb ? `
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-bar-fill ${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb) < 0.8 ? 'success' : 'warning'}" <div class="progress-bar-fill ${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb) < 0.8 ? 'success' : 'warning'}"
@ -242,42 +243,42 @@ async function loadDevice(deviceId, activeTab = null) {
</div> </div>
` : ` ` : `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Player Type</div> <div class="info-card-label">${t('device.info.player_type')}</div>
<div class="info-card-value small">Web Player</div> <div class="info-card-value small">${t('device.info.web_player')}</div>
</div> </div>
`} `}
${device.android_version && !device.android_version.startsWith('Web/') ? ` ${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">WiFi</div> <div class="info-card-label">${t('device.info.wifi')}</div>
<div class="info-card-value small" id="telWifi">${latestTelemetry.wifi_ssid || '--'}</div> <div class="info-card-value small" id="telWifi">${latestTelemetry.wifi_ssid || '--'}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px" id="telRssi">${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}</div> <div style="font-size:11px;color:var(--text-muted);margin-top:2px" id="telRssi">${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}</div>
</div> </div>
` : ''} ` : ''}
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Uptime</div> <div class="info-card-label">${t('device.info.uptime')}</div>
<div class="info-card-value small" id="telUptime">${formatUptime(latestTelemetry.uptime_seconds)}</div> <div class="info-card-value small" id="telUptime">${formatUptime(latestTelemetry.uptime_seconds)}</div>
</div> </div>
${device.android_version && !device.android_version.startsWith('Web/') ? ` ${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Android Version</div> <div class="info-card-label">${t('device.info.android_version')}</div>
<div class="info-card-value small">${device.android_version}</div> <div class="info-card-value small">${device.android_version}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">App Version</div> <div class="info-card-label">${t('device.info.app_version')}</div>
<div class="info-card-value small">${device.app_version || '--'}</div> <div class="info-card-value small">${device.app_version || '--'}</div>
</div> </div>
` : ''} ` : ''}
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Screen Resolution</div> <div class="info-card-label">${t('device.info.screen_resolution')}</div>
<div class="info-card-value small">${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}</div> <div class="info-card-value small">${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}</div>
</div> </div>
${device.android_version && !device.android_version.startsWith('Web/') ? ` ${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">RAM</div> <div class="info-card-label">${t('device.info.ram')}</div>
<div class="info-card-value small" id="telRam">${latestTelemetry.ram_free_mb ? formatBytes(latestTelemetry.ram_free_mb) + ' free' : '--'}</div> <div class="info-card-value small" id="telRam">${latestTelemetry.ram_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.ram_free_mb) }) : '--'}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">CPU Usage</div> <div class="info-card-label">${t('device.info.cpu_usage')}</div>
<div class="info-card-value small" id="telCpu">${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}</div> <div class="info-card-value small" id="telCpu">${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}</div>
</div> </div>
` : ''} ` : ''}
@ -285,16 +286,16 @@ async function loadDevice(deviceId, activeTab = null) {
<!-- Uptime Timeline (24h) --> <!-- Uptime Timeline (24h) -->
<div style="margin-top:20px"> <div style="margin-top:20px">
<h4 style="font-size:13px;margin-bottom:8px">Uptime Timeline (Last 24 Hours)</h4> <h4 style="font-size:13px;margin-bottom:8px">${t('device.timeline.title')}</h4>
<div id="uptimeTimeline" style="display:flex;height:32px;border-radius:4px;overflow:hidden;border:1px solid var(--border);background:var(--bg-primary)"></div> <div id="uptimeTimeline" style="display:flex;height:32px;border-radius:4px;overflow:hidden;border:1px solid var(--border);background:var(--bg-primary)"></div>
<div style="display:flex;justify-content:space-between;margin-top:4px"> <div style="display:flex;justify-content:space-between;margin-top:4px">
<span style="font-size:10px;color:var(--text-muted)">24h ago</span> <span style="font-size:10px;color:var(--text-muted)">${t('device.timeline.h24_ago')}</span>
<span style="font-size:10px;color:var(--text-muted)">Now</span> <span style="font-size:10px;color:var(--text-muted)">${t('device.timeline.now')}</span>
</div> </div>
<div style="display:flex;gap:12px;margin-top:8px;font-size:11px;color:var(--text-muted)"> <div style="display:flex;gap:12px;margin-top:8px;font-size:11px;color:var(--text-muted)">
<span><span style="display:inline-block;width:10px;height:10px;background:var(--success);border-radius:2px;vertical-align:-1px"></span> Online</span> <span><span style="display:inline-block;width:10px;height:10px;background:var(--success);border-radius:2px;vertical-align:-1px"></span> ${t('device.timeline.online')}</span>
<span><span style="display:inline-block;width:10px;height:10px;background:var(--danger);border-radius:2px;vertical-align:-1px"></span> Offline</span> <span><span style="display:inline-block;width:10px;height:10px;background:var(--danger);border-radius:2px;vertical-align:-1px"></span> ${t('device.timeline.offline')}</span>
<span><span style="display:inline-block;width:10px;height:10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:2px;vertical-align:-1px"></span> No data</span> <span><span style="display:inline-block;width:10px;height:10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:2px;vertical-align:-1px"></span> ${t('device.timeline.no_data')}</span>
<span id="uptimePercent" style="margin-left:auto;font-weight:600"></span> <span id="uptimePercent" style="margin-left:auto;font-weight:600"></span>
</div> </div>
</div> </div>
@ -302,63 +303,63 @@ async function loadDevice(deviceId, activeTab = null) {
<div style="margin-top:20px"> <div style="margin-top:20px">
<div style="display:flex;gap:12px;margin-bottom:12px"> <div style="display:flex;gap:12px;margin-bottom:12px">
<div class="form-group" style="flex:1;margin:0"> <div class="form-group" style="flex:1;margin:0">
<label>Orientation / Rotation</label> <label>${t('device.form.orientation_label')}</label>
<select id="deviceOrientation" class="input" style="background:var(--bg-input)"> <select id="deviceOrientation" class="input" style="background:var(--bg-input)">
<option value="landscape" ${'landscape' === (device.orientation || 'landscape') ? 'selected' : ''}>Landscape (0°)</option> <option value="landscape" ${'landscape' === (device.orientation || 'landscape') ? 'selected' : ''}>${t('device.form.orientation.landscape')}</option>
<option value="portrait" ${'portrait' === device.orientation ? 'selected' : ''}>Portrait (90° CW)</option> <option value="portrait" ${'portrait' === device.orientation ? 'selected' : ''}>${t('device.form.orientation.portrait')}</option>
<option value="landscape-flipped" ${'landscape-flipped' === device.orientation ? 'selected' : ''}>Landscape Flipped (180°)</option> <option value="landscape-flipped" ${'landscape-flipped' === device.orientation ? 'selected' : ''}>${t('device.form.orientation.landscape_flipped')}</option>
<option value="portrait-flipped" ${'portrait-flipped' === device.orientation ? 'selected' : ''}>Portrait Flipped (270° CW)</option> <option value="portrait-flipped" ${'portrait-flipped' === device.orientation ? 'selected' : ''}>${t('device.form.orientation.portrait_flipped')}</option>
</select> </select>
</div> </div>
<div class="form-group" style="flex:1;margin:0"> <div class="form-group" style="flex:1;margin:0">
<label>Default Content</label> <label>${t('device.form.default_content_label')}</label>
<select id="deviceDefaultContent" class="input" style="background:var(--bg-input)"> <select id="deviceDefaultContent" class="input" style="background:var(--bg-input)">
<option value="">None (show "Waiting...")</option> <option value="">${t('device.form.default_content_none')}</option>
</select> </select>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Notes</label> <label>${t('device.form.notes_label')}</label>
<textarea id="deviceNotes" class="input" rows="3" placeholder="Location, setup details, etc." style="resize:vertical">${esc(device.notes || '')}</textarea> <textarea id="deviceNotes" class="input" rows="3" placeholder="${t('device.form.notes_placeholder')}" style="resize:vertical">${esc(device.notes || '')}</textarea>
</div> </div>
<button class="btn btn-secondary btn-sm" id="saveNotesBtn">Save Settings</button> <button class="btn btn-secondary btn-sm" id="saveNotesBtn">${t('device.form.save_settings')}</button>
</div> </div>
<div style="margin-top:20px;display:flex;gap:8px;flex-wrap:wrap"> <div style="margin-top:20px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="rebootBtn"> <button class="btn btn-secondary btn-sm" id="rebootBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/> <polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg> </svg>
Reboot Device ${t('device.ctl.reboot_device')}
</button> </button>
<button class="btn btn-secondary btn-sm" id="screenOffBtn"> <button class="btn btn-secondary btn-sm" id="screenOffBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/> <rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg> </svg>
Screen Off ${t('device.ctl.screen_off')}
</button> </button>
<button class="btn btn-secondary btn-sm" id="screenOnBtn"> <button class="btn btn-secondary btn-sm" id="screenOnBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg> </svg>
Screen On ${t('device.ctl.screen_on')}
</button> </button>
<button class="btn btn-secondary btn-sm" id="launchAppBtn"> <button class="btn btn-secondary btn-sm" id="launchAppBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/> <polygon points="5 3 19 12 5 21 5 3"/>
</svg> </svg>
Launch Player ${t('device.ctl.launch_player')}
</button> </button>
<button class="btn btn-secondary btn-sm" id="forceUpdateBtn"> <button class="btn btn-secondary btn-sm" id="forceUpdateBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg> </svg>
Force Update ${t('device.ctl.force_update')}
</button> </button>
<button class="btn btn-danger btn-sm" id="shutdownBtn"> <button class="btn btn-danger btn-sm" id="shutdownBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/> <path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg> </svg>
Shutdown ${t('device.ctl.shutdown')}
</button> </button>
</div> </div>
</div> </div>
@ -375,24 +376,24 @@ async function loadDevice(deviceId, activeTab = null) {
<line x1="8" y1="21" x2="16" y2="21"/> <line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/> <line x1="12" y1="17" x2="12" y2="21"/>
</svg> </svg>
<p style="color:var(--text-secondary)">Click "Start Remote" to begin</p> <p style="color:var(--text-secondary)">${t('device.remote.start_prompt')}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="remote-controls"> <div class="remote-controls">
<button class="btn btn-primary" id="startRemoteBtn">Start Remote</button> <button class="btn btn-primary" id="startRemoteBtn">${t('device.remote.start')}</button>
<button class="btn btn-secondary" id="stopRemoteBtn" style="display:none">Stop Remote</button> <button class="btn btn-secondary" id="stopRemoteBtn" style="display:none">${t('device.remote.stop')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<!-- Always available --> <!-- Always available -->
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_UP')">Vol +</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_UP')">${t('device.remote.vol_up')}</button>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_DOWN')">Vol -</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_DOWN')">${t('device.remote.vol_down')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<!-- System View controls (disabled until enabled) --> <!-- System View controls (disabled until enabled) -->
<div id="systemViewControls" style="opacity:0.4;pointer-events:none"> <div id="systemViewControls" style="opacity:0.4;pointer-events:none">
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_HOME')">Home</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_HOME')">${t('device.remote.home')}</button>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_BACK')">Back</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_BACK')">${t('device.remote.back')}</button>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_APP_SWITCH')">Recents</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_APP_SWITCH')">${t('device.remote.recents')}</button>
<button class="btn btn-danger btn-sm" onclick="window._sendKey('KEYCODE_POWER')">Power</button> <button class="btn btn-danger btn-sm" onclick="window._sendKey('KEYCODE_POWER')">${t('device.remote.power')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_UP')">&#9650;</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_UP')">&#9650;</button>
<div style="display:flex;gap:4px"> <div style="display:flex;gap:4px">
@ -400,19 +401,19 @@ async function loadDevice(deviceId, activeTab = null) {
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendKey('KEYCODE_DPAD_RIGHT')">&#9654;</button> <button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendKey('KEYCODE_DPAD_RIGHT')">&#9654;</button>
</div> </div>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_DOWN')">&#9660;</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_DOWN')">&#9660;</button>
<button class="btn btn-primary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_CENTER')">OK</button> <button class="btn btn-primary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_CENTER')">${t('device.remote.ok')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<button class="btn btn-secondary btn-sm" onclick="window._sendCmd('settings')">Settings</button> <button class="btn btn-secondary btn-sm" onclick="window._sendCmd('settings')">${t('device.remote.settings')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<div style="display:flex;gap:4px"> <div style="display:flex;gap:4px">
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_off')">Scrn Off</button> <button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_off')">${t('device.remote.scrn_off')}</button>
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_on')">Scrn On</button> <button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_on')">${t('device.remote.scrn_on')}</button>
</div> </div>
</div> </div>
<button class="btn btn-primary btn-sm" id="enableSystemCaptureBtn" onclick="window._enableSystemView()" title="Prompts the device user to allow full screen capture - enables remote view of home screen, settings, and other apps" style="margin-top:8px"> <button class="btn btn-primary btn-sm" id="enableSystemCaptureBtn" onclick="window._enableSystemView()" title="${t('device.remote.system_view_tooltip')}" style="margin-top:8px">
Enable System View ${t('device.remote.enable_system_view')}
</button> </button>
<span id="systemViewHint" style="font-size:10px;color:var(--text-muted);line-height:1.2;display:block;margin-top:4px">Requires one-time approval on device</span> <span id="systemViewHint" style="font-size:10px;color:var(--text-muted);line-height:1.2;display:block;margin-top:4px">${t('device.remote.system_view_hint')}</span>
</div> </div>
</div> </div>
</div> </div>
@ -431,13 +432,13 @@ async function loadDevice(deviceId, activeTab = null) {
// Unlock the system controls after a short delay (user needs to tap "Start now" on device) // Unlock the system controls after a short delay (user needs to tap "Start now" on device)
const btn = document.getElementById('enableSystemCaptureBtn'); const btn = document.getElementById('enableSystemCaptureBtn');
const hint = document.getElementById('systemViewHint'); const hint = document.getElementById('systemViewHint');
if (btn) { btn.textContent = 'Waiting for device approval...'; btn.disabled = true; } if (btn) { btn.textContent = t('device.remote.waiting_for_approval'); btn.disabled = true; }
// Check periodically if the device granted it (we'll know because screenshots keep coming even after Home) // Check periodically if the device granted it (we'll know because screenshots keep coming even after Home)
setTimeout(() => { setTimeout(() => {
const controls = document.getElementById('systemViewControls'); const controls = document.getElementById('systemViewControls');
if (controls) { controls.style.opacity = '1'; controls.style.pointerEvents = 'auto'; } if (controls) { controls.style.opacity = '1'; controls.style.pointerEvents = 'auto'; }
if (btn) { btn.textContent = 'System View Enabled'; btn.style.background = 'var(--success)'; } if (btn) { btn.textContent = t('device.remote.system_view_enabled'); btn.style.background = 'var(--success)'; }
if (hint) hint.textContent = 'Navigation and system controls unlocked'; if (hint) hint.textContent = t('device.remote.unlocked_hint');
}, 5000); }, 5000);
}; };
@ -465,13 +466,13 @@ async function loadDevice(deviceId, activeTab = null) {
} }
} catch (err) { } catch (err) {
contentEl.innerHTML = `<div class="empty-state"><h3>Failed to load device</h3><p>${esc(err.message)}</p></div>`; contentEl.innerHTML = `<div class="empty-state"><h3>${t('device.failed_load')}</h3><p>${esc(err.message)}</p></div>`;
} }
} }
function renderPlaylist(assignments) { function renderPlaylist(assignments) {
if (!assignments.length) { if (!assignments.length) {
return `<div class="empty-state"><h3>No content assigned</h3><p>Add content from your library to this display's playlist.</p></div>`; return `<div class="empty-state"><h3>${t('device.playlist.empty_title')}</h3><p>${t('device.playlist.empty_desc')}</p></div>`;
} }
return assignments.map((a, i) => ` return assignments.map((a, i) => `
<div class="playlist-item" data-assignment-id="${a.id}" draggable="true" data-sort="${i}"> <div class="playlist-item" data-assignment-id="${a.id}" draggable="true" data-sort="${i}">
@ -493,10 +494,10 @@ function renderPlaylist(assignments) {
</div>` </div>`
} }
<div class="playlist-item-info"> <div class="playlist-item-info">
<div class="playlist-item-name">${esc(a.filename || a.widget_name || 'Unknown')}</div> <div class="playlist-item-name">${esc(a.filename || a.widget_name || t('common.unknown'))}</div>
<div class="playlist-item-meta"> <div class="playlist-item-meta">
${a.widget_id && !a.content_id ? `Widget (${a.widget_type || 'custom'})` : a.mime_type === 'video/youtube' ? 'YouTube' : a.mime_type?.startsWith('video/') ? 'Video' : 'Image'} ${a.widget_id && !a.content_id ? t('device.pl_item.widget_with_type', { type: a.widget_type || 'custom' }) : a.mime_type === 'video/youtube' ? t('device.pl_item.youtube') : a.mime_type?.startsWith('video/') ? t('device.pl_item.video') : t('device.pl_item.image')}
${a.zone_id ? ` &middot; <span style="color:var(--accent)">Zone: ${a.zone_id.slice(0,8)}</span>` : ''} ${a.zone_id ? ` &middot; <span style="color:var(--accent)">${t('device.pl_item.zone_label', { id: a.zone_id.slice(0,8) })}</span>` : ''}
${a.content_duration ? ` &middot; ${Math.floor(a.content_duration / 60)}:${String(Math.floor(a.content_duration % 60)).padStart(2, '0')}` : ''} ${a.content_duration ? ` &middot; ${Math.floor(a.content_duration / 60)}:${String(Math.floor(a.content_duration % 60)).padStart(2, '0')}` : ''}
${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` &middot; ${a.duration_sec}s` : ''} ${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` &middot; ${a.duration_sec}s` : ''}
${a.schedule_start ? ` &middot; ${a.schedule_start}-${a.schedule_end}` : ''} ${a.schedule_start ? ` &middot; ${a.schedule_start}-${a.schedule_end}` : ''}
@ -504,15 +505,15 @@ function renderPlaylist(assignments) {
</div> </div>
<div class="playlist-item-actions" style="display:flex;align-items:center;gap:4px"> <div class="playlist-item-actions" style="display:flex;align-items:center;gap:4px">
<select class="input zone-select" data-assignment-id="${a.id}" style="width:100px;font-size:11px;padding:2px 4px;background:var(--bg-input);display:none"> <select class="input zone-select" data-assignment-id="${a.id}" style="width:100px;font-size:11px;padding:2px 4px;background:var(--bg-input);display:none">
<option value="">No zone</option> <option value="">${t('device.pl_item.no_zone')}</option>
</select> </select>
<button class="btn-icon mute-toggle" data-mute-assignment="${a.id}" data-muted="${a.muted ? '1' : '0'}" title="${a.muted ? 'Unmute' : 'Mute'}" style="color:${a.muted ? 'var(--danger)' : 'var(--text-muted)'}"> <button class="btn-icon mute-toggle" data-mute-assignment="${a.id}" data-muted="${a.muted ? '1' : '0'}" title="${a.muted ? t('device.pl_item.unmute') : t('device.pl_item.mute')}" style="color:${a.muted ? 'var(--danger)' : 'var(--text-muted)'}">
${a.muted ${a.muted
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>' ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>'
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>' : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>'
} }
</button> </button>
<button class="btn-icon" title="Remove" data-remove-assignment="${a.id}"> <button class="btn-icon" title="${t('device.pl_item.remove')}" data-remove-assignment="${a.id}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" 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"/> <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> </svg>
@ -537,18 +538,18 @@ async function setupActions(device) {
// Screenshot button // Screenshot button
document.getElementById('screenshotBtn')?.addEventListener('click', () => { document.getElementById('screenshotBtn')?.addEventListener('click', () => {
requestScreenshot(device.id); requestScreenshot(device.id);
showToast('Screenshot requested', 'info'); showToast(t('device.toast.screenshot_requested'), 'info');
}); });
// Rename // Rename
document.getElementById('renameBtn')?.addEventListener('click', async () => { document.getElementById('renameBtn')?.addEventListener('click', async () => {
const name = prompt('Enter new name:', device.name); const name = prompt(t('device.prompt_new_name'), device.name);
if (name && name !== device.name) { if (name && name !== device.name) {
try { try {
await api.updateDevice(device.id, { name }); await api.updateDevice(device.id, { name });
document.getElementById('deviceName').textContent = name; document.getElementById('deviceName').textContent = name;
currentDevice.name = name; currentDevice.name = name;
showToast('Display renamed', 'success'); showToast(t('device.toast.renamed'), 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -577,7 +578,7 @@ async function setupActions(device) {
orientation: document.getElementById('deviceOrientation').value, orientation: document.getElementById('deviceOrientation').value,
default_content_id: document.getElementById('deviceDefaultContent').value || null, default_content_id: document.getElementById('deviceDefaultContent').value || null,
}); });
showToast('Settings saved', 'success'); showToast(t('device.toast.settings_saved'), 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -589,13 +590,13 @@ async function setupActions(device) {
devicePublishBtn.addEventListener('click', async () => { devicePublishBtn.addEventListener('click', async () => {
try { try {
devicePublishBtn.disabled = true; devicePublishBtn.disabled = true;
devicePublishBtn.textContent = 'Publishing...'; devicePublishBtn.textContent = t('device.draft.publishing');
await api.publishPlaylist(device.playlist_id); await api.publishPlaylist(device.playlist_id);
showToast('Playlist published — devices updated'); showToast(t('device.toast.published'));
loadDevice(device.id, 'playlist'); loadDevice(device.id, 'playlist');
} catch (err) { } catch (err) {
devicePublishBtn.disabled = false; devicePublishBtn.disabled = false;
devicePublishBtn.textContent = 'Publish'; devicePublishBtn.textContent = t('device.draft.publish');
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
}); });
@ -603,10 +604,10 @@ async function setupActions(device) {
const deviceDiscardBtn = document.getElementById('deviceDiscardDraftBtn'); const deviceDiscardBtn = document.getElementById('deviceDiscardDraftBtn');
if (deviceDiscardBtn && device.playlist_id) { if (deviceDiscardBtn && device.playlist_id) {
deviceDiscardBtn.addEventListener('click', async () => { deviceDiscardBtn.addEventListener('click', async () => {
if (!confirm('Discard all unpublished changes and revert to the last published version?')) return; if (!confirm(t('device.confirm_discard_draft'))) return;
try { try {
await api.discardPlaylistDraft(device.playlist_id); await api.discardPlaylistDraft(device.playlist_id);
showToast('Draft changes discarded'); showToast(t('device.toast.draft_discarded'));
loadDevice(device.id, 'playlist'); loadDevice(device.id, 'playlist');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -621,7 +622,9 @@ async function setupActions(device) {
playlists.forEach(p => { playlists.forEach(p => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = p.id; opt.value = p.id;
opt.textContent = `${p.name}${p.is_auto_generated ? ' (auto)' : ''}${p.item_count} items`; opt.textContent = p.is_auto_generated
? t('device.playlist_picker.with_auto', { name: p.name, n: p.item_count })
: t('device.playlist_picker.with_count', { name: p.name, n: p.item_count });
if (p.id === device.playlist_id) opt.selected = true; if (p.id === device.playlist_id) opt.selected = true;
playlistPicker.appendChild(opt); playlistPicker.appendChild(opt);
}); });
@ -638,7 +641,7 @@ async function setupActions(device) {
const assignments = await api.getAssignments(device.id); const assignments = await api.getAssignments(device.id);
document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments); document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments);
attachRemoveHandlers(device); attachRemoveHandlers(device);
showToast('Playlist changed'); showToast(t('device.toast.playlist_changed'));
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -650,12 +653,12 @@ async function setupActions(device) {
try { try {
const devices = await api.getDevices(); const devices = await api.getDevices();
const others = devices.filter(d => d.id !== device.id); const others = devices.filter(d => d.id !== device.id);
if (!others.length) { showToast('No other devices to copy to', 'info'); return; } if (!others.length) { showToast(t('device.copy.no_other_devices'), 'info'); return; }
const targetId = prompt('Copy playlist to which device?\n\n' + others.map((d, i) => `${i + 1}. ${d.name}`).join('\n') + '\n\nEnter number:'); const targetId = prompt(t('device.copy.prompt', { list: others.map((d, i) => `${i + 1}. ${d.name}`).join('\n') }));
if (!targetId) return; if (!targetId) return;
const target = others[parseInt(targetId) - 1]; const target = others[parseInt(targetId) - 1];
if (!target) { showToast('Invalid selection', 'error'); return; } if (!target) { showToast(t('device.copy.invalid_selection'), 'error'); return; }
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch(`/api/assignments/device/${device.id}/copy-to/${target.id}`, { const res = await fetch(`/api/assignments/device/${device.id}/copy-to/${target.id}`, {
@ -664,7 +667,7 @@ async function setupActions(device) {
body: JSON.stringify({ replace: false }) body: JSON.stringify({ replace: false })
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) showToast(`Copied ${data.copied} items to ${target.name}`, 'success'); if (res.ok) showToast(t('device.copy.toast', { n: data.copied, device: target.name }), 'success');
else showToast(data.error, 'error'); else showToast(data.error, 'error');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}); });
@ -676,27 +679,27 @@ async function setupActions(device) {
deleteBtn?.addEventListener('click', async () => { deleteBtn?.addEventListener('click', async () => {
if (deleteConfirming) { if (deleteConfirming) {
try { try {
deleteBtn.textContent = 'Removing...'; deleteBtn.textContent = t('device.toast.removing');
deleteBtn.disabled = true; deleteBtn.disabled = true;
await api.deleteDevice(device.id); await api.deleteDevice(device.id);
showToast('Display removed', 'success'); showToast(t('device.toast.removed'), 'success');
window.location.hash = '/'; window.location.hash = '/';
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
deleteBtn.textContent = 'Remove'; deleteBtn.textContent = t('device.remove');
deleteBtn.disabled = false; deleteBtn.disabled = false;
deleteConfirming = false; deleteConfirming = false;
} }
return; return;
} }
deleteConfirming = true; deleteConfirming = true;
deleteBtn.textContent = 'Click again to confirm'; deleteBtn.textContent = t('device.click_to_confirm');
deleteBtn.style.background = 'var(--danger)'; deleteBtn.style.background = 'var(--danger)';
deleteBtn.style.color = 'white'; deleteBtn.style.color = 'white';
clearTimeout(deleteTimeout); clearTimeout(deleteTimeout);
deleteTimeout = setTimeout(() => { deleteTimeout = setTimeout(() => {
deleteConfirming = false; deleteConfirming = false;
deleteBtn.textContent = 'Remove'; deleteBtn.textContent = t('device.remove');
deleteBtn.style.background = ''; deleteBtn.style.background = '';
deleteBtn.style.color = ''; deleteBtn.style.color = '';
}, 3000); }, 3000);
@ -709,17 +712,17 @@ async function setupActions(device) {
rebootBtn?.addEventListener('click', () => { rebootBtn?.addEventListener('click', () => {
if (rebootConfirming) { if (rebootConfirming) {
sendCommand(device.id, 'reboot', {}); sendCommand(device.id, 'reboot', {});
showToast('Reboot command sent', 'info'); showToast(t('device.toast.reboot_sent'), 'info');
rebootConfirming = false; rebootConfirming = false;
rebootBtn.textContent = 'Reboot Device'; rebootBtn.textContent = t('device.ctl.reboot_device');
return; return;
} }
rebootConfirming = true; rebootConfirming = true;
rebootBtn.textContent = 'Click again to confirm'; rebootBtn.textContent = t('device.click_to_confirm');
clearTimeout(rebootTimeout); clearTimeout(rebootTimeout);
rebootTimeout = setTimeout(() => { rebootTimeout = setTimeout(() => {
rebootConfirming = false; rebootConfirming = false;
rebootBtn.textContent = 'Reboot Device'; rebootBtn.textContent = t('device.ctl.reboot_device');
}, 3000); }, 3000);
}); });
@ -730,19 +733,19 @@ async function setupActions(device) {
shutdownBtn?.addEventListener('click', () => { shutdownBtn?.addEventListener('click', () => {
if (shutdownConfirming) { if (shutdownConfirming) {
sendCommand(device.id, 'shutdown', {}); sendCommand(device.id, 'shutdown', {});
showToast('Shutdown command sent', 'info'); showToast(t('device.toast.shutdown_sent'), 'info');
shutdownConfirming = false; shutdownConfirming = false;
shutdownBtn.textContent = 'Shutdown'; shutdownBtn.textContent = t('device.ctl.shutdown');
return; return;
} }
shutdownConfirming = true; shutdownConfirming = true;
shutdownBtn.textContent = 'Click again to confirm'; shutdownBtn.textContent = t('device.click_to_confirm');
shutdownBtn.style.background = 'var(--danger)'; shutdownBtn.style.background = 'var(--danger)';
shutdownBtn.style.color = 'white'; shutdownBtn.style.color = 'white';
clearTimeout(shutdownTimeout); clearTimeout(shutdownTimeout);
shutdownTimeout = setTimeout(() => { shutdownTimeout = setTimeout(() => {
shutdownConfirming = false; shutdownConfirming = false;
shutdownBtn.textContent = 'Shutdown'; shutdownBtn.textContent = t('device.ctl.shutdown');
shutdownBtn.style.background = ''; shutdownBtn.style.background = '';
shutdownBtn.style.color = ''; shutdownBtn.style.color = '';
}, 3000); }, 3000);
@ -751,25 +754,25 @@ async function setupActions(device) {
// Screen Off // Screen Off
document.getElementById('screenOffBtn')?.addEventListener('click', () => { document.getElementById('screenOffBtn')?.addEventListener('click', () => {
sendCommand(device.id, 'screen_off', {}); sendCommand(device.id, 'screen_off', {});
showToast('Screen off command sent', 'info'); showToast(t('device.toast.screen_off_sent'), 'info');
}); });
// Screen On // Screen On
document.getElementById('screenOnBtn')?.addEventListener('click', () => { document.getElementById('screenOnBtn')?.addEventListener('click', () => {
sendCommand(device.id, 'screen_on', {}); sendCommand(device.id, 'screen_on', {});
showToast('Screen on command sent', 'info'); showToast(t('device.toast.screen_on_sent'), 'info');
}); });
// Launch Player // Launch Player
document.getElementById('launchAppBtn')?.addEventListener('click', () => { document.getElementById('launchAppBtn')?.addEventListener('click', () => {
sendCommand(device.id, 'launch', {}); sendCommand(device.id, 'launch', {});
showToast('Launch command sent', 'info'); showToast(t('device.toast.launch_sent'), 'info');
}); });
// Force Update // Force Update
document.getElementById('forceUpdateBtn')?.addEventListener('click', () => { document.getElementById('forceUpdateBtn')?.addEventListener('click', () => {
sendCommand(device.id, 'update', {}); sendCommand(device.id, 'update', {});
showToast('Update check triggered', 'info'); showToast(t('device.toast.update_triggered'), 'info');
}); });
} }
@ -787,7 +790,7 @@ function setupRemote(device) {
startBtn.style.display = 'none'; startBtn.style.display = 'none';
stopBtn.style.display = ''; stopBtn.style.display = '';
overlay.style.display = 'none'; overlay.style.display = 'none';
showToast('Remote session started', 'info'); showToast(t('device.toast.remote_started'), 'info');
}); });
stopBtn?.addEventListener('click', () => { stopBtn?.addEventListener('click', () => {
@ -828,7 +831,7 @@ async function setupPlaylistActions(device) {
layouts.filter(l => !l.is_template).forEach(l => { layouts.filter(l => !l.is_template).forEach(l => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = l.id; opt.value = l.id;
opt.textContent = `${l.name} (${l.zones?.length || 0} zones)`; opt.textContent = t('device.layout.zones_count', { name: l.name, n: l.zones?.length || 0 });
if (device.layout_id === l.id) opt.selected = true; if (device.layout_id === l.id) opt.selected = true;
select.appendChild(opt); select.appendChild(opt);
}); });
@ -836,7 +839,7 @@ async function setupPlaylistActions(device) {
layouts.filter(l => l.is_template).forEach(l => { layouts.filter(l => l.is_template).forEach(l => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = l.id; opt.value = l.id;
opt.textContent = `[Template] ${l.name} (${l.zones?.length || 0} zones)`; opt.textContent = t('device.layout.template_zones_count', { name: l.name, n: l.zones?.length || 0 });
if (device.layout_id === l.id) opt.selected = true; if (device.layout_id === l.id) opt.selected = true;
select.appendChild(opt); select.appendChild(opt);
}); });
@ -854,7 +857,7 @@ async function setupPlaylistActions(device) {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ layout_id: layoutId || null }) body: JSON.stringify({ layout_id: layoutId || null })
}); });
showToast(layoutId ? 'Layout applied' : 'Switched to fullscreen', 'success'); showToast(layoutId ? t('device.toast.layout_applied') : t('device.toast.switched_to_fullscreen'), 'success');
// Reload the device page to show updated zone selectors, stay on playlist tab // Reload the device page to show updated zone selectors, stay on playlist tab
loadDevice(device.id, 'playlist'); loadDevice(device.id, 'playlist');
} catch (err) { } catch (err) {
@ -884,7 +887,7 @@ async function setupPlaylistActions(device) {
} }
if (!content.length && !widgets.length && !kioskPages.length) { if (!content.length && !widgets.length && !kioskPages.length) {
showToast('No content, widgets, or kiosk pages yet. Create something first!', 'error'); showToast(t('device.assign.empty_all'), 'error');
return; return;
} }
@ -893,7 +896,7 @@ async function setupPlaylistActions(device) {
modal.innerHTML = ` modal.innerHTML = `
<div class="modal" style="max-width:650px;width:95vw"> <div class="modal" style="max-width:650px;width:95vw">
<div class="modal-header"> <div class="modal-header">
<h3>Add to Playlist</h3> <h3>${t('device.assign.modal_title')}</h3>
<button class="btn-icon" id="closeAssignModal"> <button class="btn-icon" id="closeAssignModal">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
@ -903,22 +906,22 @@ async function setupPlaylistActions(device) {
<div class="modal-body"> <div class="modal-body">
${zones.length > 0 ? ` ${zones.length > 0 ? `
<div class="form-group"> <div class="form-group">
<label>Zone</label> <label>${t('device.assign.zone_label')}</label>
<select id="assignZone" class="input" style="background:var(--bg-input)"> <select id="assignZone" class="input" style="background:var(--bg-input)">
<option value="">Default (fullscreen)</option> <option value="">${t('device.assign.zone_default')}</option>
${zones.map(z => `<option value="${z.id}">${z.name} (${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}%)</option>`).join('')} ${zones.map(z => `<option value="${z.id}">${z.name} (${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}%)</option>`).join('')}
</select> </select>
</div> </div>
` : ''} ` : ''}
<div class="form-group"> <div class="form-group">
<label>Display Duration (seconds, for images/widgets)</label> <label>${t('device.assign.duration_label')}</label>
<input type="number" id="assignDuration" class="input" value="10" min="1" max="3600"> <input type="number" id="assignDuration" class="input" value="10" min="1" max="3600">
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:12px"> <div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:12px">
<div class="assign-tab active" data-tab="media" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid var(--accent);color:var(--accent)">Media (${content.length})</div> <div class="assign-tab active" data-tab="media" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid var(--accent);color:var(--accent)">${t('device.assign.tab.media', { n: content.length })}</div>
<div class="assign-tab" data-tab="widgets" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;color:var(--text-secondary)">Widgets (${widgets.length})</div> <div class="assign-tab" data-tab="widgets" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;color:var(--text-secondary)">${t('device.assign.tab.widgets', { n: widgets.length })}</div>
<div class="assign-tab" data-tab="kiosk" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;color:var(--text-secondary)">Kiosk (${kioskPages.length})</div> <div class="assign-tab" data-tab="kiosk" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;color:var(--text-secondary)">${t('device.assign.tab.kiosk', { n: kioskPages.length })}</div>
</div> </div>
<!-- Media grid --> <!-- Media grid -->
<div class="assign-content-grid" id="assignMedia"> <div class="assign-content-grid" id="assignMedia">
@ -936,7 +939,7 @@ async function setupPlaylistActions(device) {
} }
<div class="assign-content-item-name">${esc(c.filename)}</div> <div class="assign-content-item-name">${esc(c.filename)}</div>
</div> </div>
`).join('') || '<p style="color:var(--text-muted);padding:16px;text-align:center">No media uploaded yet</p>'} `).join('') || `<p style="color:var(--text-muted);padding:16px;text-align:center">${t('device.assign.no_media')}</p>`}
</div> </div>
<!-- Widgets grid --> <!-- Widgets grid -->
<div class="assign-content-grid" id="assignWidgets" style="display:none"> <div class="assign-content-grid" id="assignWidgets" style="display:none">
@ -949,7 +952,7 @@ async function setupPlaylistActions(device) {
</div> </div>
<div class="assign-content-item-name">${w.name}</div> <div class="assign-content-item-name">${w.name}</div>
</div>`; </div>`;
}).join('') || '<p style="color:var(--text-muted);padding:16px;text-align:center">No widgets created yet. <a href="#/widgets" style="color:var(--accent)">Create one</a></p>'} }).join('') || `<p style="color:var(--text-muted);padding:16px;text-align:center">${t('device.assign.no_widgets')} <a href="#/widgets" style="color:var(--accent)">${t('device.assign.create_one')}</a></p>`}
</div> </div>
<!-- Kiosk grid --> <!-- Kiosk grid -->
<div class="assign-content-grid" id="assignKiosk" style="display:none"> <div class="assign-content-grid" id="assignKiosk" style="display:none">
@ -958,12 +961,12 @@ async function setupPlaylistActions(device) {
<div style="aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:32px">&#128433;</div> <div style="aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:32px">&#128433;</div>
<div class="assign-content-item-name">${k.name}</div> <div class="assign-content-item-name">${k.name}</div>
</div> </div>
`).join('') || '<p style="color:var(--text-muted);padding:16px;text-align:center">No kiosk pages yet. <a href="#/kiosk" style="color:var(--accent)">Create one</a></p>'} `).join('') || `<p style="color:var(--text-muted);padding:16px;text-align:center">${t('device.assign.no_kiosk')} <a href="#/kiosk" style="color:var(--accent)">${t('device.assign.create_one')}</a></p>`}
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" id="cancelAssign">Cancel</button> <button class="btn btn-secondary" id="cancelAssign">${t('common.cancel')}</button>
<button class="btn btn-primary" id="confirmAssign">Add Selected</button> <button class="btn btn-primary" id="confirmAssign">${t('device.assign.add_selected')}</button>
</div> </div>
</div> </div>
`; `;
@ -995,7 +998,7 @@ async function setupPlaylistActions(device) {
modal.querySelector('#cancelAssign').onclick = () => modal.remove(); modal.querySelector('#cancelAssign').onclick = () => modal.remove();
modal.querySelector('#confirmAssign').onclick = async () => { modal.querySelector('#confirmAssign').onclick = async () => {
if (!selectedId) { if (!selectedId) {
showToast('Select something first', 'error'); showToast(t('device.assign.select_first'), 'error');
return; return;
} }
const duration = parseInt(modal.querySelector('#assignDuration').value) || 10; const duration = parseInt(modal.querySelector('#assignDuration').value) || 10;
@ -1011,13 +1014,13 @@ async function setupPlaylistActions(device) {
const wRes = await fetch('/api/widgets', { const wRes = await fetch('/api/widgets', {
method: 'POST', method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' }, headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ widget_type: 'webpage', name: `Kiosk: ${kioskPages.find(k => k.id === selectedId)?.name || 'Page'}`, config: { url: `${serverUrl}/api/kiosk/${selectedId}/render` } }) body: JSON.stringify({ widget_type: 'webpage', name: t('device.assign.kiosk_widget_name', { name: kioskPages.find(k => k.id === selectedId)?.name || 'Page' }), config: { url: `${serverUrl}/api/kiosk/${selectedId}/render` } })
}); });
const widget = await wRes.json(); const widget = await wRes.json();
await api.addAssignment(device.id, { widget_id: widget.id, duration_sec: 0 }); await api.addAssignment(device.id, { widget_id: widget.id, duration_sec: 0 });
} }
modal.remove(); modal.remove();
showToast('Added to playlist', 'success'); showToast(t('device.toast.added_to_playlist'), 'success');
loadDevice(device.id, 'playlist'); loadDevice(device.id, 'playlist');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -1060,7 +1063,7 @@ function attachRemoveHandlers(device) {
select.onchange = async () => { select.onchange = async () => {
try { try {
await api.updateAssignment(assignmentId, { zone_id: select.value || null }); await api.updateAssignment(assignmentId, { zone_id: select.value || null });
showToast(`Zone updated`, 'success'); showToast(t('device.toast.zone_updated'), 'success');
loadDevice(device.id, 'playlist'); loadDevice(device.id, 'playlist');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };
@ -1076,7 +1079,7 @@ function attachRemoveHandlers(device) {
const currentlyMuted = btn.dataset.muted === '1'; const currentlyMuted = btn.dataset.muted === '1';
try { try {
await api.updateAssignment(id, { muted: !currentlyMuted }); await api.updateAssignment(id, { muted: !currentlyMuted });
showToast(currentlyMuted ? 'Unmuted' : 'Muted', 'success'); showToast(currentlyMuted ? t('device.toast.unmuted') : t('device.toast.muted'), 'success');
loadDevice(device.id, 'playlist'); loadDevice(device.id, 'playlist');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}); });
@ -1089,7 +1092,7 @@ function attachRemoveHandlers(device) {
const id = btn.dataset.removeAssignment; const id = btn.dataset.removeAssignment;
try { try {
await api.deleteAssignment(id); await api.deleteAssignment(id);
showToast('Content removed from playlist', 'success'); showToast(t('device.toast.removed_from_playlist'), 'success');
loadDevice(device.id, 'playlist'); loadDevice(device.id, 'playlist');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -1140,7 +1143,7 @@ function attachRemoveHandlers(device) {
try { try {
await api.reorderAssignments(device.id, newOrder); await api.reorderAssignments(device.id, newOrder);
showToast('Playlist reordered', 'success'); showToast(t('device.toast.playlist_reordered'), 'success');
loadDevice(device.id, 'playlist'); loadDevice(device.id, 'playlist');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -1193,7 +1196,11 @@ function renderUptimeTimeline(uptimeData, statusLog = []) {
const knownSlots = slotStatus.filter(s => s !== 'unknown').length; const knownSlots = slotStatus.filter(s => s !== 'unknown').length;
const onlineSlots = slotStatus.filter(s => s === 'online').length; const onlineSlots = slotStatus.filter(s => s === 'online').length;
const uptimePct = knownSlots > 0 ? Math.round((onlineSlots / knownSlots) * 100) : 0; const uptimePct = knownSlots > 0 ? Math.round((onlineSlots / knownSlots) * 100) : 0;
if (percentEl) percentEl.textContent = `${uptimePct}% uptime (${knownSlots > 0 ? knownSlots * 15 + 'min tracked' : 'no data'})`; if (percentEl) {
percentEl.textContent = knownSlots > 0
? t('device.timeline.uptime_pct_tracked', { pct: uptimePct, n: knownSlots * 15 })
: t('device.timeline.uptime_pct_no_data', { pct: uptimePct });
}
// Color map // Color map
const colors = { const colors = {
@ -1207,7 +1214,7 @@ function renderUptimeTimeline(uptimeData, statusLog = []) {
timeline.innerHTML = slotStatus.map((status, i) => { timeline.innerHTML = slotStatus.map((status, i) => {
const time = new Date((dayAgo + i * slotDuration) * 1000); const time = new Date((dayAgo + i * slotDuration) * 1000);
const label = time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const label = time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const statusLabel = status === 'unknown' ? 'No data' : status.charAt(0).toUpperCase() + status.slice(1); const statusLabel = status === 'unknown' ? t('device.timeline.no_data') : status === 'online' ? t('device.timeline.online') : t('device.timeline.offline');
return `<div style="flex:1;background:${colors[status]};opacity:${opacities[status]}" title="${label} - ${statusLabel}"></div>`; return `<div style="flex:1;background:${colors[status]};opacity:${opacities[status]}" title="${label} - ${statusLabel}"></div>`;
}).join(''); }).join('');
} }
@ -1218,11 +1225,11 @@ function updateTelemetryDisplay(telemetry) {
if (el) el.textContent = val; if (el) el.textContent = val;
}; };
if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%'); if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%');
if (telemetry.storage_free_mb) update('telStorage', formatBytes(telemetry.storage_free_mb) + ' free'); if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) }));
if (telemetry.wifi_ssid) update('telWifi', telemetry.wifi_ssid); if (telemetry.wifi_ssid) update('telWifi', telemetry.wifi_ssid);
if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm'); if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm');
if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds)); if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds));
if (telemetry.ram_free_mb) update('telRam', formatBytes(telemetry.ram_free_mb) + ' free'); if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) }));
if (telemetry.cpu_usage != null) update('telCpu', telemetry.cpu_usage.toFixed(1) + '%'); if (telemetry.cpu_usage != null) update('telCpu', telemetry.cpu_usage.toFixed(1) + '%');
} }

View file

@ -1,6 +1,6 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { getLanguage, setLanguage, getAvailableLanguages } from '../i18n.js'; import { getLanguage, setLanguage, getAvailableLanguages, t, tn } from '../i18n.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
import { resetBranding } from '../branding.js'; import { resetBranding } from '../branding.js';
@ -17,118 +17,115 @@ export async function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>Settings</h1> <h1>${t('settings.title')}</h1>
<div class="subtitle">Server configuration and setup information</div> <div class="subtitle">${t('settings.subtitle')}</div>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Account</h3> <h3>${t('settings.account')}</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
<div class="form-group"><label>Email</label><input type="email" class="input" value="${esc(user.email || '')}" disabled></div> <div class="form-group"><label>${t('auth.email')}</label><input type="email" class="input" value="${esc(user.email || '')}" disabled></div>
<div class="form-group"><label>Name</label><input type="text" id="acctName" class="input" value="${esc(user.name || '')}"></div> <div class="form-group"><label>${t('auth.name')}</label><input type="text" id="acctName" class="input" value="${esc(user.name || '')}"></div>
</div> </div>
<button class="btn btn-secondary btn-sm" id="saveAcctBtn">Save Profile</button> <button class="btn btn-secondary btn-sm" id="saveAcctBtn">${t('settings.save_profile')}</button>
${user.auth_provider === 'local' ? ` ${user.auth_provider === 'local' ? `
<div style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px"> <div style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px">
<h4 style="font-size:14px;margin-bottom:8px">Change Password</h4> <h4 style="font-size:14px;margin-bottom:8px">${t('settings.change_password')}</h4>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">Must be at least 8 characters.</p> <p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('settings.password_min_8')}</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
<div class="form-group"><label>Current Password</label><input type="password" id="acctCurrentPw" class="input" autocomplete="current-password"></div> <div class="form-group"><label>${t('settings.current_password')}</label><input type="password" id="acctCurrentPw" class="input" autocomplete="current-password"></div>
<div class="form-group"><label>New Password</label><input type="password" id="acctNewPw" class="input" autocomplete="new-password"></div> <div class="form-group"><label>${t('settings.new_password')}</label><input type="password" id="acctNewPw" class="input" autocomplete="new-password"></div>
<div class="form-group"><label>Confirm New Password</label><input type="password" id="acctConfirmPw" class="input" autocomplete="new-password"></div> <div class="form-group"><label>${t('settings.confirm_new_password')}</label><input type="password" id="acctConfirmPw" class="input" autocomplete="new-password"></div>
</div> </div>
<button class="btn btn-primary btn-sm" id="changePwBtn">Change Password</button> <button class="btn btn-primary btn-sm" id="changePwBtn">${t('settings.change_password')}</button>
</div> </div>
` : ` ` : `
<p style="color:var(--text-muted);font-size:12px;margin-top:16px">You sign in via <strong>${esc(user.auth_provider || 'SSO')}</strong>. Manage your password there.</p> <p style="color:var(--text-muted);font-size:12px;margin-top:16px">${t('settings.sso_note', { provider: esc(user.auth_provider || 'SSO') })}</p>
`} `}
</div> </div>
${isAdmin ? ` ${isAdmin ? `
<div class="settings-section"> <div class="settings-section">
<h3>License</h3> <h3>${t('settings.license')}</h3>
<div id="licenseSection"><p style="color:var(--text-muted);font-size:13px">MIT License - all features included.</p></div> <div id="licenseSection"><p style="color:var(--text-muted);font-size:13px">${t('settings.license_mit')}</p></div>
</div> </div>
${isSuperAdmin ? '<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">Platform admin tools are in the <a href="#/admin" style="color:var(--accent)">Admin</a> page.</p>' : ''} ${isSuperAdmin ? `<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('settings.platform_admin_link')} <a href="#/admin" style="color:var(--accent)">${t('nav.admin')}</a> ${t('settings.platform_admin_page_suffix')}</p>` : ''}
<div class="settings-section"> <div class="settings-section">
<h3>User Management</h3> <h3>${t('settings.user_management')}</h3>
<div id="userManagement"><p style="color:var(--text-muted)">Loading users...</p></div> <div id="userManagement"><p style="color:var(--text-muted)">${t('settings.loading_users')}</p></div>
</div> </div>
<div class="settings-section" id="whiteLabelSection"> <div class="settings-section" id="whiteLabelSection">
<h3>White Label / Branding</h3> <h3>${t('settings.white_label')}</h3>
<div id="whiteLabelForm"> <div id="whiteLabelForm">
<p style="color:var(--text-muted);font-size:12px;margin-bottom:16px">Customize the look of your dashboard and player for your clients.</p> <p style="color:var(--text-muted);font-size:12px;margin-bottom:16px">${t('settings.white_label_desc')}</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group"><label>Brand Name</label><input type="text" id="wlBrandName" class="input" placeholder="ScreenTinker"></div> <div class="form-group"><label>${t('settings.brand_name')}</label><input type="text" id="wlBrandName" class="input" placeholder="ScreenTinker"></div>
<div class="form-group"><label>Logo URL</label><input type="text" id="wlLogoUrl" class="input" placeholder="https://..."></div> <div class="form-group"><label>${t('settings.logo_url')}</label><input type="text" id="wlLogoUrl" class="input" placeholder="https://..."></div>
<div class="form-group"><label>Primary Color</label><input type="color" id="wlPrimaryColor" value="#3B82F6" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div> <div class="form-group"><label>${t('settings.primary_color')}</label><input type="color" id="wlPrimaryColor" value="#3B82F6" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
<div class="form-group"><label>Background Color</label><input type="color" id="wlBgColor" value="#111827" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div> <div class="form-group"><label>${t('settings.bg_color')}</label><input type="color" id="wlBgColor" value="#111827" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
<div class="form-group"><label>Custom Domain</label><input type="text" id="wlDomain" class="input" placeholder="signage.yourcompany.com"></div> <div class="form-group"><label>${t('settings.custom_domain')}</label><input type="text" id="wlDomain" class="input" placeholder="signage.yourcompany.com"></div>
<div class="form-group"><label>Favicon URL</label><input type="text" id="wlFavicon" class="input" placeholder="https://..."></div> <div class="form-group"><label>${t('settings.favicon_url')}</label><input type="text" id="wlFavicon" class="input" placeholder="https://..."></div>
</div> </div>
<div class="form-group"><label>Custom CSS (optional)</label><textarea id="wlCustomCss" class="input" rows="3" style="font-family:monospace;font-size:12px" placeholder=":root { --accent: #ff6600; }"></textarea></div> <div class="form-group"><label>${t('settings.custom_css')}</label><textarea id="wlCustomCss" class="input" rows="3" style="font-family:monospace;font-size:12px" placeholder=":root { --accent: #ff6600; }"></textarea></div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px"><input type="checkbox" id="wlHideBranding"> Hide "ScreenTinker" branding</label></div> <div class="form-group"><label style="display:flex;align-items:center;gap:8px"><input type="checkbox" id="wlHideBranding"> ${t('settings.hide_branding')}</label></div>
<button class="btn btn-primary btn-sm" id="saveWhiteLabelBtn">Save Branding</button> <button class="btn btn-primary btn-sm" id="saveWhiteLabelBtn">${t('settings.save_branding')}</button>
<button class="btn btn-secondary btn-sm" id="previewWhiteLabelBtn" style="margin-left:8px">Preview</button> <button class="btn btn-secondary btn-sm" id="previewWhiteLabelBtn" style="margin-left:8px">${t('settings.preview')}</button>
</div> </div>
</div> </div>
` : ''} ` : ''}
<div class="settings-section"> <div class="settings-section">
<h3>Server Information</h3> <h3>${t('settings.server_info')}</h3>
<div class="info-grid"> <div class="info-grid">
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Server URL</div> <div class="info-card-label">${t('settings.server_url')}</div>
<div class="info-card-value small">${serverUrl}</div> <div class="info-card-value small">${serverUrl}</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">Use this URL when setting up the Android app</p> <p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('settings.server_url_hint')}</p>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">API Endpoint</div> <div class="info-card-label">${t('settings.api_endpoint')}</div>
<div class="info-card-value small">${serverUrl}/api</div> <div class="info-card-value small">${serverUrl}/api</div>
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Setup Guide</h3> <h3>${t('settings.setup_guide')}</h3>
<div style="color:var(--text-secondary);font-size:13px;line-height:1.8"> <div style="color:var(--text-secondary);font-size:13px;line-height:1.8">
<ol style="padding-left:20px;list-style:decimal"> <ol style="padding-left:20px;list-style:decimal">
<li>Install the <strong>ScreenTinker</strong> APK on your Apolosign portable TV via sideloading</li> <li>${t('settings.setup_step_1')}</li>
<li>Open the app and enter this server URL: <code style="background:var(--bg-input);padding:2px 6px;border-radius:4px">${serverUrl}</code></li> <li>${t('settings.setup_step_2_prefix')} <code style="background:var(--bg-input);padding:2px 6px;border-radius:4px">${serverUrl}</code></li>
<li>The app will display a <strong>6-digit pairing code</strong></li> <li>${t('settings.setup_step_3')}</li>
<li>Click <strong>"Add Display"</strong> on the dashboard and enter the pairing code</li> <li>${t('settings.setup_step_4')}</li>
<li>Upload content in the <strong>Content Library</strong></li> <li>${t('settings.setup_step_5')}</li>
<li>Assign content to the display's <strong>Playlist</strong></li> <li>${t('settings.setup_step_6')}</li>
</ol> </ol>
</div> </div>
</div> </div>
${isAdmin ? `
` : ''}
<div class="settings-section"> <div class="settings-section">
<h3>Your Data</h3> <h3>${t('settings.your_data')}</h3>
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">Export or import your devices, content, layouts, schedules, and all settings. Use this to migrate between cloud and self-hosted instances.</p> <p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">${t('settings.your_data_desc')}</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="exportDataBtn"> <button class="btn btn-secondary btn-sm" id="exportDataBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg> </svg>
Export My Data ${t('settings.export_my_data')}
</button> </button>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);cursor:pointer"> <label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);cursor:pointer">
<input type="checkbox" id="exportIncludeFiles"> Include media files (ZIP) <input type="checkbox" id="exportIncludeFiles"> ${t('settings.include_media_zip')}
</label> </label>
<button class="btn btn-secondary btn-sm" id="importDataBtn"> <button class="btn btn-secondary btn-sm" id="importDataBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg> </svg>
Import Data ${t('settings.import_data')}
</button> </button>
<input type="file" id="importFileInput" accept=".json,.zip" style="display:none"> <input type="file" id="importFileInput" accept=".json,.zip" style="display:none">
</div> </div>
@ -136,23 +133,23 @@ export async function render(container) {
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Language</h3> <h3>${t('settings.language')}</h3>
<select id="langSelect" class="input" style="width:200px;background:var(--bg-input)"> <select id="langSelect" class="input" style="width:200px;background:var(--bg-input)">
${getAvailableLanguages().map(l => `<option value="${l.code}" ${l.code === getLanguage() ? 'selected' : ''}>${l.name}</option>`).join('')} ${getAvailableLanguages().map(l => `<option value="${l.code}" ${l.code === getLanguage() ? 'selected' : ''}>${l.name}</option>`).join('')}
</select> </select>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>About</h3> <h3>${t('settings.about')}</h3>
<div style="color:var(--text-secondary);font-size:13px"> <div style="color:var(--text-secondary);font-size:13px">
<p><strong>ScreenTinker</strong> v1.4.1</p> <p><strong>ScreenTinker</strong> v1.4.1</p>
<p style="margin-top:4px">Digital signage management system.</p> <p style="margin-top:4px">${t('settings.about_tagline')}</p>
<p style="margin-top:12px"> <p style="margin-top:12px">
<a href="/legal/terms.html" target="_blank" style="color:var(--accent);font-size:12px">Terms of Service</a> <a href="/legal/terms.html" target="_blank" style="color:var(--accent);font-size:12px">${t('auth.terms')}</a>
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
<a href="/legal/privacy.html" target="_blank" style="color:var(--accent);font-size:12px">Privacy Policy</a> <a href="/legal/privacy.html" target="_blank" style="color:var(--accent);font-size:12px">${t('auth.privacy')}</a>
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
<a href="/legal/third-party.html" target="_blank" style="color:var(--accent);font-size:12px">Third-Party Licenses</a> <a href="/legal/third-party.html" target="_blank" style="color:var(--accent);font-size:12px">${t('settings.third_party_licenses')}</a>
</p> </p>
</div> </div>
</div> </div>
@ -177,7 +174,7 @@ export async function render(container) {
if (res.ok) { if (res.ok) {
document.getElementById('supportTokenOutput').value = data.token; document.getElementById('supportTokenOutput').value = data.token;
document.getElementById('supportTokenResult').style.display = 'block'; document.getElementById('supportTokenResult').style.display = 'block';
showToast(`Support token generated (valid ${hours}h)`, 'success'); showToast(t('settings.toast.support_token_generated', { hours }), 'success');
} else showToast(data.error, 'error'); } else showToast(data.error, 'error');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}); });
@ -204,35 +201,35 @@ export async function render(container) {
statusEl.style.background = 'var(--bg-secondary)'; statusEl.style.background = 'var(--bg-secondary)';
statusEl.style.border = '1px solid var(--border)'; statusEl.style.border = '1px solid var(--border)';
statusEl.style.color = 'var(--text-secondary)'; statusEl.style.color = 'var(--text-secondary)';
statusEl.textContent = 'Reading file...'; statusEl.textContent = t('settings.import.reading_file');
try { try {
let data; let data;
if (isZip) { if (isZip) {
// For ZIP, show basic info and skip preview parsing // For ZIP, show basic info and skip preview parsing
data = { format: 'screentinker-export-v1', _isZip: true }; data = { format: 'screentinker-export-v1', _isZip: true };
statusEl.innerHTML = `ZIP export detected: <strong>${esc(file.name)}</strong> (${(file.size / 1048576).toFixed(1)} MB)<br>Contains data + media files.<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`; statusEl.innerHTML = `${t('settings.import.zip_detected', { name: esc(file.name), size: (file.size / 1048576).toFixed(1) })}<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">${t('settings.import.confirm')}</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">${t('common.cancel')}</button>`;
} else { } else {
const text = await file.text(); const text = await file.text();
data = JSON.parse(text); data = JSON.parse(text);
if (!data.format || !data.format.startsWith('screentinker-export')) { if (!data.format || !data.format.startsWith('screentinker-export')) {
statusEl.style.color = 'var(--danger)'; statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Invalid file. Must be a ScreenTinker export JSON or ZIP.'; statusEl.textContent = t('settings.import.invalid_file');
return; return;
} }
const summary = [ const summary = [
data.devices?.length ? `${data.devices.length} devices` : null, data.devices?.length ? t('settings.import.summary_devices', { n: data.devices.length }) : null,
data.content?.length ? `${data.content.length} content items` : null, data.content?.length ? t('settings.import.summary_content', { n: data.content.length }) : null,
data.widgets?.length ? `${data.widgets.length} widgets` : null, data.widgets?.length ? t('settings.import.summary_widgets', { n: data.widgets.length }) : null,
data.layouts?.length ? `${data.layouts.length} layouts` : null, data.layouts?.length ? t('settings.import.summary_layouts', { n: data.layouts.length }) : null,
data.schedules?.length ? `${data.schedules.length} schedules` : null, data.schedules?.length ? t('settings.import.summary_schedules', { n: data.schedules.length }) : null,
data.video_walls?.length ? `${data.video_walls.length} video walls` : null, data.video_walls?.length ? t('settings.import.summary_walls', { n: data.video_walls.length }) : null,
data.kiosk_pages?.length ? `${data.kiosk_pages.length} kiosk pages` : null, data.kiosk_pages?.length ? t('settings.import.summary_kiosk', { n: data.kiosk_pages.length }) : null,
].filter(Boolean).join(', '); ].filter(Boolean).join(', ');
statusEl.innerHTML = `Found: ${esc(summary) || 'empty export'}.<br>From: ${esc(data.user?.email) || 'unknown'} (exported ${esc(data.exported_at?.split('T')[0]) || 'unknown'})<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`; statusEl.innerHTML = `${t('settings.import.found_summary', { summary: esc(summary) || t('settings.import.empty_export'), email: esc(data.user?.email) || t('common.unknown'), date: esc(data.exported_at?.split('T')[0]) || t('common.unknown') })}<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">${t('settings.import.confirm')}</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">${t('common.cancel')}</button>`;
} }
document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; }; document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; };
document.getElementById('confirmImportBtn').onclick = async () => { document.getElementById('confirmImportBtn').onclick = async () => {
statusEl.innerHTML = isZip ? 'Uploading and importing... This may take a moment for large files.' : 'Importing...'; statusEl.innerHTML = isZip ? t('settings.import.uploading_zip') : t('settings.import.importing');
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
let res; let res;
@ -255,28 +252,28 @@ export async function render(container) {
if (res.ok) { if (res.ok) {
const imported = Object.entries(result.stats).filter(([k,v]) => v > 0 && k !== 'files_restored').map(([k,v]) => `${v} ${k}`).join(', '); const imported = Object.entries(result.stats).filter(([k,v]) => v > 0 && k !== 'files_restored').map(([k,v]) => `${v} ${k}`).join(', ');
statusEl.style.color = 'var(--success)'; statusEl.style.color = 'var(--success)';
let html = `Import complete: ${imported}.`; let html = t('settings.import.complete', { imported });
if (result.device_pairings?.length) { if (result.device_pairings?.length) {
html += `<br><br><strong>Device Pairing Codes:</strong><br><table style="margin-top:8px;font-size:12px;border-collapse:collapse">` + html += `<br><br><strong>${t('settings.import.pairing_codes_title')}</strong><br><table style="margin-top:8px;font-size:12px;border-collapse:collapse">` +
result.device_pairings.map(d => `<tr><td style="padding:4px 12px 4px 0">${d.name}</td><td style="font-family:monospace;font-weight:700;font-size:14px;letter-spacing:2px">${d.pairing_code}</td></tr>`).join('') + result.device_pairings.map(d => `<tr><td style="padding:4px 12px 4px 0">${d.name}</td><td style="font-family:monospace;font-weight:700;font-size:14px;letter-spacing:2px">${d.pairing_code}</td></tr>`).join('') +
`</table><br>Enter these codes on each device to re-link them. All assignments and schedules will be preserved.`; `</table><br>${t('settings.import.pairing_codes_hint')}`;
} }
html += `<br><br>${(result.notes || []).map(n => '&bull; ' + n).join('<br>')}`; html += `<br><br>${(result.notes || []).map(n => '&bull; ' + n).join('<br>')}`;
statusEl.innerHTML = html; statusEl.innerHTML = html;
showToast('Data imported successfully', 'success'); showToast(t('settings.toast.import_success'), 'success');
} else { } else {
statusEl.style.color = 'var(--danger)'; statusEl.style.color = 'var(--danger)';
statusEl.textContent = result.error || 'Import failed'; statusEl.textContent = result.error || t('settings.import.failed');
} }
} catch (err) { } catch (err) {
statusEl.style.color = 'var(--danger)'; statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Import failed: ' + err.message; statusEl.textContent = t('settings.import.failed_with_error', { error: err.message });
} }
e.target.value = ''; e.target.value = '';
}; };
} catch (err) { } catch (err) {
statusEl.style.color = 'var(--danger)'; statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Failed to read file: ' + err.message; statusEl.textContent = t('settings.import.read_failed', { error: err.message });
} }
}); });
@ -288,14 +285,14 @@ export async function render(container) {
document.getElementById('saveAcctBtn')?.addEventListener('click', async () => { document.getElementById('saveAcctBtn')?.addEventListener('click', async () => {
const name = document.getElementById('acctName').value.trim(); const name = document.getElementById('acctName').value.trim();
if (!name) return showToast('Name cannot be empty', 'error'); if (!name) return showToast(t('settings.toast.name_required'), 'error');
const btn = document.getElementById('saveAcctBtn'); const btn = document.getElementById('saveAcctBtn');
btn.disabled = true; btn.disabled = true;
try { try {
const updated = await api.updateMe({ name }); const updated = await api.updateMe({ name });
const stored = JSON.parse(localStorage.getItem('user') || '{}'); const stored = JSON.parse(localStorage.getItem('user') || '{}');
localStorage.setItem('user', JSON.stringify({ ...stored, ...updated })); localStorage.setItem('user', JSON.stringify({ ...stored, ...updated }));
showToast('Profile saved', 'success'); showToast(t('settings.toast.profile_saved'), 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} finally { } finally {
@ -307,9 +304,9 @@ export async function render(container) {
const current = document.getElementById('acctCurrentPw').value; const current = document.getElementById('acctCurrentPw').value;
const next = document.getElementById('acctNewPw').value; const next = document.getElementById('acctNewPw').value;
const confirm = document.getElementById('acctConfirmPw').value; const confirm = document.getElementById('acctConfirmPw').value;
if (!current) return showToast('Enter your current password', 'error'); if (!current) return showToast(t('settings.toast.current_password_required'), 'error');
if (next.length < 8) return showToast('New password must be at least 8 characters', 'error'); if (next.length < 8) return showToast(t('settings.toast.new_password_min_8'), 'error');
if (next !== confirm) return showToast('New passwords do not match', 'error'); if (next !== confirm) return showToast(t('settings.toast.passwords_dont_match'), 'error');
const btn = document.getElementById('changePwBtn'); const btn = document.getElementById('changePwBtn');
btn.disabled = true; btn.disabled = true;
try { try {
@ -317,7 +314,7 @@ export async function render(container) {
document.getElementById('acctCurrentPw').value = ''; document.getElementById('acctCurrentPw').value = '';
document.getElementById('acctNewPw').value = ''; document.getElementById('acctNewPw').value = '';
document.getElementById('acctConfirmPw').value = ''; document.getElementById('acctConfirmPw').value = '';
showToast('Password changed', 'success'); showToast(t('settings.toast.password_changed'), 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} finally { } finally {
@ -336,10 +333,10 @@ async function loadWhiteLabel() {
const section = document.getElementById('whiteLabelSection'); const section = document.getElementById('whiteLabelSection');
if (section && user.plan_id !== 'enterprise' && user.role !== 'superadmin') { if (section && user.plan_id !== 'enterprise' && user.role !== 'superadmin') {
section.innerHTML = ` section.innerHTML = `
<h3>White Label / Branding</h3> <h3>${t('settings.white_label')}</h3>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center"> <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center">
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:8px">Custom branding is available on the Enterprise plan</p> <p style="color:var(--text-secondary);font-size:14px;margin-bottom:8px">${t('settings.white_label_enterprise_only')}</p>
<a href="#/billing" class="btn btn-secondary btn-sm" style="text-decoration:none">View Plans</a> <a href="#/billing" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('settings.view_plans')}</a>
</div> </div>
`; `;
return; return;
@ -376,7 +373,7 @@ async function loadWhiteLabel() {
}) })
}); });
await resetBranding(); await resetBranding();
showToast('Branding saved', 'success'); showToast(t('settings.toast.branding_saved'), 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -387,7 +384,7 @@ async function loadWhiteLabel() {
const bg = document.getElementById('wlBgColor').value; const bg = document.getElementById('wlBgColor').value;
document.documentElement.style.setProperty('--accent', primary); document.documentElement.style.setProperty('--accent', primary);
document.documentElement.style.setProperty('--bg-primary', bg); document.documentElement.style.setProperty('--bg-primary', bg);
showToast('Preview applied (refresh to reset)', 'info'); showToast(t('settings.toast.preview_applied'), 'info');
}); });
} }
@ -408,11 +405,11 @@ async function loadUsers() {
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:520px"> <table style="width:100%;border-collapse:collapse;font-size:13px;min-width:520px">
<thead> <thead>
<tr style="border-bottom:1px solid var(--border);text-align:left"> <tr style="border-bottom:1px solid var(--border);text-align:left">
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">User</th> <th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_user')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Auth</th> <th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_auth')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Role</th> <th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_role')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Plan</th> <th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_plan')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Actions</th> <th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -434,14 +431,14 @@ async function loadUsers() {
</select> </select>
</td> </td>
<td style="padding:10px 12px"> <td style="padding:10px 12px">
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${u.id}">Remove</button>` : '<span style="color:var(--text-muted);font-size:11px">You</span>'} ${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${u.id}">${t('settings.user.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('settings.user.you')}</span>`}
</td> </td>
</tr> </tr>
`).join('')} `).join('')}
</tbody> </tbody>
</table> </table>
</div> </div>
<p style="color:var(--text-muted);font-size:11px;margin-top:12px">${users.length} user(s) registered</p> <p style="color:var(--text-muted);font-size:11px;margin-top:12px">${tn('settings.user.count', users.length)}</p>
`; `;
// Plan change handlers // Plan change handlers
@ -451,7 +448,7 @@ async function loadUsers() {
const planId = select.value; const planId = select.value;
try { try {
await api.assignPlan(userId, planId); await api.assignPlan(userId, planId);
showToast('Plan updated', 'success'); showToast(t('settings.toast.plan_updated'), 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
loadUsers(); // Revert loadUsers(); // Revert
@ -466,7 +463,7 @@ async function loadUsers() {
if (confirming) { if (confirming) {
try { try {
await api.deleteUser(btn.dataset.userId); await api.deleteUser(btn.dataset.userId);
showToast('User removed', 'success'); showToast(t('settings.toast.user_removed'), 'success');
loadUsers(); loadUsers();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -474,12 +471,12 @@ async function loadUsers() {
return; return;
} }
confirming = true; confirming = true;
btn.textContent = 'Confirm?'; btn.textContent = t('settings.user.confirm');
btn.style.background = 'var(--danger)'; btn.style.background = 'var(--danger)';
btn.style.color = 'white'; btn.style.color = 'white';
setTimeout(() => { setTimeout(() => {
confirming = false; confirming = false;
btn.textContent = 'Remove'; btn.textContent = t('settings.user.remove');
btn.style.background = ''; btn.style.background = '';
btn.style.color = ''; btn.style.color = '';
}, 3000); }, 3000);