mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
i18n batch 5: wire layout-editor + video-wall + billing (~85 keys)
- layout-editor.js: list with templates + custom, zone editor with drag/resize and properties panel - video-wall.js: list with grid preview, editor with grid config, bezel inputs, drag-and-drop device placement - billing.js: current plan card, plans grid with checkout buttons, Stripe portal integration - 943 keys total, parity 100% across en/es/fr/de/pt Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f4a81d7be2
commit
7a17bb5079
|
|
@ -889,4 +889,98 @@ export default {
|
||||||
'kiosk.url_placeholder': 'URL oder Seite',
|
'kiosk.url_placeholder': 'URL oder Seite',
|
||||||
'kiosk.no_buttons': 'Noch keine Buttons',
|
'kiosk.no_buttons': 'Noch keine Buttons',
|
||||||
'kiosk.new_button': 'Neuer Button',
|
'kiosk.new_button': 'Neuer Button',
|
||||||
|
|
||||||
|
// Layout editor
|
||||||
|
'layout.title': 'Layouts',
|
||||||
|
'layout.subtitle': 'Bildschirm-Layouts und Vorlagen',
|
||||||
|
'layout.help_tip': 'Erstellen Sie Multi-Zonen-Bildschirmlayouts. Verwenden Sie Vorlagen oder erstellen Sie eigene. Ziehen Sie Zonen zum Positionieren, ändern Sie die Größe an der Ecke. Layouts auf der Playlist-Registerkarte zuweisen.',
|
||||||
|
'layout.new_layout': 'Neues Layout',
|
||||||
|
'layout.templates': 'Vorlagen',
|
||||||
|
'layout.my_layouts': 'Meine Layouts',
|
||||||
|
'layout.empty_custom': 'Noch keine benutzerdefinierten Layouts',
|
||||||
|
'layout.prompt_name': 'Layout-Name:',
|
||||||
|
'layout.default_zone_name': 'Hauptzone',
|
||||||
|
'layout.template_label': 'Vorlage',
|
||||||
|
'layout.use_template': 'Vorlage verwenden',
|
||||||
|
'layout.zone_count_one': '1 Zone',
|
||||||
|
'layout.zone_count_other': '{n} Zonen',
|
||||||
|
'layout.confirm_delete': 'Layout „{name}" löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||||
|
'layout.toast.deleted': 'Layout gelöscht',
|
||||||
|
'layout.toast.delete_failed': 'Layout konnte nicht gelöscht werden',
|
||||||
|
'layout.toast.saved': 'Layout gespeichert',
|
||||||
|
'layout.not_found': 'Layout nicht gefunden',
|
||||||
|
'layout.back': 'Zurück zu Layouts',
|
||||||
|
'layout.add_zone': 'Zone hinzufügen',
|
||||||
|
'layout.zones': 'Zonen',
|
||||||
|
'layout.properties': 'Eigenschaften',
|
||||||
|
'layout.delete_zone': 'Zone löschen',
|
||||||
|
'layout.zone_n': 'Zone {n}',
|
||||||
|
'layout.prop.name': 'Name',
|
||||||
|
'layout.prop.x': 'X (%)',
|
||||||
|
'layout.prop.y': 'Y (%)',
|
||||||
|
'layout.prop.width': 'Breite (%)',
|
||||||
|
'layout.prop.height': 'Höhe (%)',
|
||||||
|
'layout.prop.type': 'Typ',
|
||||||
|
'layout.type_content': 'Inhalt',
|
||||||
|
'layout.type_widget': 'Widget',
|
||||||
|
|
||||||
|
// Video walls
|
||||||
|
'wall.title': 'Videowände',
|
||||||
|
'wall.subtitle': 'Kombinieren Sie mehrere Bildschirme zu einem großen',
|
||||||
|
'wall.help_tip': 'Kombinieren Sie mehrere Bildschirme zu einem großen. Rastergröße einstellen, Geräte in Positionen ziehen, Rahmenkompensation einstellen. Inhalt zuweisen, der über alle Geräte abgespielt wird.',
|
||||||
|
'wall.new_wall': 'Neue Videowand',
|
||||||
|
'wall.prompt_name': 'Name der Videowand:',
|
||||||
|
'wall.empty_title': 'Noch keine Videowände',
|
||||||
|
'wall.empty_desc': 'Erstellen Sie eine Videowand, um mehrere Bildschirme zu kombinieren.',
|
||||||
|
'wall.grid_summary': '{cols}x{rows}-Raster • {n} Geräte',
|
||||||
|
'wall.not_found': 'Wand nicht gefunden',
|
||||||
|
'wall.back': 'Zurück zu Videowänden',
|
||||||
|
'wall.delete_wall': 'Wand löschen',
|
||||||
|
'wall.grid_config': 'Raster-Konfiguration',
|
||||||
|
'wall.columns': 'Spalten',
|
||||||
|
'wall.rows': 'Zeilen',
|
||||||
|
'wall.h_bezel': 'H Rahmen (mm)',
|
||||||
|
'wall.v_bezel': 'V Rahmen (mm)',
|
||||||
|
'wall.update': 'Aktualisieren',
|
||||||
|
'wall.content': 'Inhalt',
|
||||||
|
'wall.no_content': 'Kein Inhalt',
|
||||||
|
'wall.set_content': 'Inhalt festlegen',
|
||||||
|
'wall.available_displays': 'Verfügbare Bildschirme',
|
||||||
|
'wall.all_assigned': 'Alle Geräte zugewiesen',
|
||||||
|
'wall.drop_here': 'Hier ablegen',
|
||||||
|
'wall.toast.placed': '{name} platziert bei [{col},{row}]',
|
||||||
|
'wall.toast.grid_updated': 'Raster aktualisiert',
|
||||||
|
'wall.toast.content_updated': 'Inhalt aktualisiert',
|
||||||
|
'wall.toast.deleted': 'Wand gelöscht',
|
||||||
|
|
||||||
|
// Billing
|
||||||
|
'billing.title': 'Abonnement',
|
||||||
|
'billing.subtitle': 'Verwalten Sie Ihren Plan und die Abrechnung',
|
||||||
|
'billing.current_plan': 'Aktueller Plan',
|
||||||
|
'billing.self_hosted': 'Selbstgehostet',
|
||||||
|
'billing.trial_days_left': 'Testversion - {n} Tage übrig',
|
||||||
|
'billing.trial_ends': 'Ihre {plan}-Testversion endet in {n} Tagen',
|
||||||
|
'billing.trial_after': 'Nach der Testversion werden Sie zum kostenlosen Plan (1 Gerät) verschoben. Upgraden Sie jetzt, um alle Geräte und Funktionen zu behalten.',
|
||||||
|
'billing.devices': 'Geräte',
|
||||||
|
'billing.devices_lc': 'Geräte',
|
||||||
|
'billing.storage': 'Speicher',
|
||||||
|
'billing.storage_lc': 'Speicher',
|
||||||
|
'billing.features': 'Funktionen',
|
||||||
|
'billing.feat.remote_control': 'Fernsteuerung',
|
||||||
|
'billing.feat.remote_urls': 'Remote-URLs',
|
||||||
|
'billing.feat.priority_support': 'Prioritäts-Support',
|
||||||
|
'billing.available_plans': 'Verfügbare Pläne',
|
||||||
|
'billing.current': 'Aktuell',
|
||||||
|
'billing.unlimited': 'Unbegrenzt',
|
||||||
|
'billing.free': 'Kostenlos',
|
||||||
|
'billing.per_month': '/Mon',
|
||||||
|
'billing.yearly_save': 'oder {price} $/Jahr ({pct}% sparen)',
|
||||||
|
'billing.monthly': 'Monatlich',
|
||||||
|
'billing.yearly': 'Jährlich',
|
||||||
|
'billing.manage_subscription': 'Abonnement verwalten',
|
||||||
|
'billing.self_hosted_note': 'Selbstgehosteter Modus: Pläne können von Administratoren ohne Abrechnung zugewiesen werden.',
|
||||||
|
'billing.failed_to_load': 'Laden fehlgeschlagen',
|
||||||
|
'billing.toast.checkout_failed': 'Bezahlung konnte nicht gestartet werden: {error}',
|
||||||
|
'billing.toast.portal_failed': 'Abrechnungsportal konnte nicht geöffnet werden: {error}',
|
||||||
|
'billing.toast.payment_success': 'Bezahlung erfolgreich! Ihr Plan wurde aktualisiert.',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -925,4 +925,98 @@ export default {
|
||||||
'kiosk.url_placeholder': 'URL or page',
|
'kiosk.url_placeholder': 'URL or page',
|
||||||
'kiosk.no_buttons': 'No buttons yet',
|
'kiosk.no_buttons': 'No buttons yet',
|
||||||
'kiosk.new_button': 'New Button',
|
'kiosk.new_button': 'New Button',
|
||||||
|
|
||||||
|
// Layout editor
|
||||||
|
'layout.title': 'Layouts',
|
||||||
|
'layout.subtitle': 'Screen layouts and templates',
|
||||||
|
'layout.help_tip': 'Create multi-zone screen layouts. Use templates or build custom ones. Drag zones to position, resize with corner handle. Assign layouts to devices in the Playlist tab.',
|
||||||
|
'layout.new_layout': 'New Layout',
|
||||||
|
'layout.templates': 'Templates',
|
||||||
|
'layout.my_layouts': 'My Layouts',
|
||||||
|
'layout.empty_custom': 'No custom layouts yet',
|
||||||
|
'layout.prompt_name': 'Layout name:',
|
||||||
|
'layout.default_zone_name': 'Main',
|
||||||
|
'layout.template_label': 'Template',
|
||||||
|
'layout.use_template': 'Use Template',
|
||||||
|
'layout.zone_count_one': '1 zone',
|
||||||
|
'layout.zone_count_other': '{n} zones',
|
||||||
|
'layout.confirm_delete': 'Delete layout "{name}"? This cannot be undone.',
|
||||||
|
'layout.toast.deleted': 'Layout deleted',
|
||||||
|
'layout.toast.delete_failed': 'Failed to delete layout',
|
||||||
|
'layout.toast.saved': 'Layout saved',
|
||||||
|
'layout.not_found': 'Layout not found',
|
||||||
|
'layout.back': 'Back to Layouts',
|
||||||
|
'layout.add_zone': 'Add Zone',
|
||||||
|
'layout.zones': 'Zones',
|
||||||
|
'layout.properties': 'Properties',
|
||||||
|
'layout.delete_zone': 'Delete Zone',
|
||||||
|
'layout.zone_n': 'Zone {n}',
|
||||||
|
'layout.prop.name': 'Name',
|
||||||
|
'layout.prop.x': 'X (%)',
|
||||||
|
'layout.prop.y': 'Y (%)',
|
||||||
|
'layout.prop.width': 'Width (%)',
|
||||||
|
'layout.prop.height': 'Height (%)',
|
||||||
|
'layout.prop.type': 'Type',
|
||||||
|
'layout.type_content': 'Content',
|
||||||
|
'layout.type_widget': 'Widget',
|
||||||
|
|
||||||
|
// Video walls
|
||||||
|
'wall.title': 'Video Walls',
|
||||||
|
'wall.subtitle': 'Combine multiple displays into one large screen',
|
||||||
|
'wall.help_tip': 'Combine multiple displays into one large screen. Set grid size, drag devices into positions, adjust bezel compensation. Assign content to play across all devices.',
|
||||||
|
'wall.new_wall': 'New Video Wall',
|
||||||
|
'wall.prompt_name': 'Video wall name:',
|
||||||
|
'wall.empty_title': 'No video walls yet',
|
||||||
|
'wall.empty_desc': 'Create a video wall to combine multiple displays.',
|
||||||
|
'wall.grid_summary': '{cols}x{rows} grid • {n} devices',
|
||||||
|
'wall.not_found': 'Wall not found',
|
||||||
|
'wall.back': 'Back to Video Walls',
|
||||||
|
'wall.delete_wall': 'Delete Wall',
|
||||||
|
'wall.grid_config': 'Grid Configuration',
|
||||||
|
'wall.columns': 'Columns',
|
||||||
|
'wall.rows': 'Rows',
|
||||||
|
'wall.h_bezel': 'H Bezel (mm)',
|
||||||
|
'wall.v_bezel': 'V Bezel (mm)',
|
||||||
|
'wall.update': 'Update',
|
||||||
|
'wall.content': 'Content',
|
||||||
|
'wall.no_content': 'No content',
|
||||||
|
'wall.set_content': 'Set Content',
|
||||||
|
'wall.available_displays': 'Available Displays',
|
||||||
|
'wall.all_assigned': 'All devices assigned',
|
||||||
|
'wall.drop_here': 'Drop here',
|
||||||
|
'wall.toast.placed': '{name} placed at [{col},{row}]',
|
||||||
|
'wall.toast.grid_updated': 'Grid updated',
|
||||||
|
'wall.toast.content_updated': 'Content updated',
|
||||||
|
'wall.toast.deleted': 'Wall deleted',
|
||||||
|
|
||||||
|
// Billing
|
||||||
|
'billing.title': 'Subscription',
|
||||||
|
'billing.subtitle': 'Manage your plan and billing',
|
||||||
|
'billing.current_plan': 'Current Plan',
|
||||||
|
'billing.self_hosted': 'Self-Hosted',
|
||||||
|
'billing.trial_days_left': 'Trial - {n} days left',
|
||||||
|
'billing.trial_ends': 'Your {plan} trial ends in {n} days',
|
||||||
|
'billing.trial_after': "After the trial, you'll be moved to the Free plan (1 device). Upgrade now to keep all your devices and features.",
|
||||||
|
'billing.devices': 'Devices',
|
||||||
|
'billing.devices_lc': 'devices',
|
||||||
|
'billing.storage': 'Storage',
|
||||||
|
'billing.storage_lc': 'storage',
|
||||||
|
'billing.features': 'Features',
|
||||||
|
'billing.feat.remote_control': 'Remote Control',
|
||||||
|
'billing.feat.remote_urls': 'Remote URLs',
|
||||||
|
'billing.feat.priority_support': 'Priority Support',
|
||||||
|
'billing.available_plans': 'Available Plans',
|
||||||
|
'billing.current': 'Current',
|
||||||
|
'billing.unlimited': 'Unlimited',
|
||||||
|
'billing.free': 'Free',
|
||||||
|
'billing.per_month': '/mo',
|
||||||
|
'billing.yearly_save': 'or ${price}/year (save {pct}%)',
|
||||||
|
'billing.monthly': 'Monthly',
|
||||||
|
'billing.yearly': 'Yearly',
|
||||||
|
'billing.manage_subscription': 'Manage Subscription',
|
||||||
|
'billing.self_hosted_note': 'Self-hosted mode: plans can be assigned by admins without billing.',
|
||||||
|
'billing.failed_to_load': 'Failed to load',
|
||||||
|
'billing.toast.checkout_failed': 'Failed to start checkout: {error}',
|
||||||
|
'billing.toast.portal_failed': 'Failed to open billing portal: {error}',
|
||||||
|
'billing.toast.payment_success': 'Payment successful! Your plan has been upgraded.',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -888,4 +888,98 @@ export default {
|
||||||
'kiosk.url_placeholder': 'URL o página',
|
'kiosk.url_placeholder': 'URL o página',
|
||||||
'kiosk.no_buttons': 'Aún no hay botones',
|
'kiosk.no_buttons': 'Aún no hay botones',
|
||||||
'kiosk.new_button': 'Nuevo botón',
|
'kiosk.new_button': 'Nuevo botón',
|
||||||
|
|
||||||
|
// Layout editor
|
||||||
|
'layout.title': 'Diseños',
|
||||||
|
'layout.subtitle': 'Diseños de pantalla y plantillas',
|
||||||
|
'layout.help_tip': 'Crea diseños de pantalla multi-zona. Usa plantillas o crea uno propio. Arrastra zonas para posicionar, cambia tamaño con la esquina. Asigna diseños a dispositivos desde la pestaña Lista.',
|
||||||
|
'layout.new_layout': 'Nuevo diseño',
|
||||||
|
'layout.templates': 'Plantillas',
|
||||||
|
'layout.my_layouts': 'Mis diseños',
|
||||||
|
'layout.empty_custom': 'Aún no hay diseños personalizados',
|
||||||
|
'layout.prompt_name': 'Nombre del diseño:',
|
||||||
|
'layout.default_zone_name': 'Principal',
|
||||||
|
'layout.template_label': 'Plantilla',
|
||||||
|
'layout.use_template': 'Usar plantilla',
|
||||||
|
'layout.zone_count_one': '1 zona',
|
||||||
|
'layout.zone_count_other': '{n} zonas',
|
||||||
|
'layout.confirm_delete': '¿Eliminar el diseño "{name}"? Esto no se puede deshacer.',
|
||||||
|
'layout.toast.deleted': 'Diseño eliminado',
|
||||||
|
'layout.toast.delete_failed': 'Error al eliminar el diseño',
|
||||||
|
'layout.toast.saved': 'Diseño guardado',
|
||||||
|
'layout.not_found': 'Diseño no encontrado',
|
||||||
|
'layout.back': 'Volver a diseños',
|
||||||
|
'layout.add_zone': 'Agregar zona',
|
||||||
|
'layout.zones': 'Zonas',
|
||||||
|
'layout.properties': 'Propiedades',
|
||||||
|
'layout.delete_zone': 'Eliminar zona',
|
||||||
|
'layout.zone_n': 'Zona {n}',
|
||||||
|
'layout.prop.name': 'Nombre',
|
||||||
|
'layout.prop.x': 'X (%)',
|
||||||
|
'layout.prop.y': 'Y (%)',
|
||||||
|
'layout.prop.width': 'Ancho (%)',
|
||||||
|
'layout.prop.height': 'Alto (%)',
|
||||||
|
'layout.prop.type': 'Tipo',
|
||||||
|
'layout.type_content': 'Contenido',
|
||||||
|
'layout.type_widget': 'Widget',
|
||||||
|
|
||||||
|
// Video walls
|
||||||
|
'wall.title': 'Muros de video',
|
||||||
|
'wall.subtitle': 'Combina varias pantallas en una sola grande',
|
||||||
|
'wall.help_tip': 'Combina varias pantallas en una sola grande. Configura el tamaño de la cuadrícula, arrastra dispositivos a posiciones, ajusta la compensación de bisel. Asigna contenido para reproducir en todos los dispositivos.',
|
||||||
|
'wall.new_wall': 'Nuevo muro de video',
|
||||||
|
'wall.prompt_name': 'Nombre del muro de video:',
|
||||||
|
'wall.empty_title': 'Aún no hay muros',
|
||||||
|
'wall.empty_desc': 'Crea un muro para combinar varias pantallas.',
|
||||||
|
'wall.grid_summary': 'Cuadrícula {cols}x{rows} • {n} dispositivos',
|
||||||
|
'wall.not_found': 'Muro no encontrado',
|
||||||
|
'wall.back': 'Volver a muros',
|
||||||
|
'wall.delete_wall': 'Eliminar muro',
|
||||||
|
'wall.grid_config': 'Configuración de cuadrícula',
|
||||||
|
'wall.columns': 'Columnas',
|
||||||
|
'wall.rows': 'Filas',
|
||||||
|
'wall.h_bezel': 'Bisel H (mm)',
|
||||||
|
'wall.v_bezel': 'Bisel V (mm)',
|
||||||
|
'wall.update': 'Actualizar',
|
||||||
|
'wall.content': 'Contenido',
|
||||||
|
'wall.no_content': 'Sin contenido',
|
||||||
|
'wall.set_content': 'Establecer contenido',
|
||||||
|
'wall.available_displays': 'Pantallas disponibles',
|
||||||
|
'wall.all_assigned': 'Todos los dispositivos asignados',
|
||||||
|
'wall.drop_here': 'Soltar aquí',
|
||||||
|
'wall.toast.placed': '{name} colocado en [{col},{row}]',
|
||||||
|
'wall.toast.grid_updated': 'Cuadrícula actualizada',
|
||||||
|
'wall.toast.content_updated': 'Contenido actualizado',
|
||||||
|
'wall.toast.deleted': 'Muro eliminado',
|
||||||
|
|
||||||
|
// Billing
|
||||||
|
'billing.title': 'Suscripción',
|
||||||
|
'billing.subtitle': 'Administra tu plan y facturación',
|
||||||
|
'billing.current_plan': 'Plan actual',
|
||||||
|
'billing.self_hosted': 'Autoalojado',
|
||||||
|
'billing.trial_days_left': 'Prueba - {n} días restantes',
|
||||||
|
'billing.trial_ends': 'Tu prueba {plan} termina en {n} días',
|
||||||
|
'billing.trial_after': 'Después de la prueba, pasarás al plan Gratis (1 dispositivo). Actualiza ahora para conservar tus dispositivos y funciones.',
|
||||||
|
'billing.devices': 'Dispositivos',
|
||||||
|
'billing.devices_lc': 'dispositivos',
|
||||||
|
'billing.storage': 'Almacenamiento',
|
||||||
|
'billing.storage_lc': 'almacenamiento',
|
||||||
|
'billing.features': 'Funciones',
|
||||||
|
'billing.feat.remote_control': 'Control remoto',
|
||||||
|
'billing.feat.remote_urls': 'URLs remotas',
|
||||||
|
'billing.feat.priority_support': 'Soporte prioritario',
|
||||||
|
'billing.available_plans': 'Planes disponibles',
|
||||||
|
'billing.current': 'Actual',
|
||||||
|
'billing.unlimited': 'Ilimitado',
|
||||||
|
'billing.free': 'Gratis',
|
||||||
|
'billing.per_month': '/mes',
|
||||||
|
'billing.yearly_save': 'o ${price}/año (ahorra {pct}%)',
|
||||||
|
'billing.monthly': 'Mensual',
|
||||||
|
'billing.yearly': 'Anual',
|
||||||
|
'billing.manage_subscription': 'Gestionar suscripción',
|
||||||
|
'billing.self_hosted_note': 'Modo autoalojado: los planes pueden ser asignados por administradores sin facturación.',
|
||||||
|
'billing.failed_to_load': 'Error al cargar',
|
||||||
|
'billing.toast.checkout_failed': 'Error al iniciar el pago: {error}',
|
||||||
|
'billing.toast.portal_failed': 'Error al abrir el portal de facturación: {error}',
|
||||||
|
'billing.toast.payment_success': '¡Pago exitoso! Tu plan ha sido actualizado.',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -889,4 +889,98 @@ export default {
|
||||||
'kiosk.url_placeholder': 'URL ou page',
|
'kiosk.url_placeholder': 'URL ou page',
|
||||||
'kiosk.no_buttons': 'Aucun bouton',
|
'kiosk.no_buttons': 'Aucun bouton',
|
||||||
'kiosk.new_button': 'Nouveau bouton',
|
'kiosk.new_button': 'Nouveau bouton',
|
||||||
|
|
||||||
|
// Layout editor
|
||||||
|
'layout.title': 'Mises en page',
|
||||||
|
'layout.subtitle': 'Mises en page d\'écran et modèles',
|
||||||
|
'layout.help_tip': 'Créez des mises en page multi-zones. Utilisez des modèles ou créez les vôtres. Glissez les zones pour positionner, redimensionnez avec la poignée. Attribuez les mises en page aux appareils depuis l\'onglet Liste.',
|
||||||
|
'layout.new_layout': 'Nouvelle mise en page',
|
||||||
|
'layout.templates': 'Modèles',
|
||||||
|
'layout.my_layouts': 'Mes mises en page',
|
||||||
|
'layout.empty_custom': 'Aucune mise en page personnalisée',
|
||||||
|
'layout.prompt_name': 'Nom de la mise en page :',
|
||||||
|
'layout.default_zone_name': 'Principal',
|
||||||
|
'layout.template_label': 'Modèle',
|
||||||
|
'layout.use_template': 'Utiliser le modèle',
|
||||||
|
'layout.zone_count_one': '1 zone',
|
||||||
|
'layout.zone_count_other': '{n} zones',
|
||||||
|
'layout.confirm_delete': 'Supprimer la mise en page « {name} » ? Cette action est irréversible.',
|
||||||
|
'layout.toast.deleted': 'Mise en page supprimée',
|
||||||
|
'layout.toast.delete_failed': 'Échec de la suppression',
|
||||||
|
'layout.toast.saved': 'Mise en page enregistrée',
|
||||||
|
'layout.not_found': 'Mise en page introuvable',
|
||||||
|
'layout.back': 'Retour aux mises en page',
|
||||||
|
'layout.add_zone': 'Ajouter une zone',
|
||||||
|
'layout.zones': 'Zones',
|
||||||
|
'layout.properties': 'Propriétés',
|
||||||
|
'layout.delete_zone': 'Supprimer la zone',
|
||||||
|
'layout.zone_n': 'Zone {n}',
|
||||||
|
'layout.prop.name': 'Nom',
|
||||||
|
'layout.prop.x': 'X (%)',
|
||||||
|
'layout.prop.y': 'Y (%)',
|
||||||
|
'layout.prop.width': 'Largeur (%)',
|
||||||
|
'layout.prop.height': 'Hauteur (%)',
|
||||||
|
'layout.prop.type': 'Type',
|
||||||
|
'layout.type_content': 'Contenu',
|
||||||
|
'layout.type_widget': 'Widget',
|
||||||
|
|
||||||
|
// Video walls
|
||||||
|
'wall.title': 'Murs vidéo',
|
||||||
|
'wall.subtitle': 'Combinez plusieurs écrans en un seul grand',
|
||||||
|
'wall.help_tip': 'Combinez plusieurs écrans en un seul grand. Définissez la grille, glissez les appareils, ajustez la compensation de cadre. Attribuez du contenu pour la lecture sur tous les appareils.',
|
||||||
|
'wall.new_wall': 'Nouveau mur vidéo',
|
||||||
|
'wall.prompt_name': 'Nom du mur vidéo :',
|
||||||
|
'wall.empty_title': 'Aucun mur vidéo',
|
||||||
|
'wall.empty_desc': 'Créez un mur vidéo pour combiner plusieurs écrans.',
|
||||||
|
'wall.grid_summary': 'Grille {cols}x{rows} • {n} appareils',
|
||||||
|
'wall.not_found': 'Mur introuvable',
|
||||||
|
'wall.back': 'Retour aux murs',
|
||||||
|
'wall.delete_wall': 'Supprimer le mur',
|
||||||
|
'wall.grid_config': 'Configuration de la grille',
|
||||||
|
'wall.columns': 'Colonnes',
|
||||||
|
'wall.rows': 'Lignes',
|
||||||
|
'wall.h_bezel': 'Cadre H (mm)',
|
||||||
|
'wall.v_bezel': 'Cadre V (mm)',
|
||||||
|
'wall.update': 'Mettre à jour',
|
||||||
|
'wall.content': 'Contenu',
|
||||||
|
'wall.no_content': 'Aucun contenu',
|
||||||
|
'wall.set_content': 'Définir le contenu',
|
||||||
|
'wall.available_displays': 'Écrans disponibles',
|
||||||
|
'wall.all_assigned': 'Tous les appareils attribués',
|
||||||
|
'wall.drop_here': 'Déposer ici',
|
||||||
|
'wall.toast.placed': '{name} placé en [{col},{row}]',
|
||||||
|
'wall.toast.grid_updated': 'Grille mise à jour',
|
||||||
|
'wall.toast.content_updated': 'Contenu mis à jour',
|
||||||
|
'wall.toast.deleted': 'Mur supprimé',
|
||||||
|
|
||||||
|
// Billing
|
||||||
|
'billing.title': 'Abonnement',
|
||||||
|
'billing.subtitle': 'Gérez votre plan et votre facturation',
|
||||||
|
'billing.current_plan': 'Plan actuel',
|
||||||
|
'billing.self_hosted': 'Auto-hébergé',
|
||||||
|
'billing.trial_days_left': 'Essai - {n} jours restants',
|
||||||
|
'billing.trial_ends': 'Votre essai {plan} se termine dans {n} jours',
|
||||||
|
'billing.trial_after': 'Après l\'essai, vous passerez au plan Gratuit (1 appareil). Mettez à niveau maintenant pour conserver vos appareils et fonctionnalités.',
|
||||||
|
'billing.devices': 'Appareils',
|
||||||
|
'billing.devices_lc': 'appareils',
|
||||||
|
'billing.storage': 'Stockage',
|
||||||
|
'billing.storage_lc': 'stockage',
|
||||||
|
'billing.features': 'Fonctionnalités',
|
||||||
|
'billing.feat.remote_control': 'Contrôle à distance',
|
||||||
|
'billing.feat.remote_urls': 'URLs distantes',
|
||||||
|
'billing.feat.priority_support': 'Support prioritaire',
|
||||||
|
'billing.available_plans': 'Plans disponibles',
|
||||||
|
'billing.current': 'Actuel',
|
||||||
|
'billing.unlimited': 'Illimité',
|
||||||
|
'billing.free': 'Gratuit',
|
||||||
|
'billing.per_month': '/mois',
|
||||||
|
'billing.yearly_save': 'ou {price} $/an (économisez {pct} %)',
|
||||||
|
'billing.monthly': 'Mensuel',
|
||||||
|
'billing.yearly': 'Annuel',
|
||||||
|
'billing.manage_subscription': 'Gérer l\'abonnement',
|
||||||
|
'billing.self_hosted_note': 'Mode auto-hébergé : les plans peuvent être attribués par les administrateurs sans facturation.',
|
||||||
|
'billing.failed_to_load': 'Échec du chargement',
|
||||||
|
'billing.toast.checkout_failed': 'Échec du paiement : {error}',
|
||||||
|
'billing.toast.portal_failed': 'Échec d\'ouverture du portail : {error}',
|
||||||
|
'billing.toast.payment_success': 'Paiement réussi ! Votre plan a été mis à niveau.',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -889,4 +889,98 @@ export default {
|
||||||
'kiosk.url_placeholder': 'URL ou página',
|
'kiosk.url_placeholder': 'URL ou página',
|
||||||
'kiosk.no_buttons': 'Sem botões ainda',
|
'kiosk.no_buttons': 'Sem botões ainda',
|
||||||
'kiosk.new_button': 'Novo botão',
|
'kiosk.new_button': 'Novo botão',
|
||||||
|
|
||||||
|
// Layout editor
|
||||||
|
'layout.title': 'Layouts',
|
||||||
|
'layout.subtitle': 'Layouts e modelos de tela',
|
||||||
|
'layout.help_tip': 'Crie layouts de tela multi-zona. Use modelos ou crie os seus. Arraste zonas para posicionar, redimensione pelo canto. Atribua layouts a dispositivos na aba Playlist.',
|
||||||
|
'layout.new_layout': 'Novo layout',
|
||||||
|
'layout.templates': 'Modelos',
|
||||||
|
'layout.my_layouts': 'Meus layouts',
|
||||||
|
'layout.empty_custom': 'Sem layouts personalizados ainda',
|
||||||
|
'layout.prompt_name': 'Nome do layout:',
|
||||||
|
'layout.default_zone_name': 'Principal',
|
||||||
|
'layout.template_label': 'Modelo',
|
||||||
|
'layout.use_template': 'Usar modelo',
|
||||||
|
'layout.zone_count_one': '1 zona',
|
||||||
|
'layout.zone_count_other': '{n} zonas',
|
||||||
|
'layout.confirm_delete': 'Excluir o layout "{name}"? Isso não pode ser desfeito.',
|
||||||
|
'layout.toast.deleted': 'Layout excluído',
|
||||||
|
'layout.toast.delete_failed': 'Falha ao excluir o layout',
|
||||||
|
'layout.toast.saved': 'Layout salvo',
|
||||||
|
'layout.not_found': 'Layout não encontrado',
|
||||||
|
'layout.back': 'Voltar para layouts',
|
||||||
|
'layout.add_zone': 'Adicionar zona',
|
||||||
|
'layout.zones': 'Zonas',
|
||||||
|
'layout.properties': 'Propriedades',
|
||||||
|
'layout.delete_zone': 'Excluir zona',
|
||||||
|
'layout.zone_n': 'Zona {n}',
|
||||||
|
'layout.prop.name': 'Nome',
|
||||||
|
'layout.prop.x': 'X (%)',
|
||||||
|
'layout.prop.y': 'Y (%)',
|
||||||
|
'layout.prop.width': 'Largura (%)',
|
||||||
|
'layout.prop.height': 'Altura (%)',
|
||||||
|
'layout.prop.type': 'Tipo',
|
||||||
|
'layout.type_content': 'Conteúdo',
|
||||||
|
'layout.type_widget': 'Widget',
|
||||||
|
|
||||||
|
// Video walls
|
||||||
|
'wall.title': 'Paredes de vídeo',
|
||||||
|
'wall.subtitle': 'Combine várias telas em uma grande',
|
||||||
|
'wall.help_tip': 'Combine várias telas em uma grande. Defina o tamanho da grade, arraste dispositivos para posições, ajuste compensação de moldura. Atribua conteúdo para reproduzir em todos os dispositivos.',
|
||||||
|
'wall.new_wall': 'Nova parede de vídeo',
|
||||||
|
'wall.prompt_name': 'Nome da parede de vídeo:',
|
||||||
|
'wall.empty_title': 'Nenhuma parede de vídeo ainda',
|
||||||
|
'wall.empty_desc': 'Crie uma parede de vídeo para combinar várias telas.',
|
||||||
|
'wall.grid_summary': 'Grade {cols}x{rows} • {n} dispositivos',
|
||||||
|
'wall.not_found': 'Parede não encontrada',
|
||||||
|
'wall.back': 'Voltar para paredes',
|
||||||
|
'wall.delete_wall': 'Excluir parede',
|
||||||
|
'wall.grid_config': 'Configuração da grade',
|
||||||
|
'wall.columns': 'Colunas',
|
||||||
|
'wall.rows': 'Linhas',
|
||||||
|
'wall.h_bezel': 'Moldura H (mm)',
|
||||||
|
'wall.v_bezel': 'Moldura V (mm)',
|
||||||
|
'wall.update': 'Atualizar',
|
||||||
|
'wall.content': 'Conteúdo',
|
||||||
|
'wall.no_content': 'Sem conteúdo',
|
||||||
|
'wall.set_content': 'Definir conteúdo',
|
||||||
|
'wall.available_displays': 'Telas disponíveis',
|
||||||
|
'wall.all_assigned': 'Todos os dispositivos atribuídos',
|
||||||
|
'wall.drop_here': 'Solte aqui',
|
||||||
|
'wall.toast.placed': '{name} posicionado em [{col},{row}]',
|
||||||
|
'wall.toast.grid_updated': 'Grade atualizada',
|
||||||
|
'wall.toast.content_updated': 'Conteúdo atualizado',
|
||||||
|
'wall.toast.deleted': 'Parede excluída',
|
||||||
|
|
||||||
|
// Billing
|
||||||
|
'billing.title': 'Assinatura',
|
||||||
|
'billing.subtitle': 'Gerencie seu plano e cobrança',
|
||||||
|
'billing.current_plan': 'Plano atual',
|
||||||
|
'billing.self_hosted': 'Auto-hospedado',
|
||||||
|
'billing.trial_days_left': 'Avaliação - {n} dias restantes',
|
||||||
|
'billing.trial_ends': 'Sua avaliação {plan} termina em {n} dias',
|
||||||
|
'billing.trial_after': 'Após a avaliação, você passará para o plano Gratuito (1 dispositivo). Atualize agora para manter seus dispositivos e recursos.',
|
||||||
|
'billing.devices': 'Dispositivos',
|
||||||
|
'billing.devices_lc': 'dispositivos',
|
||||||
|
'billing.storage': 'Armazenamento',
|
||||||
|
'billing.storage_lc': 'armazenamento',
|
||||||
|
'billing.features': 'Recursos',
|
||||||
|
'billing.feat.remote_control': 'Controle remoto',
|
||||||
|
'billing.feat.remote_urls': 'URLs remotas',
|
||||||
|
'billing.feat.priority_support': 'Suporte prioritário',
|
||||||
|
'billing.available_plans': 'Planos disponíveis',
|
||||||
|
'billing.current': 'Atual',
|
||||||
|
'billing.unlimited': 'Ilimitado',
|
||||||
|
'billing.free': 'Grátis',
|
||||||
|
'billing.per_month': '/mês',
|
||||||
|
'billing.yearly_save': 'ou ${price}/ano (economize {pct}%)',
|
||||||
|
'billing.monthly': 'Mensal',
|
||||||
|
'billing.yearly': 'Anual',
|
||||||
|
'billing.manage_subscription': 'Gerenciar assinatura',
|
||||||
|
'billing.self_hosted_note': 'Modo auto-hospedado: planos podem ser atribuídos por administradores sem cobrança.',
|
||||||
|
'billing.failed_to_load': 'Falha ao carregar',
|
||||||
|
'billing.toast.checkout_failed': 'Falha ao iniciar pagamento: {error}',
|
||||||
|
'billing.toast.portal_failed': 'Falha ao abrir portal de cobrança: {error}',
|
||||||
|
'billing.toast.payment_success': 'Pagamento bem-sucedido! Seu plano foi atualizado.',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.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 } from '../i18n.js';
|
||||||
|
|
||||||
export async function render(container) {
|
export async function render(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Subscription</h1>
|
<h1>${t('billing.title')}</h1>
|
||||||
<div class="subtitle">Manage your plan and billing</div>
|
<div class="subtitle">${t('billing.subtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="billingContent"><div class="empty-state"><h3>Loading...</h3></div></div>
|
<div id="billingContent"><div class="empty-state"><h3>${t('common.loading')}</h3></div></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -22,27 +23,26 @@ export async function render(container) {
|
||||||
const content = document.getElementById('billingContent');
|
const content = document.getElementById('billingContent');
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<!-- Current Plan -->
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Current Plan</h3>
|
<h3>${t('billing.current_plan')}</h3>
|
||||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
|
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
|
||||||
<div style="font-size:28px;font-weight:700;color:var(--accent)">${subData.plan.display_name}</div>
|
<div style="font-size:28px;font-weight:700;color:var(--accent)">${subData.plan.display_name}</div>
|
||||||
${subData.self_hosted ? '<span style="background:var(--success-dim);color:var(--success);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">Self-Hosted</span>' : ''}
|
${subData.self_hosted ? `<span style="background:var(--success-dim);color:var(--success);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">${t('billing.self_hosted')}</span>` : ''}
|
||||||
${subData.trial?.active ? `<span style="background:var(--warning-dim);color:var(--warning);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">Trial - ${subData.trial.days_left} days left</span>` : ''}
|
${subData.trial?.active ? `<span style="background:var(--warning-dim);color:var(--warning);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">${t('billing.trial_days_left', { n: subData.trial.days_left })}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${subData.trial?.active ? `
|
${subData.trial?.active ? `
|
||||||
<div style="background:var(--bg-secondary);border:1px solid var(--warning);border-radius:var(--radius);padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:12px">
|
<div style="background:var(--bg-secondary);border:1px solid var(--warning);border-radius:var(--radius);padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:12px">
|
||||||
<span style="font-size:20px">⏱</span>
|
<span style="font-size:20px">⏱</span>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:13px;font-weight:500">Your ${subData.trial.plan?.charAt(0).toUpperCase() + subData.trial.plan?.slice(1)} trial ends in ${subData.trial.days_left} days</div>
|
<div style="font-size:13px;font-weight:500">${t('billing.trial_ends', { plan: (subData.trial.plan?.charAt(0).toUpperCase() + subData.trial.plan?.slice(1)) || '', n: subData.trial.days_left })}</div>
|
||||||
<div style="font-size:12px;color:var(--text-muted)">After the trial, you'll be moved to the Free plan (1 device). Upgrade now to keep all your devices and features.</div>
|
<div style="font-size:12px;color:var(--text-muted)">${t('billing.trial_after')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="info-grid" style="margin-bottom:0">
|
<div class="info-grid" style="margin-bottom:0">
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Devices</div>
|
<div class="info-card-label">${t('billing.devices')}</div>
|
||||||
<div class="info-card-value">${subData.usage.devices} <span style="font-size:14px;color:var(--text-secondary)">/ ${subData.plan.max_devices === -1 ? 'Unlimited' : subData.plan.max_devices}</span></div>
|
<div class="info-card-value">${subData.usage.devices} <span style="font-size:14px;color:var(--text-secondary)">/ ${subData.plan.max_devices === -1 ? t('billing.unlimited') : subData.plan.max_devices}</span></div>
|
||||||
${subData.plan.max_devices > 0 ? `
|
${subData.plan.max_devices > 0 ? `
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-bar-fill ${subData.usage.devices / subData.plan.max_devices > 0.8 ? 'warning' : 'success'}"
|
<div class="progress-bar-fill ${subData.usage.devices / subData.plan.max_devices > 0.8 ? 'warning' : 'success'}"
|
||||||
|
|
@ -50,8 +50,8 @@ export async function render(container) {
|
||||||
</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('billing.storage')}</div>
|
||||||
<div class="info-card-value small">${subData.usage.storage_mb} MB <span style="color:var(--text-secondary)">/ ${subData.plan.max_storage_mb === -1 ? 'Unlimited' : subData.plan.max_storage_mb + ' MB'}</span></div>
|
<div class="info-card-value small">${subData.usage.storage_mb} MB <span style="color:var(--text-secondary)">/ ${subData.plan.max_storage_mb === -1 ? t('billing.unlimited') : subData.plan.max_storage_mb + ' MB'}</span></div>
|
||||||
${subData.plan.max_storage_mb > 0 ? `
|
${subData.plan.max_storage_mb > 0 ? `
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-bar-fill ${subData.usage.storage_mb / subData.plan.max_storage_mb > 0.8 ? 'warning' : 'success'}"
|
<div class="progress-bar-fill ${subData.usage.storage_mb / subData.plan.max_storage_mb > 0.8 ? 'warning' : 'success'}"
|
||||||
|
|
@ -59,51 +59,50 @@ export async function render(container) {
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Features</div>
|
<div class="info-card-label">${t('billing.features')}</div>
|
||||||
<div style="font-size:13px;margin-top:4px">
|
<div style="font-size:13px;margin-top:4px">
|
||||||
${subData.plan.remote_control ? '<div style="color:var(--success)">✓ Remote Control</div>' : '<div style="color:var(--text-muted)">✗ Remote Control</div>'}
|
${subData.plan.remote_control ? `<div style="color:var(--success)">✓ ${t('billing.feat.remote_control')}</div>` : `<div style="color:var(--text-muted)">✗ ${t('billing.feat.remote_control')}</div>`}
|
||||||
${subData.plan.remote_url ? '<div style="color:var(--success)">✓ Remote URLs</div>' : '<div style="color:var(--text-muted)">✗ Remote URLs</div>'}
|
${subData.plan.remote_url ? `<div style="color:var(--success)">✓ ${t('billing.feat.remote_urls')}</div>` : `<div style="color:var(--text-muted)">✗ ${t('billing.feat.remote_urls')}</div>`}
|
||||||
${subData.plan.priority_support ? '<div style="color:var(--success)">✓ Priority Support</div>' : '<div style="color:var(--text-muted)">✗ Priority Support</div>'}
|
${subData.plan.priority_support ? `<div style="color:var(--success)">✓ ${t('billing.feat.priority_support')}</div>` : `<div style="color:var(--text-muted)">✗ ${t('billing.feat.priority_support')}</div>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plans -->
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Available Plans</h3>
|
<h3>${t('billing.available_plans')}</h3>
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(240px, 1fr));gap:16px">
|
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(240px, 1fr));gap:16px">
|
||||||
${plans.map(p => `
|
${plans.map(p => `
|
||||||
<div style="background:var(--bg-secondary);border:${p.id === subData.plan.id ? '2px solid var(--accent)' : '1px solid var(--border)'};border-radius:var(--radius-lg);padding:20px;position:relative">
|
<div style="background:var(--bg-secondary);border:${p.id === subData.plan.id ? '2px solid var(--accent)' : '1px solid var(--border)'};border-radius:var(--radius-lg);padding:20px;position:relative">
|
||||||
${p.id === subData.plan.id ? '<div style="position:absolute;top:-10px;right:12px;background:var(--accent);color:white;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:500">Current</div>' : ''}
|
${p.id === subData.plan.id ? `<div style="position:absolute;top:-10px;right:12px;background:var(--accent);color:white;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:500">${t('billing.current')}</div>` : ''}
|
||||||
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${p.display_name}</div>
|
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${p.display_name}</div>
|
||||||
<div style="font-size:24px;font-weight:700;color:var(--accent);margin-bottom:12px">
|
<div style="font-size:24px;font-weight:700;color:var(--accent);margin-bottom:12px">
|
||||||
${p.price_monthly > 0 ? `$${p.price_monthly}<span style="font-size:13px;color:var(--text-secondary);font-weight:400">/mo</span>` : 'Free'}
|
${p.price_monthly > 0 ? `$${p.price_monthly}<span style="font-size:13px;color:var(--text-secondary);font-weight:400">${t('billing.per_month')}</span>` : t('billing.free')}
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:13px;color:var(--text-secondary);line-height:2">
|
<div style="font-size:13px;color:var(--text-secondary);line-height:2">
|
||||||
<div>${p.max_devices === -1 ? 'Unlimited' : p.max_devices} devices</div>
|
<div>${p.max_devices === -1 ? t('billing.unlimited') : p.max_devices} ${t('billing.devices_lc')}</div>
|
||||||
<div>${p.max_storage_mb === -1 ? 'Unlimited' : (p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024) + ' GB' : p.max_storage_mb + ' MB')} storage</div>
|
<div>${p.max_storage_mb === -1 ? t('billing.unlimited') : (p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024) + ' GB' : p.max_storage_mb + ' MB')} ${t('billing.storage_lc')}</div>
|
||||||
<div>${p.remote_control ? '✓' : '✗'} Remote Control</div>
|
<div>${p.remote_control ? '✓' : '✗'} ${t('billing.feat.remote_control')}</div>
|
||||||
<div>${p.remote_url ? '✓' : '✗'} Remote URLs</div>
|
<div>${p.remote_url ? '✓' : '✗'} ${t('billing.feat.remote_urls')}</div>
|
||||||
<div>${p.priority_support ? '✓' : '✗'} Priority Support</div>
|
<div>${p.priority_support ? '✓' : '✗'} ${t('billing.feat.priority_support')}</div>
|
||||||
</div>
|
</div>
|
||||||
${p.price_yearly > 0 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:8px">or $${p.price_yearly}/year (save ${Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100)}%)</div>` : ''}
|
${p.price_yearly > 0 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:8px">${t('billing.yearly_save', { price: p.price_yearly, pct: Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100) })}</div>` : ''}
|
||||||
${!subData.self_hosted && p.price_monthly > 0 && p.id !== subData.plan.id ? `
|
${!subData.self_hosted && p.price_monthly > 0 && p.id !== subData.plan.id ? `
|
||||||
<div style="margin-top:12px;display:flex;gap:6px">
|
<div style="margin-top:12px;display:flex;gap:6px">
|
||||||
<button class="btn btn-primary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','monthly')">Monthly</button>
|
<button class="btn btn-primary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','monthly')">${t('billing.monthly')}</button>
|
||||||
${p.price_yearly > 0 ? `<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','yearly')">Yearly</button>` : ''}
|
${p.price_yearly > 0 ? `<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','yearly')">${t('billing.yearly')}</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
${!subData.self_hosted && p.id === subData.plan.id && subData.subscription?.stripe_subscription_id ? `
|
${!subData.self_hosted && p.id === subData.plan.id && subData.subscription?.stripe_subscription_id ? `
|
||||||
<button class="btn btn-secondary btn-sm" style="width:100%;margin-top:12px" onclick="window._manageSubscription()">Manage Subscription</button>
|
<button class="btn btn-secondary btn-sm" style="width:100%;margin-top:12px" onclick="window._manageSubscription()">${t('billing.manage_subscription')}</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
${subData.self_hosted ? '<p style="color:var(--text-muted);font-size:12px;margin-top:12px">Self-hosted mode: plans can be assigned by admins without billing.</p>' : ''}
|
${subData.self_hosted ? `<p style="color:var(--text-muted);font-size:12px;margin-top:12px">${t('billing.self_hosted_note')}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
// Checkout handler
|
|
||||||
window._checkout = async (planId, interval) => {
|
window._checkout = async (planId, interval) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/stripe/checkout', {
|
const res = await fetch('/api/stripe/checkout', {
|
||||||
|
|
@ -115,11 +114,10 @@ export async function render(container) {
|
||||||
if (data.error) { showToast(data.error, 'error'); return; }
|
if (data.error) { showToast(data.error, 'error'); return; }
|
||||||
if (data.url) window.location.href = data.url;
|
if (data.url) window.location.href = data.url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('Failed to start checkout: ' + err.message, 'error');
|
showToast(t('billing.toast.checkout_failed', { error: err.message }), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Manage subscription handler (Stripe Customer Portal)
|
|
||||||
window._manageSubscription = async () => {
|
window._manageSubscription = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/stripe/portal', {
|
const res = await fetch('/api/stripe/portal', {
|
||||||
|
|
@ -130,18 +128,17 @@ export async function render(container) {
|
||||||
if (data.error) { showToast(data.error, 'error'); return; }
|
if (data.error) { showToast(data.error, 'error'); return; }
|
||||||
if (data.url) window.location.href = data.url;
|
if (data.url) window.location.href = data.url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('Failed to open billing portal: ' + err.message, 'error');
|
showToast(t('billing.toast.portal_failed', { error: err.message }), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for payment success/cancel in URL
|
|
||||||
if (window.location.hash.includes('payment=success')) {
|
if (window.location.hash.includes('payment=success')) {
|
||||||
showToast('Payment successful! Your plan has been upgraded.', 'success');
|
showToast(t('billing.toast.payment_success'), 'success');
|
||||||
window.location.hash = '#/billing';
|
window.location.hash = '#/billing';
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>Failed to load</h3><p>${esc(err.message)}</p></div>`;
|
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>${t('billing.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +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 { t, tn } from '../i18n.js';
|
||||||
|
|
||||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||||
|
|
||||||
|
|
@ -15,22 +16,22 @@ export async function render(container) {
|
||||||
async function renderList(container) {
|
async function renderList(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>Layouts <span class="help-tip" data-tip="Create multi-zone screen layouts. Use templates or build custom ones. Drag zones to position, resize with corner handle. Assign layouts to devices in the Playlist tab.">?</span></h1><div class="subtitle">Screen layouts and templates</div></div>
|
<div><h1>${t('layout.title')} <span class="help-tip" data-tip="${t('layout.help_tip')}">?</span></h1><div class="subtitle">${t('layout.subtitle')}</div></div>
|
||||||
<button class="btn btn-primary" id="newLayoutBtn">
|
<button class="btn btn-primary" id="newLayoutBtn">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
New Layout
|
${t('layout.new_layout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 style="margin-bottom:12px;font-size:14px;color:var(--text-secondary)">Templates</h3>
|
<h3 style="margin-bottom:12px;font-size:14px;color:var(--text-secondary)">${t('layout.templates')}</h3>
|
||||||
<div class="content-grid" id="templateGrid"></div>
|
<div class="content-grid" id="templateGrid"></div>
|
||||||
<h3 style="margin:24px 0 12px;font-size:14px;color:var(--text-secondary)">My Layouts</h3>
|
<h3 style="margin:24px 0 12px;font-size:14px;color:var(--text-secondary)">${t('layout.my_layouts')}</h3>
|
||||||
<div class="content-grid" id="layoutGrid"></div>
|
<div class="content-grid" id="layoutGrid"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('newLayoutBtn').onclick = async () => {
|
document.getElementById('newLayoutBtn').onclick = async () => {
|
||||||
const name = prompt('Layout name:');
|
const name = prompt(t('layout.prompt_name'));
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const layout = await API('/layouts', { method: 'POST', body: JSON.stringify({ name, zones: [{ name: 'Main', x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 100 }] }) });
|
const layout = await API('/layouts', { method: 'POST', body: JSON.stringify({ name, zones: [{ name: t('layout.default_zone_name'), x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 100 }] }) });
|
||||||
window.location.hash = `#/layout/${layout.id}`;
|
window.location.hash = `#/layout/${layout.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -41,9 +42,8 @@ async function renderList(container) {
|
||||||
|
|
||||||
document.getElementById('templateGrid').innerHTML = templates.map(l => renderLayoutCard(l, true)).join('');
|
document.getElementById('templateGrid').innerHTML = templates.map(l => renderLayoutCard(l, true)).join('');
|
||||||
document.getElementById('layoutGrid').innerHTML = custom.length ? custom.map(l => renderLayoutCard(l, false)).join('') :
|
document.getElementById('layoutGrid').innerHTML = custom.length ? custom.map(l => renderLayoutCard(l, false)).join('') :
|
||||||
'<div class="empty-state" style="grid-column:1/-1"><p>No custom layouts yet</p></div>';
|
`<div class="empty-state" style="grid-column:1/-1"><p>${t('layout.empty_custom')}</p></div>`;
|
||||||
|
|
||||||
// Use template click
|
|
||||||
container.querySelectorAll('[data-use-template]').forEach(btn => {
|
container.querySelectorAll('[data-use-template]').forEach(btn => {
|
||||||
btn.onclick = async () => {
|
btn.onclick = async () => {
|
||||||
const layout = await API(`/layouts/${btn.dataset.useTemplate}/duplicate`, { method: 'POST', body: '{}' });
|
const layout = await API(`/layouts/${btn.dataset.useTemplate}/duplicate`, { method: 'POST', body: '{}' });
|
||||||
|
|
@ -51,23 +51,21 @@ async function renderList(container) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit layout click
|
|
||||||
container.querySelectorAll('[data-edit-layout]').forEach(btn => {
|
container.querySelectorAll('[data-edit-layout]').forEach(btn => {
|
||||||
btn.onclick = () => { window.location.hash = `#/layout/${btn.dataset.editLayout}`; };
|
btn.onclick = () => { window.location.hash = `#/layout/${btn.dataset.editLayout}`; };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete layout click
|
|
||||||
container.querySelectorAll('[data-delete-layout]').forEach(btn => {
|
container.querySelectorAll('[data-delete-layout]').forEach(btn => {
|
||||||
btn.onclick = async (e) => {
|
btn.onclick = async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const name = btn.dataset.layoutName;
|
const name = btn.dataset.layoutName;
|
||||||
if (!confirm(`Delete layout "${name}"? This cannot be undone.`)) return;
|
if (!confirm(t('layout.confirm_delete', { name }))) return;
|
||||||
try {
|
try {
|
||||||
await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' });
|
await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' });
|
||||||
showToast('Layout deleted');
|
showToast(t('layout.toast.deleted'));
|
||||||
renderList(container);
|
renderList(container);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || 'Failed to delete layout', 'error');
|
showToast(err.message || t('layout.toast.delete_failed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -77,6 +75,8 @@ async function renderList(container) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLayoutCard(layout, isTemplate) {
|
function renderLayoutCard(layout, isTemplate) {
|
||||||
|
const zoneCount = layout.zones?.length || 0;
|
||||||
|
const zonesText = tn('layout.zone_count', zoneCount);
|
||||||
return `
|
return `
|
||||||
<div class="content-item" style="cursor:pointer">
|
<div class="content-item" style="cursor:pointer">
|
||||||
<div class="content-item-preview" style="position:relative;background:var(--bg-primary)">
|
<div class="content-item-preview" style="position:relative;background:var(--bg-primary)">
|
||||||
|
|
@ -90,14 +90,14 @@ function renderLayoutCard(layout, isTemplate) {
|
||||||
</div>
|
</div>
|
||||||
<div class="content-item-body">
|
<div class="content-item-body">
|
||||||
<div class="content-item-name">${layout.name}</div>
|
<div class="content-item-name">${layout.name}</div>
|
||||||
<div class="content-item-size">${layout.zones?.length || 0} zone(s) ${isTemplate ? '• Template' : ''}</div>
|
<div class="content-item-size">${zonesText}${isTemplate ? ' • ' + t('layout.template_label') : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-item-actions">
|
<div class="content-item-actions">
|
||||||
${isTemplate
|
${isTemplate
|
||||||
? `<button class="btn btn-primary btn-sm" data-use-template="${layout.id}">Use Template</button>`
|
? `<button class="btn btn-primary btn-sm" data-use-template="${layout.id}">${t('layout.use_template')}</button>`
|
||||||
: `<button class="btn btn-secondary btn-sm" data-edit-layout="${layout.id}">Edit</button>`
|
: `<button class="btn btn-secondary btn-sm" data-edit-layout="${layout.id}">${t('common.edit')}</button>`
|
||||||
}
|
}
|
||||||
<button class="btn btn-danger btn-sm" data-delete-layout="${layout.id}" data-layout-name="${layout.name}" style="margin-left:4px">Delete</button>
|
<button class="btn btn-danger btn-sm" data-delete-layout="${layout.id}" data-layout-name="${layout.name}" style="margin-left:4px">${t('common.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -107,44 +107,43 @@ async function renderEditor(container, layoutId) {
|
||||||
let layout;
|
let layout;
|
||||||
try {
|
try {
|
||||||
layout = await API(`/layouts/${layoutId}`);
|
layout = await API(`/layouts/${layoutId}`);
|
||||||
} catch { container.innerHTML = '<div class="empty-state"><h3>Layout not found</h3></div>'; return; }
|
} catch { container.innerHTML = `<div class="empty-state"><h3>${t('layout.not_found')}</h3></div>`; return; }
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<a href="#/layouts" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px">
|
<a href="#/layouts" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px">
|
||||||
<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"/></svg>
|
<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"/></svg>
|
||||||
Back to Layouts
|
${t('layout.back')}
|
||||||
</a>
|
</a>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 id="layoutName">${layout.name}</h1>
|
<h1 id="layoutName">${layout.name}</h1>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button class="btn btn-secondary btn-sm" id="addZoneBtn">Add Zone</button>
|
<button class="btn btn-secondary btn-sm" id="addZoneBtn">${t('layout.add_zone')}</button>
|
||||||
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">Save</button>
|
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">${t('common.save')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:20px">
|
<div style="display:flex;gap:20px">
|
||||||
<div style="flex:1">
|
<div style="flex:1">
|
||||||
<div id="canvasWrap" style="position:relative;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden">
|
<div id="canvasWrap" style="position:relative;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden">
|
||||||
<div id="canvas" style="position:relative;width:100%;padding-top:56.25%">
|
<div id="canvas" style="position:relative;width:100%;padding-top:56.25%">
|
||||||
<!-- Zones rendered here -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="width:280px">
|
<div style="width:280px">
|
||||||
<h3 style="font-size:14px;margin-bottom:12px">Zones</h3>
|
<h3 style="font-size:14px;margin-bottom:12px">${t('layout.zones')}</h3>
|
||||||
<div id="zoneList"></div>
|
<div id="zoneList"></div>
|
||||||
<div id="zoneProperties" style="margin-top:16px;display:none">
|
<div id="zoneProperties" style="margin-top:16px;display:none">
|
||||||
<h3 style="font-size:14px;margin-bottom:12px">Properties</h3>
|
<h3 style="font-size:14px;margin-bottom:12px">${t('layout.properties')}</h3>
|
||||||
<div class="form-group"><label>Name</label><input type="text" id="propName" class="input"></div>
|
<div class="form-group"><label>${t('layout.prop.name')}</label><input type="text" id="propName" class="input"></div>
|
||||||
<div class="form-group"><label>X (%)</label><input type="number" id="propX" class="input" min="0" max="100" step="0.1"></div>
|
<div class="form-group"><label>${t('layout.prop.x')}</label><input type="number" id="propX" class="input" min="0" max="100" step="0.1"></div>
|
||||||
<div class="form-group"><label>Y (%)</label><input type="number" id="propY" class="input" min="0" max="100" step="0.1"></div>
|
<div class="form-group"><label>${t('layout.prop.y')}</label><input type="number" id="propY" class="input" min="0" max="100" step="0.1"></div>
|
||||||
<div class="form-group"><label>Width (%)</label><input type="number" id="propW" class="input" min="1" max="100" step="0.1"></div>
|
<div class="form-group"><label>${t('layout.prop.width')}</label><input type="number" id="propW" class="input" min="1" max="100" step="0.1"></div>
|
||||||
<div class="form-group"><label>Height (%)</label><input type="number" id="propH" class="input" min="1" max="100" step="0.1"></div>
|
<div class="form-group"><label>${t('layout.prop.height')}</label><input type="number" id="propH" class="input" min="1" max="100" step="0.1"></div>
|
||||||
<div class="form-group"><label>Type</label>
|
<div class="form-group"><label>${t('layout.prop.type')}</label>
|
||||||
<select id="propType" class="input" style="background:var(--bg-input)">
|
<select id="propType" class="input" style="background:var(--bg-input)">
|
||||||
<option value="content">Content</option><option value="widget">Widget</option>
|
<option value="content">${t('layout.type_content')}</option><option value="widget">${t('layout.type_widget')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-danger btn-sm" id="deleteZoneBtn" style="width:100%;justify-content:center;margin-top:8px">Delete Zone</button>
|
<button class="btn btn-danger btn-sm" id="deleteZoneBtn" style="width:100%;justify-content:center;margin-top:8px">${t('layout.delete_zone')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -156,7 +155,6 @@ async function renderEditor(container, layoutId) {
|
||||||
|
|
||||||
function renderZones() {
|
function renderZones() {
|
||||||
const canvas = document.getElementById('canvas');
|
const canvas = document.getElementById('canvas');
|
||||||
// Clear only zone divs
|
|
||||||
canvas.querySelectorAll('.zone-el').forEach(z => z.remove());
|
canvas.querySelectorAll('.zone-el').forEach(z => z.remove());
|
||||||
|
|
||||||
zones.forEach((z, i) => {
|
zones.forEach((z, i) => {
|
||||||
|
|
@ -170,7 +168,6 @@ async function renderEditor(container, layoutId) {
|
||||||
user-select:none;z-index:${z.z_index || 0}`;
|
user-select:none;z-index:${z.z_index || 0}`;
|
||||||
el.textContent = z.name;
|
el.textContent = z.name;
|
||||||
|
|
||||||
// Drag to move
|
|
||||||
el.onmousedown = (e) => {
|
el.onmousedown = (e) => {
|
||||||
if (e.target !== el) return;
|
if (e.target !== el) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -200,7 +197,6 @@ async function renderEditor(container, layoutId) {
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resize handle
|
|
||||||
const handle = document.createElement('div');
|
const handle = document.createElement('div');
|
||||||
handle.style.cssText = 'position:absolute;right:0;bottom:0;width:12px;height:12px;cursor:se-resize;background:var(--accent);border-radius:2px 0 0 0;opacity:0.7';
|
handle.style.cssText = 'position:absolute;right:0;bottom:0;width:12px;height:12px;cursor:se-resize;background:var(--accent);border-radius:2px 0 0 0;opacity:0.7';
|
||||||
handle.onmousedown = (e) => {
|
handle.onmousedown = (e) => {
|
||||||
|
|
@ -228,7 +224,6 @@ async function renderEditor(container, layoutId) {
|
||||||
canvas.appendChild(el);
|
canvas.appendChild(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Zone list sidebar
|
|
||||||
document.getElementById('zoneList').innerHTML = zones.map((z, i) => `
|
document.getElementById('zoneList').innerHTML = zones.map((z, i) => `
|
||||||
<div style="padding:8px 10px;background:${selectedZone === i ? 'var(--bg-card-hover)' : 'var(--bg-secondary)'};
|
<div style="padding:8px 10px;background:${selectedZone === i ? 'var(--bg-card-hover)' : 'var(--bg-secondary)'};
|
||||||
border:1px solid ${selectedZone === i ? 'var(--accent)' : 'var(--border)'};border-radius:var(--radius);
|
border:1px solid ${selectedZone === i ? 'var(--accent)' : 'var(--border)'};border-radius:var(--radius);
|
||||||
|
|
@ -256,7 +251,6 @@ async function renderEditor(container, layoutId) {
|
||||||
document.getElementById('propType').value = z.zone_type;
|
document.getElementById('propType').value = z.zone_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Property input handlers
|
|
||||||
['propName', 'propX', 'propY', 'propW', 'propH', 'propType'].forEach(id => {
|
['propName', 'propX', 'propY', 'propW', 'propH', 'propType'].forEach(id => {
|
||||||
document.getElementById(id).oninput = () => {
|
document.getElementById(id).oninput = () => {
|
||||||
if (selectedZone === null) return;
|
if (selectedZone === null) return;
|
||||||
|
|
@ -272,7 +266,7 @@ async function renderEditor(container, layoutId) {
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('addZoneBtn').onclick = () => {
|
document.getElementById('addZoneBtn').onclick = () => {
|
||||||
zones.push({ id: null, name: `Zone ${zones.length + 1}`, x_percent: 10, y_percent: 10, width_percent: 30, height_percent: 30, z_index: 0, zone_type: 'content', fit_mode: 'cover', background_color: '#000000', sort_order: zones.length });
|
zones.push({ id: null, name: t('layout.zone_n', { n: zones.length + 1 }), x_percent: 10, y_percent: 10, width_percent: 30, height_percent: 30, z_index: 0, zone_type: 'content', fit_mode: 'cover', background_color: '#000000', sort_order: zones.length });
|
||||||
selectedZone = zones.length - 1;
|
selectedZone = zones.length - 1;
|
||||||
renderZones();
|
renderZones();
|
||||||
updateProperties();
|
updateProperties();
|
||||||
|
|
@ -288,14 +282,13 @@ async function renderEditor(container, layoutId) {
|
||||||
|
|
||||||
document.getElementById('saveLayoutBtn').onclick = async () => {
|
document.getElementById('saveLayoutBtn').onclick = async () => {
|
||||||
try {
|
try {
|
||||||
// Delete existing zones and recreate
|
|
||||||
for (const z of layout.zones || []) {
|
for (const z of layout.zones || []) {
|
||||||
await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' });
|
await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
for (const z of zones) {
|
for (const z of zones) {
|
||||||
await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) });
|
await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) });
|
||||||
}
|
}
|
||||||
showToast('Layout saved', 'success');
|
showToast(t('layout.toast.saved'), 'success');
|
||||||
layout = await API(`/layouts/${layoutId}`);
|
layout = await API(`/layouts/${layoutId}`);
|
||||||
zones = layout.zones;
|
zones = layout.zones;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.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 } from '../i18n.js';
|
||||||
|
|
||||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||||
|
|
||||||
|
|
@ -16,17 +17,17 @@ export async function render(container) {
|
||||||
async function renderList(container) {
|
async function renderList(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>Video Walls <span class="help-tip" data-tip="Combine multiple displays into one large screen. Set grid size, drag devices into positions, adjust bezel compensation. Assign content to play across all devices.">?</span></h1><div class="subtitle">Combine multiple displays into one large screen</div></div>
|
<div><h1>${t('wall.title')} <span class="help-tip" data-tip="${t('wall.help_tip')}">?</span></h1><div class="subtitle">${t('wall.subtitle')}</div></div>
|
||||||
<button class="btn btn-primary" id="newWallBtn">
|
<button class="btn btn-primary" id="newWallBtn">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
New Video Wall
|
${t('wall.new_wall')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-grid" id="wallGrid"></div>
|
<div class="content-grid" id="wallGrid"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('newWallBtn').onclick = async () => {
|
document.getElementById('newWallBtn').onclick = async () => {
|
||||||
const name = prompt('Video wall name:');
|
const name = prompt(t('wall.prompt_name'));
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const wall = await API('/walls', { method: 'POST', body: JSON.stringify({ name }) });
|
const wall = await API('/walls', { method: 'POST', body: JSON.stringify({ name }) });
|
||||||
window.location.hash = `#/wall/${wall.id}`;
|
window.location.hash = `#/wall/${wall.id}`;
|
||||||
|
|
@ -37,7 +38,7 @@ async function renderList(container) {
|
||||||
const grid = document.getElementById('wallGrid');
|
const grid = document.getElementById('wallGrid');
|
||||||
|
|
||||||
if (!walls.length) {
|
if (!walls.length) {
|
||||||
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><h3>No video walls yet</h3><p>Create a video wall to combine multiple displays.</p></div>';
|
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('wall.empty_title')}</h3><p>${t('wall.empty_desc')}</p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,7 +56,7 @@ async function renderList(container) {
|
||||||
</div>
|
</div>
|
||||||
<div class="content-item-body">
|
<div class="content-item-body">
|
||||||
<div class="content-item-name">${w.name}</div>
|
<div class="content-item-name">${w.name}</div>
|
||||||
<div class="content-item-size">${w.grid_cols}x${w.grid_rows} grid • ${w.devices?.length || 0} devices</div>
|
<div class="content-item-size">${t('wall.grid_summary', { cols: w.grid_cols, rows: w.grid_rows, n: w.devices?.length || 0 })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
@ -66,7 +67,7 @@ async function renderWallEditor(container, wallId) {
|
||||||
let wall, devices;
|
let wall, devices;
|
||||||
try {
|
try {
|
||||||
[wall, devices] = await Promise.all([API(`/walls/${wallId}`), api.getDevices()]);
|
[wall, devices] = await Promise.all([API(`/walls/${wallId}`), api.getDevices()]);
|
||||||
} catch { container.innerHTML = '<div class="empty-state"><h3>Wall not found</h3></div>'; return; }
|
} catch { container.innerHTML = `<div class="empty-state"><h3>${t('wall.not_found')}</h3></div>`; return; }
|
||||||
|
|
||||||
const content = await api.getContent();
|
const content = await api.getContent();
|
||||||
const unassigned = devices.filter(d => !wall.devices?.find(wd => wd.device_id === d.id));
|
const unassigned = devices.filter(d => !wall.devices?.find(wd => wd.device_id === d.id));
|
||||||
|
|
@ -74,38 +75,38 @@ async function renderWallEditor(container, wallId) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<a href="#/walls" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px">
|
<a href="#/walls" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px">
|
||||||
<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"/></svg>
|
<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"/></svg>
|
||||||
Back to Video Walls
|
${t('wall.back')}
|
||||||
</a>
|
</a>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>${wall.name}</h1>
|
<h1>${wall.name}</h1>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button class="btn btn-danger btn-sm" id="deleteWallBtn">Delete Wall</button>
|
<button class="btn btn-danger btn-sm" id="deleteWallBtn">${t('wall.delete_wall')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:24px">
|
<div style="display:flex;gap:24px">
|
||||||
<div style="flex:1">
|
<div style="flex:1">
|
||||||
<h3 style="font-size:14px;margin-bottom:12px">Grid Configuration</h3>
|
<h3 style="font-size:14px;margin-bottom:12px">${t('wall.grid_config')}</h3>
|
||||||
<div style="display:flex;gap:12px;margin-bottom:16px">
|
<div style="display:flex;gap:12px;margin-bottom:16px">
|
||||||
<div class="form-group" style="margin:0"><label>Columns</label><input type="number" id="gridCols" class="input" value="${wall.grid_cols}" min="1" max="10" style="width:80px"></div>
|
<div class="form-group" style="margin:0"><label>${t('wall.columns')}</label><input type="number" id="gridCols" class="input" value="${wall.grid_cols}" min="1" max="10" style="width:80px"></div>
|
||||||
<div class="form-group" style="margin:0"><label>Rows</label><input type="number" id="gridRows" class="input" value="${wall.grid_rows}" min="1" max="10" style="width:80px"></div>
|
<div class="form-group" style="margin:0"><label>${t('wall.rows')}</label><input type="number" id="gridRows" class="input" value="${wall.grid_rows}" min="1" max="10" style="width:80px"></div>
|
||||||
<div class="form-group" style="margin:0"><label>H Bezel (mm)</label><input type="number" id="bezelH" class="input" value="${wall.bezel_h_mm}" min="0" step="0.5" style="width:80px"></div>
|
<div class="form-group" style="margin:0"><label>${t('wall.h_bezel')}</label><input type="number" id="bezelH" class="input" value="${wall.bezel_h_mm}" min="0" step="0.5" style="width:80px"></div>
|
||||||
<div class="form-group" style="margin:0"><label>V Bezel (mm)</label><input type="number" id="bezelV" class="input" value="${wall.bezel_v_mm}" min="0" step="0.5" style="width:80px"></div>
|
<div class="form-group" style="margin:0"><label>${t('wall.v_bezel')}</label><input type="number" id="bezelV" class="input" value="${wall.bezel_v_mm}" min="0" step="0.5" style="width:80px"></div>
|
||||||
<button class="btn btn-primary btn-sm" id="updateGridBtn" style="align-self:flex-end">Update</button>
|
<button class="btn btn-primary btn-sm" id="updateGridBtn" style="align-self:flex-end">${t('wall.update')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="wallGrid" style="display:inline-grid;gap:4px;background:var(--bg-primary);padding:16px;border:1px solid var(--border);border-radius:var(--radius-lg)"></div>
|
<div id="wallGrid" style="display:inline-grid;gap:4px;background:var(--bg-primary);padding:16px;border:1px solid var(--border);border-radius:var(--radius-lg)"></div>
|
||||||
|
|
||||||
<h3 style="font-size:14px;margin:24px 0 12px">Content</h3>
|
<h3 style="font-size:14px;margin:24px 0 12px">${t('wall.content')}</h3>
|
||||||
<select id="wallContent" class="input" style="width:300px;background:var(--bg-input)">
|
<select id="wallContent" class="input" style="width:300px;background:var(--bg-input)">
|
||||||
<option value="">No content</option>
|
<option value="">${t('wall.no_content')}</option>
|
||||||
${content.filter(c => c.mime_type?.startsWith('video/')).map(c => `<option value="${c.id}" ${c.id === wall.content_id ? 'selected' : ''}>${esc(c.filename)}</option>`).join('')}
|
${content.filter(c => c.mime_type?.startsWith('video/')).map(c => `<option value="${c.id}" ${c.id === wall.content_id ? 'selected' : ''}>${esc(c.filename)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-primary btn-sm" id="setContentBtn" style="margin-left:8px">Set Content</button>
|
<button class="btn btn-primary btn-sm" id="setContentBtn" style="margin-left:8px">${t('wall.set_content')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="width:250px">
|
<div style="width:250px">
|
||||||
<h3 style="font-size:14px;margin-bottom:12px">Available Displays</h3>
|
<h3 style="font-size:14px;margin-bottom:12px">${t('wall.available_displays')}</h3>
|
||||||
<div id="availableDevices">
|
<div id="availableDevices">
|
||||||
${unassigned.map(d => `
|
${unassigned.map(d => `
|
||||||
<div class="playlist-item" style="cursor:grab;margin-bottom:4px" draggable="true" data-device-id="${d.id}" data-device-name="${d.name}">
|
<div class="playlist-item" style="cursor:grab;margin-bottom:4px" draggable="true" data-device-id="${d.id}" data-device-name="${d.name}">
|
||||||
|
|
@ -114,7 +115,7 @@ async function renderWallEditor(container, wallId) {
|
||||||
<div class="playlist-item-meta"><span class="status-dot ${d.status}" style="display:inline-block"></span> ${d.status}</div>
|
<div class="playlist-item-meta"><span class="status-dot ${d.status}" style="display:inline-block"></span> ${d.status}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('') || '<p style="color:var(--text-muted);font-size:12px">All devices assigned</p>'}
|
`).join('') || `<p style="color:var(--text-muted);font-size:12px">${t('wall.all_assigned')}</p>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,14 +137,13 @@ async function renderWallEditor(container, wallId) {
|
||||||
display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:11px;color:var(--text-secondary)"
|
display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:11px;color:var(--text-secondary)"
|
||||||
data-grid-col="${c}" data-grid-row="${r}">
|
data-grid-col="${c}" data-grid-row="${r}">
|
||||||
${dev ? `<div style="font-weight:500">${dev.device_name}</div><div style="font-size:9px;color:var(--text-muted)">[${c},${r}]</div>` :
|
${dev ? `<div style="font-weight:500">${dev.device_name}</div><div style="font-size:9px;color:var(--text-muted)">[${c},${r}]</div>` :
|
||||||
`<div style="color:var(--text-muted)">Drop here</div><div style="font-size:9px">[${c},${r}]</div>`}
|
`<div style="color:var(--text-muted)">${t('wall.drop_here')}</div><div style="font-size:9px">[${c},${r}]</div>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
grid.innerHTML = html;
|
grid.innerHTML = html;
|
||||||
|
|
||||||
// Drop targets
|
|
||||||
grid.querySelectorAll('[data-grid-col]').forEach(cell => {
|
grid.querySelectorAll('[data-grid-col]').forEach(cell => {
|
||||||
cell.ondragover = (e) => { e.preventDefault(); cell.style.borderColor = 'var(--success)'; };
|
cell.ondragover = (e) => { e.preventDefault(); cell.style.borderColor = 'var(--success)'; };
|
||||||
cell.ondragleave = () => { cell.style.borderColor = ''; };
|
cell.ondragleave = () => { cell.style.borderColor = ''; };
|
||||||
|
|
@ -155,7 +155,6 @@ async function renderWallEditor(container, wallId) {
|
||||||
const col = parseInt(cell.dataset.gridCol);
|
const col = parseInt(cell.dataset.gridCol);
|
||||||
const row = parseInt(cell.dataset.gridRow);
|
const row = parseInt(cell.dataset.gridRow);
|
||||||
|
|
||||||
// Add to wall devices
|
|
||||||
const existing = wall.devices?.filter(d => !(d.grid_col === col && d.grid_row === row)) || [];
|
const existing = wall.devices?.filter(d => !(d.grid_col === col && d.grid_row === row)) || [];
|
||||||
existing.push({ device_id: deviceId, device_name: deviceName, grid_col: col, grid_row: row });
|
existing.push({ device_id: deviceId, device_name: deviceName, grid_col: col, grid_row: row });
|
||||||
|
|
||||||
|
|
@ -163,13 +162,12 @@ async function renderWallEditor(container, wallId) {
|
||||||
const updated = await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: existing }) });
|
const updated = await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: existing }) });
|
||||||
wall.devices = updated.devices;
|
wall.devices = updated.devices;
|
||||||
renderGrid();
|
renderGrid();
|
||||||
showToast(`${deviceName} placed at [${col},${row}]`, 'success');
|
showToast(t('wall.toast.placed', { name: deviceName, col, row }), 'success');
|
||||||
} catch (err) { showToast(err.message, 'error'); }
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag sources
|
|
||||||
container.querySelectorAll('[draggable]').forEach(el => {
|
container.querySelectorAll('[draggable]').forEach(el => {
|
||||||
el.ondragstart = (e) => {
|
el.ondragstart = (e) => {
|
||||||
e.dataTransfer.setData('device-id', el.dataset.deviceId);
|
e.dataTransfer.setData('device-id', el.dataset.deviceId);
|
||||||
|
|
@ -188,7 +186,7 @@ async function renderWallEditor(container, wallId) {
|
||||||
wall.grid_cols = parseInt(document.getElementById('gridCols').value);
|
wall.grid_cols = parseInt(document.getElementById('gridCols').value);
|
||||||
wall.grid_rows = parseInt(document.getElementById('gridRows').value);
|
wall.grid_rows = parseInt(document.getElementById('gridRows').value);
|
||||||
renderGrid();
|
renderGrid();
|
||||||
showToast('Grid updated', 'success');
|
showToast(t('wall.toast.grid_updated'), 'success');
|
||||||
} catch (err) { showToast(err.message, 'error'); }
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -196,14 +194,14 @@ async function renderWallEditor(container, wallId) {
|
||||||
const contentId = document.getElementById('wallContent').value;
|
const contentId = document.getElementById('wallContent').value;
|
||||||
try {
|
try {
|
||||||
await API(`/walls/${wallId}/content`, { method: 'PUT', body: JSON.stringify({ content_id: contentId || null }) });
|
await API(`/walls/${wallId}/content`, { method: 'PUT', body: JSON.stringify({ content_id: contentId || null }) });
|
||||||
showToast('Content updated', 'success');
|
showToast(t('wall.toast.content_updated'), 'success');
|
||||||
} catch (err) { showToast(err.message, 'error'); }
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('deleteWallBtn').onclick = async () => {
|
document.getElementById('deleteWallBtn').onclick = async () => {
|
||||||
try {
|
try {
|
||||||
await API(`/walls/${wallId}`, { method: 'DELETE' });
|
await API(`/walls/${wallId}`, { method: 'DELETE' });
|
||||||
showToast('Wall deleted', 'success');
|
showToast(t('wall.toast.deleted'), 'success');
|
||||||
window.location.hash = '#/walls';
|
window.location.hash = '#/walls';
|
||||||
} catch (err) { showToast(err.message, 'error'); }
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue