diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 01ffa8d..ff1e3a0 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -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.', }; diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index e76608e..3b41c66 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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.', }; diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index a30ff19..4f2c131 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -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.', }; diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 2f1f369..5d336f8 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -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.', }; diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index 92a498a..5492720 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -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.', }; diff --git a/frontend/js/views/billing.js b/frontend/js/views/billing.js index 526a5f0..3feea59 100644 --- a/frontend/js/views/billing.js +++ b/frontend/js/views/billing.js @@ -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 = ` -

Loading...

+

${t('common.loading')}

`; try { @@ -22,27 +23,26 @@ export async function render(container) { const content = document.getElementById('billingContent'); content.innerHTML = ` -
-

Current Plan

+

${t('billing.current_plan')}

${subData.plan.display_name}
- ${subData.self_hosted ? 'Self-Hosted' : ''} - ${subData.trial?.active ? `Trial - ${subData.trial.days_left} days left` : ''} + ${subData.self_hosted ? `${t('billing.self_hosted')}` : ''} + ${subData.trial?.active ? `${t('billing.trial_days_left', { n: subData.trial.days_left })}` : ''}
${subData.trial?.active ? `
-
Your ${subData.trial.plan?.charAt(0).toUpperCase() + subData.trial.plan?.slice(1)} trial ends in ${subData.trial.days_left} days
-
After the trial, you'll be moved to the Free plan (1 device). Upgrade now to keep all your devices and features.
+
${t('billing.trial_ends', { plan: (subData.trial.plan?.charAt(0).toUpperCase() + subData.trial.plan?.slice(1)) || '', n: subData.trial.days_left })}
+
${t('billing.trial_after')}
` : ''}
-
Devices
-
${subData.usage.devices} / ${subData.plan.max_devices === -1 ? 'Unlimited' : subData.plan.max_devices}
+
${t('billing.devices')}
+
${subData.usage.devices} / ${subData.plan.max_devices === -1 ? t('billing.unlimited') : subData.plan.max_devices}
${subData.plan.max_devices > 0 ? `
` : ''}
-
Storage
-
${subData.usage.storage_mb} MB / ${subData.plan.max_storage_mb === -1 ? 'Unlimited' : subData.plan.max_storage_mb + ' MB'}
+
${t('billing.storage')}
+
${subData.usage.storage_mb} MB / ${subData.plan.max_storage_mb === -1 ? t('billing.unlimited') : subData.plan.max_storage_mb + ' MB'}
${subData.plan.max_storage_mb > 0 ? `
` : ''}
-
Features
+
${t('billing.features')}
- ${subData.plan.remote_control ? '
✓ Remote Control
' : '
✗ Remote Control
'} - ${subData.plan.remote_url ? '
✓ Remote URLs
' : '
✗ Remote URLs
'} - ${subData.plan.priority_support ? '
✓ Priority Support
' : '
✗ Priority Support
'} + ${subData.plan.remote_control ? `
✓ ${t('billing.feat.remote_control')}
` : `
✗ ${t('billing.feat.remote_control')}
`} + ${subData.plan.remote_url ? `
✓ ${t('billing.feat.remote_urls')}
` : `
✗ ${t('billing.feat.remote_urls')}
`} + ${subData.plan.priority_support ? `
✓ ${t('billing.feat.priority_support')}
` : `
✗ ${t('billing.feat.priority_support')}
`}
-
-

Available Plans

+

${t('billing.available_plans')}

${plans.map(p => `
- ${p.id === subData.plan.id ? '
Current
' : ''} + ${p.id === subData.plan.id ? `
${t('billing.current')}
` : ''}
${p.display_name}
- ${p.price_monthly > 0 ? `$${p.price_monthly}/mo` : 'Free'} + ${p.price_monthly > 0 ? `$${p.price_monthly}${t('billing.per_month')}` : t('billing.free')}
-
${p.max_devices === -1 ? 'Unlimited' : p.max_devices} devices
-
${p.max_storage_mb === -1 ? 'Unlimited' : (p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024) + ' GB' : p.max_storage_mb + ' MB')} storage
-
${p.remote_control ? '✓' : '✗'} Remote Control
-
${p.remote_url ? '✓' : '✗'} Remote URLs
-
${p.priority_support ? '✓' : '✗'} Priority Support
+
${p.max_devices === -1 ? t('billing.unlimited') : p.max_devices} ${t('billing.devices_lc')}
+
${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')}
+
${p.remote_control ? '✓' : '✗'} ${t('billing.feat.remote_control')}
+
${p.remote_url ? '✓' : '✗'} ${t('billing.feat.remote_urls')}
+
${p.priority_support ? '✓' : '✗'} ${t('billing.feat.priority_support')}
- ${p.price_yearly > 0 ? `
or $${p.price_yearly}/year (save ${Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100)}%)
` : ''} + ${p.price_yearly > 0 ? `
${t('billing.yearly_save', { price: p.price_yearly, pct: Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100) })}
` : ''} ${!subData.self_hosted && p.price_monthly > 0 && p.id !== subData.plan.id ? `
- - ${p.price_yearly > 0 ? `` : ''} + + ${p.price_yearly > 0 ? `` : ''}
` : ''} ${!subData.self_hosted && p.id === subData.plan.id && subData.subscription?.stripe_subscription_id ? ` - + ` : ''}
`).join('')}
- ${subData.self_hosted ? '

Self-hosted mode: plans can be assigned by admins without billing.

' : ''} + ${subData.self_hosted ? `

${t('billing.self_hosted_note')}

` : ''}
`; - // 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 = `

Failed to load

${esc(err.message)}

`; + document.getElementById('billingContent').innerHTML = `

${t('billing.failed_to_load')}

${esc(err.message)}

`; } } diff --git a/frontend/js/views/layout-editor.js b/frontend/js/views/layout-editor.js index 5876b0b..e216529 100644 --- a/frontend/js/views/layout-editor.js +++ b/frontend/js/views/layout-editor.js @@ -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 = ` -

Templates

+

${t('layout.templates')}

-

My Layouts

+

${t('layout.my_layouts')}

`; 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('') : - '

No custom layouts yet

'; + `

${t('layout.empty_custom')}

`; - // 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 `
@@ -90,14 +90,14 @@ function renderLayoutCard(layout, isTemplate) {
${layout.name}
-
${layout.zones?.length || 0} zone(s) ${isTemplate ? '• Template' : ''}
+
${zonesText}${isTemplate ? ' • ' + t('layout.template_label') : ''}
${isTemplate - ? `` - : `` + ? `` + : `` } - +
`; @@ -107,44 +107,43 @@ async function renderEditor(container, layoutId) { let layout; try { layout = await API(`/layouts/${layoutId}`); - } catch { container.innerHTML = '

Layout not found

'; return; } + } catch { container.innerHTML = `

${t('layout.not_found')}

`; return; } container.innerHTML = ` - Back to Layouts + ${t('layout.back')}
-
-

Zones

+

${t('layout.zones')}

@@ -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) => `
-

Video Walls ?

Combine multiple displays into one large screen
+

${t('wall.title')} ?

${t('wall.subtitle')}
`; 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 = '

No video walls yet

Create a video wall to combine multiple displays.

'; + grid.innerHTML = `

${t('wall.empty_title')}

${t('wall.empty_desc')}

`; return; } @@ -55,7 +56,7 @@ async function renderList(container) {
${w.name}
-
${w.grid_cols}x${w.grid_rows} grid • ${w.devices?.length || 0} devices
+
${t('wall.grid_summary', { cols: w.grid_cols, rows: w.grid_rows, n: w.devices?.length || 0 })}
`).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 = '

Wall not found

'; return; } + } catch { container.innerHTML = `

${t('wall.not_found')}

`; 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 = ` - Back to Video Walls + ${t('wall.back')}
-

Grid Configuration

+

${t('wall.grid_config')}

-
-
-
-
- +
+
+
+
+
-

Content

+

${t('wall.content')}

- +
-

Available Displays

+

${t('wall.available_displays')}

${unassigned.map(d => `
@@ -114,7 +115,7 @@ async function renderWallEditor(container, wallId) {
${d.status}
- `).join('') || '

All devices assigned

'} + `).join('') || `

${t('wall.all_assigned')}

`}
@@ -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 ? `
${dev.device_name}
[${c},${r}]
` : - `
Drop here
[${c},${r}]
`} + `
${t('wall.drop_here')}
[${c},${r}]
`}
`; } } 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'); } };