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:
ScreenTinker 2026-04-29 20:13:38 -05:00
parent f4a81d7be2
commit 7a17bb5079
8 changed files with 563 additions and 105 deletions

View file

@ -889,4 +889,98 @@ export default {
'kiosk.url_placeholder': 'URL oder Seite',
'kiosk.no_buttons': 'Noch keine Buttons',
'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.',
};

View file

@ -925,4 +925,98 @@ export default {
'kiosk.url_placeholder': 'URL or page',
'kiosk.no_buttons': 'No buttons yet',
'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.',
};

View file

@ -888,4 +888,98 @@ export default {
'kiosk.url_placeholder': 'URL o página',
'kiosk.no_buttons': 'Aún no hay botones',
'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.',
};

View file

@ -889,4 +889,98 @@ export default {
'kiosk.url_placeholder': 'URL ou page',
'kiosk.no_buttons': 'Aucun 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.',
};

View file

@ -889,4 +889,98 @@ export default {
'kiosk.url_placeholder': 'URL ou página',
'kiosk.no_buttons': 'Sem botões ainda',
'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.',
};

View file

@ -1,16 +1,17 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t } from '../i18n.js';
export async function render(container) {
container.innerHTML = `
<div class="page-header">
<div>
<h1>Subscription</h1>
<div class="subtitle">Manage your plan and billing</div>
<h1>${t('billing.title')}</h1>
<div class="subtitle">${t('billing.subtitle')}</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 {
@ -22,27 +23,26 @@ export async function render(container) {
const content = document.getElementById('billingContent');
content.innerHTML = `
<!-- Current Plan -->
<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="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.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.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">${t('billing.trial_days_left', { n: subData.trial.days_left })}</span>` : ''}
</div>
${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">
<span style="font-size:20px">&#9201;</span>
<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: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: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)">${t('billing.trial_after')}</div>
</div>
</div>
` : ''}
<div class="info-grid" style="margin-bottom:0">
<div class="info-card">
<div class="info-card-label">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-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 ? t('billing.unlimited') : subData.plan.max_devices}</span></div>
${subData.plan.max_devices > 0 ? `
<div class="progress-bar">
<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 class="info-card">
<div class="info-card-label">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-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 ? t('billing.unlimited') : subData.plan.max_storage_mb + ' MB'}</span></div>
${subData.plan.max_storage_mb > 0 ? `
<div class="progress-bar">
<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 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">
${subData.plan.remote_control ? '<div style="color:var(--success)">&#10003; Remote Control</div>' : '<div style="color:var(--text-muted)">&#10007; Remote Control</div>'}
${subData.plan.remote_url ? '<div style="color:var(--success)">&#10003; Remote URLs</div>' : '<div style="color:var(--text-muted)">&#10007; Remote URLs</div>'}
${subData.plan.priority_support ? '<div style="color:var(--success)">&#10003; Priority Support</div>' : '<div style="color:var(--text-muted)">&#10007; Priority Support</div>'}
${subData.plan.remote_control ? `<div style="color:var(--success)">&#10003; ${t('billing.feat.remote_control')}</div>` : `<div style="color:var(--text-muted)">&#10007; ${t('billing.feat.remote_control')}</div>`}
${subData.plan.remote_url ? `<div style="color:var(--success)">&#10003; ${t('billing.feat.remote_urls')}</div>` : `<div style="color:var(--text-muted)">&#10007; ${t('billing.feat.remote_urls')}</div>`}
${subData.plan.priority_support ? `<div style="color:var(--success)">&#10003; ${t('billing.feat.priority_support')}</div>` : `<div style="color:var(--text-muted)">&#10007; ${t('billing.feat.priority_support')}</div>`}
</div>
</div>
</div>
</div>
<!-- Plans -->
<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">
${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">
${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: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 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_storage_mb === -1 ? 'Unlimited' : (p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024) + ' GB' : p.max_storage_mb + ' MB')} storage</div>
<div>${p.remote_control ? '&#10003;' : '&#10007;'} Remote Control</div>
<div>${p.remote_url ? '&#10003;' : '&#10007;'} Remote URLs</div>
<div>${p.priority_support ? '&#10003;' : '&#10007;'} Priority Support</div>
<div>${p.max_devices === -1 ? t('billing.unlimited') : p.max_devices} ${t('billing.devices_lc')}</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 ? '&#10003;' : '&#10007;'} ${t('billing.feat.remote_control')}</div>
<div>${p.remote_url ? '&#10003;' : '&#10007;'} ${t('billing.feat.remote_urls')}</div>
<div>${p.priority_support ? '&#10003;' : '&#10007;'} ${t('billing.feat.priority_support')}</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 ? `
<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>
${p.price_yearly > 0 ? `<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','yearly')">Yearly</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')">${t('billing.yearly')}</button>` : ''}
</div>
` : ''}
${!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>
`).join('')}
</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>
`;
// Checkout handler
window._checkout = async (planId, interval) => {
try {
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.url) window.location.href = data.url;
} 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 () => {
try {
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.url) window.location.href = data.url;
} 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')) {
showToast('Payment successful! Your plan has been upgraded.', 'success');
showToast(t('billing.toast.payment_success'), 'success');
window.location.hash = '#/billing';
}
} 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>`;
}
}

View file

@ -1,5 +1,6 @@
import { api } from '../api.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());
@ -15,22 +16,22 @@ export async function render(container) {
async function renderList(container) {
container.innerHTML = `
<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">
<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>
</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>
<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>
`;
document.getElementById('newLayoutBtn').onclick = async () => {
const name = prompt('Layout name:');
const name = prompt(t('layout.prompt_name'));
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}`;
};
@ -41,9 +42,8 @@ async function renderList(container) {
document.getElementById('templateGrid').innerHTML = templates.map(l => renderLayoutCard(l, true)).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 => {
btn.onclick = async () => {
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 => {
btn.onclick = () => { window.location.hash = `#/layout/${btn.dataset.editLayout}`; };
});
// Delete layout click
container.querySelectorAll('[data-delete-layout]').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const name = btn.dataset.layoutName;
if (!confirm(`Delete layout "${name}"? This cannot be undone.`)) return;
if (!confirm(t('layout.confirm_delete', { name }))) return;
try {
await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' });
showToast('Layout deleted');
showToast(t('layout.toast.deleted'));
renderList(container);
} 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) {
const zoneCount = layout.zones?.length || 0;
const zonesText = tn('layout.zone_count', zoneCount);
return `
<div class="content-item" style="cursor:pointer">
<div class="content-item-preview" style="position:relative;background:var(--bg-primary)">
@ -90,14 +90,14 @@ function renderLayoutCard(layout, isTemplate) {
</div>
<div class="content-item-body">
<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 class="content-item-actions">
${isTemplate
? `<button class="btn btn-primary btn-sm" data-use-template="${layout.id}">Use Template</button>`
: `<button class="btn btn-secondary btn-sm" data-edit-layout="${layout.id}">Edit</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}">${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>
`;
@ -107,44 +107,43 @@ async function renderEditor(container, layoutId) {
let layout;
try {
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 = `
<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>
Back to Layouts
${t('layout.back')}
</a>
<div class="page-header">
<h1 id="layoutName">${layout.name}</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="addZoneBtn">Add Zone</button>
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">Save</button>
<button class="btn btn-secondary btn-sm" id="addZoneBtn">${t('layout.add_zone')}</button>
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">${t('common.save')}</button>
</div>
</div>
<div style="display:flex;gap:20px">
<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="canvas" style="position:relative;width:100%;padding-top:56.25%">
<!-- Zones rendered here -->
</div>
</div>
</div>
<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="zoneProperties" style="margin-top:16px;display:none">
<h3 style="font-size:14px;margin-bottom:12px">Properties</h3>
<div class="form-group"><label>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>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>Height (%)</label><input type="number" id="propH" class="input" min="1" max="100" step="0.1"></div>
<div class="form-group"><label>Type</label>
<h3 style="font-size:14px;margin-bottom:12px">${t('layout.properties')}</h3>
<div class="form-group"><label>${t('layout.prop.name')}</label><input type="text" id="propName" class="input"></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>${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>${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>${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>${t('layout.prop.type')}</label>
<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>
</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>
@ -156,7 +155,6 @@ async function renderEditor(container, layoutId) {
function renderZones() {
const canvas = document.getElementById('canvas');
// Clear only zone divs
canvas.querySelectorAll('.zone-el').forEach(z => z.remove());
zones.forEach((z, i) => {
@ -170,7 +168,6 @@ async function renderEditor(container, layoutId) {
user-select:none;z-index:${z.z_index || 0}`;
el.textContent = z.name;
// Drag to move
el.onmousedown = (e) => {
if (e.target !== el) return;
e.preventDefault();
@ -200,7 +197,6 @@ async function renderEditor(container, layoutId) {
document.addEventListener('mouseup', onUp);
};
// Resize handle
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.onmousedown = (e) => {
@ -228,7 +224,6 @@ async function renderEditor(container, layoutId) {
canvas.appendChild(el);
});
// Zone list sidebar
document.getElementById('zoneList').innerHTML = zones.map((z, i) => `
<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);
@ -256,7 +251,6 @@ async function renderEditor(container, layoutId) {
document.getElementById('propType').value = z.zone_type;
}
// Property input handlers
['propName', 'propX', 'propY', 'propW', 'propH', 'propType'].forEach(id => {
document.getElementById(id).oninput = () => {
if (selectedZone === null) return;
@ -272,7 +266,7 @@ async function renderEditor(container, layoutId) {
});
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;
renderZones();
updateProperties();
@ -288,14 +282,13 @@ async function renderEditor(container, layoutId) {
document.getElementById('saveLayoutBtn').onclick = async () => {
try {
// Delete existing zones and recreate
for (const z of layout.zones || []) {
await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' });
}
for (const z of zones) {
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}`);
zones = layout.zones;
} catch (err) {

View file

@ -1,6 +1,7 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t } from '../i18n.js';
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) {
container.innerHTML = `
<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">
<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>
</div>
<div class="content-grid" id="wallGrid"></div>
`;
document.getElementById('newWallBtn').onclick = async () => {
const name = prompt('Video wall name:');
const name = prompt(t('wall.prompt_name'));
if (!name) return;
const wall = await API('/walls', { method: 'POST', body: JSON.stringify({ name }) });
window.location.hash = `#/wall/${wall.id}`;
@ -37,7 +38,7 @@ async function renderList(container) {
const grid = document.getElementById('wallGrid');
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;
}
@ -55,7 +56,7 @@ async function renderList(container) {
</div>
<div class="content-item-body">
<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>
`).join('');
@ -66,7 +67,7 @@ async function renderWallEditor(container, wallId) {
let wall, devices;
try {
[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 unassigned = devices.filter(d => !wall.devices?.find(wd => wd.device_id === d.id));
@ -74,38 +75,38 @@ async function renderWallEditor(container, wallId) {
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">
<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>
<div class="page-header">
<h1>${wall.name}</h1>
<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 style="display:flex;gap:24px">
<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 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>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>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>
<button class="btn btn-primary btn-sm" id="updateGridBtn" style="align-self:flex-end">Update</button>
<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>${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>${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>${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">${t('wall.update')}</button>
</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)">
<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('')}
</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 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">
${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}">
@ -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>
</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>
@ -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)"
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>` :
`<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>
`;
}
}
grid.innerHTML = html;
// Drop targets
grid.querySelectorAll('[data-grid-col]').forEach(cell => {
cell.ondragover = (e) => { e.preventDefault(); cell.style.borderColor = 'var(--success)'; };
cell.ondragleave = () => { cell.style.borderColor = ''; };
@ -155,7 +155,6 @@ async function renderWallEditor(container, wallId) {
const col = parseInt(cell.dataset.gridCol);
const row = parseInt(cell.dataset.gridRow);
// Add to wall devices
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 });
@ -163,13 +162,12 @@ async function renderWallEditor(container, wallId) {
const updated = await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: existing }) });
wall.devices = updated.devices;
renderGrid();
showToast(`${deviceName} placed at [${col},${row}]`, 'success');
showToast(t('wall.toast.placed', { name: deviceName, col, row }), 'success');
} catch (err) { showToast(err.message, 'error'); }
};
});
}
// Drag sources
container.querySelectorAll('[draggable]').forEach(el => {
el.ondragstart = (e) => {
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_rows = parseInt(document.getElementById('gridRows').value);
renderGrid();
showToast('Grid updated', 'success');
showToast(t('wall.toast.grid_updated'), 'success');
} catch (err) { showToast(err.message, 'error'); }
};
@ -196,14 +194,14 @@ async function renderWallEditor(container, wallId) {
const contentId = document.getElementById('wallContent').value;
try {
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'); }
};
document.getElementById('deleteWallBtn').onclick = async () => {
try {
await API(`/walls/${wallId}`, { method: 'DELETE' });
showToast('Wall deleted', 'success');
showToast(t('wall.toast.deleted'), 'success');
window.location.hash = '#/walls';
} catch (err) { showToast(err.message, 'error'); }
};