i18n batch 6: wire teams + activity + help (~62 keys)

- teams.js: list, detail with members + shared devices, invite/role
  controls, all toasts
- activity.js: page chrome, action verb/noun mapping translated through
  t() so the audit log reads naturally in each language
- help.js: page chrome translated; guides and FAQ body content kept
  in English with a comment explaining why (machine-translated docs
  read worse than English source)
- 1008 keys total, parity 100% across en/es/fr/de/pt

All 16 dashboard views now use t(). index.html modal, player overlay,
and Android resources still pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-29 20:16:21 -05:00
parent 7a17bb5079
commit 6d6f901ef4
8 changed files with 399 additions and 67 deletions

View file

@ -983,4 +983,69 @@ export default {
'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.',
// Teams
'team.title': 'Teams',
'team.subtitle': 'Teams und gemeinsamen Zugriff verwalten',
'team.help_tip': 'Erstellen Sie Teams, um Geräte mit anderen Nutzern zu teilen. Eigentümer verwalten das Team, Editoren ändern Inhalte/Playlists, Betrachter überwachen nur.',
'team.new_team': 'Neues Team',
'team.prompt_name': 'Teamname:',
'team.empty_title': 'Noch keine Teams',
'team.empty_desc': 'Erstellen Sie ein Team, um Geräte mit anderen Nutzern zu teilen.',
'team.your_role': 'Ihre Rolle: {role}',
'team.member_count_one': '1 Mitglied',
'team.member_count_other': '{n} Mitglieder',
'team.not_found': 'Team nicht gefunden',
'team.back': 'Zurück zu Teams',
'team.delete_team': 'Team löschen',
'team.members_count': 'Mitglieder ({n})',
'team.invite': '+ Einladen',
'team.role_viewer': 'Betrachter',
'team.role_editor': 'Editor',
'team.role_owner': 'Eigentümer',
'team.remove': 'Entfernen',
'team.remove_from_team': 'Aus Team entfernen',
'team.no_members': 'Keine Mitglieder',
'team.shared_devices': 'Geteilte Geräte ({n})',
'team.add_device': '+ Gerät hinzufügen...',
'team.no_devices': 'Keine Geräte mit diesem Team geteilt',
'team.prompt_email': 'E-Mail-Adresse zum Einladen:',
'team.prompt_role': 'Rolle (viewer, editor oder owner):',
'team.toast.invalid_role': 'Ungültige Rolle',
'team.toast.invitation_sent': 'Einladung gesendet',
'team.toast.role_updated': 'Rolle aktualisiert',
'team.toast.member_removed': 'Mitglied entfernt',
'team.toast.device_added': 'Gerät zum Team hinzugefügt',
'team.toast.device_removed': 'Gerät aus Team entfernt',
'team.toast.deleted': 'Team gelöscht',
// Activity
'activity.title': 'Aktivitätsprotokoll',
'activity.subtitle': 'Audit-Trail aller Aktionen',
'activity.load_more': 'Mehr laden',
'activity.empty_title': 'Noch keine Aktivität',
'activity.empty_desc': 'Aktionen erscheinen hier, sobald Sie das System nutzen.',
'activity.system': 'System',
'activity.verb_created': 'hat erstellt',
'activity.verb_updated': 'hat aktualisiert',
'activity.verb_deleted': 'hat gelöscht',
'activity.action_paired_device': 'hat ein Gerät gekoppelt',
'activity.action_added_remote_content': 'hat Remote-Inhalt hinzugefügt',
'activity.noun_content': 'Inhalt',
'activity.noun_device': 'Gerät',
'activity.noun_playlist_assignment': 'Playlist-Zuweisung',
'activity.noun_assignment': 'Zuweisung',
'activity.noun_layout': 'Layout',
'activity.noun_widget': 'Widget',
'activity.noun_schedule': 'Zeitplan',
'activity.noun_video_wall': 'Videowand',
'activity.alert_device_offline': 'Alarm: Gerät offline',
// Help
'help.title': 'Hilfe-Center',
'help.subtitle': 'Schnellanleitungen und FAQ',
'help.faq': 'Häufig gestellte Fragen',
'help.shortcuts': 'Tastaturkürzel',
'help.shortcut_esc': 'Web-Player zurücksetzen (auf Player-Seite)',
'help.shortcut_f': 'Vollbild umschalten (Web-Player)',
};

View file

@ -1019,4 +1019,69 @@ export default {
'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.',
// Teams
'team.title': 'Teams',
'team.subtitle': 'Manage teams and shared access',
'team.help_tip': 'Create teams to share devices with other users. Owners manage the team, editors can change content/playlists, viewers can only monitor.',
'team.new_team': 'New Team',
'team.prompt_name': 'Team name:',
'team.empty_title': 'No teams yet',
'team.empty_desc': 'Create a team to share devices with other users.',
'team.your_role': 'Your role: {role}',
'team.member_count_one': '1 member',
'team.member_count_other': '{n} members',
'team.not_found': 'Team not found',
'team.back': 'Back to Teams',
'team.delete_team': 'Delete Team',
'team.members_count': 'Members ({n})',
'team.invite': '+ Invite',
'team.role_viewer': 'Viewer',
'team.role_editor': 'Editor',
'team.role_owner': 'Owner',
'team.remove': 'Remove',
'team.remove_from_team': 'Remove from team',
'team.no_members': 'No members yet',
'team.shared_devices': 'Shared Devices ({n})',
'team.add_device': '+ Add device...',
'team.no_devices': 'No devices shared with this team',
'team.prompt_email': 'Email address to invite:',
'team.prompt_role': 'Role (viewer, editor, or owner):',
'team.toast.invalid_role': 'Invalid role',
'team.toast.invitation_sent': 'Invitation sent',
'team.toast.role_updated': 'Role updated',
'team.toast.member_removed': 'Member removed',
'team.toast.device_added': 'Device added to team',
'team.toast.device_removed': 'Device removed from team',
'team.toast.deleted': 'Team deleted',
// Activity log
'activity.title': 'Activity Log',
'activity.subtitle': 'Audit trail of all actions',
'activity.load_more': 'Load More',
'activity.empty_title': 'No activity yet',
'activity.empty_desc': 'Actions will appear here as you use the system.',
'activity.system': 'System',
'activity.verb_created': 'created',
'activity.verb_updated': 'updated',
'activity.verb_deleted': 'deleted',
'activity.action_paired_device': 'paired a device',
'activity.action_added_remote_content': 'added remote content',
'activity.noun_content': 'content',
'activity.noun_device': 'device',
'activity.noun_playlist_assignment': 'playlist assignment',
'activity.noun_assignment': 'assignment',
'activity.noun_layout': 'layout',
'activity.noun_widget': 'widget',
'activity.noun_schedule': 'schedule',
'activity.noun_video_wall': 'video wall',
'activity.alert_device_offline': 'alert: device went offline',
// Help
'help.title': 'Help Center',
'help.subtitle': 'Quick guides and FAQ',
'help.faq': 'Frequently Asked Questions',
'help.shortcuts': 'Keyboard Shortcuts',
'help.shortcut_esc': 'Reset web player (on player page)',
'help.shortcut_f': 'Toggle fullscreen (web player)',
};

View file

@ -982,4 +982,69 @@ export default {
'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.',
// Teams
'team.title': 'Equipos',
'team.subtitle': 'Gestiona equipos y acceso compartido',
'team.help_tip': 'Crea equipos para compartir dispositivos con otros usuarios. Los propietarios gestionan el equipo, los editores pueden cambiar contenido/listas, los espectadores solo monitorean.',
'team.new_team': 'Nuevo equipo',
'team.prompt_name': 'Nombre del equipo:',
'team.empty_title': 'Aún no hay equipos',
'team.empty_desc': 'Crea un equipo para compartir dispositivos con otros usuarios.',
'team.your_role': 'Tu rol: {role}',
'team.member_count_one': '1 miembro',
'team.member_count_other': '{n} miembros',
'team.not_found': 'Equipo no encontrado',
'team.back': 'Volver a equipos',
'team.delete_team': 'Eliminar equipo',
'team.members_count': 'Miembros ({n})',
'team.invite': '+ Invitar',
'team.role_viewer': 'Espectador',
'team.role_editor': 'Editor',
'team.role_owner': 'Propietario',
'team.remove': 'Eliminar',
'team.remove_from_team': 'Quitar del equipo',
'team.no_members': 'Sin miembros',
'team.shared_devices': 'Dispositivos compartidos ({n})',
'team.add_device': '+ Agregar dispositivo...',
'team.no_devices': 'No hay dispositivos compartidos con este equipo',
'team.prompt_email': 'Email para invitar:',
'team.prompt_role': 'Rol (viewer, editor o owner):',
'team.toast.invalid_role': 'Rol no válido',
'team.toast.invitation_sent': 'Invitación enviada',
'team.toast.role_updated': 'Rol actualizado',
'team.toast.member_removed': 'Miembro eliminado',
'team.toast.device_added': 'Dispositivo agregado al equipo',
'team.toast.device_removed': 'Dispositivo quitado del equipo',
'team.toast.deleted': 'Equipo eliminado',
// Activity
'activity.title': 'Registro de actividad',
'activity.subtitle': 'Historial de auditoría de todas las acciones',
'activity.load_more': 'Cargar más',
'activity.empty_title': 'Aún no hay actividad',
'activity.empty_desc': 'Las acciones aparecerán aquí a medida que uses el sistema.',
'activity.system': 'Sistema',
'activity.verb_created': 'creó',
'activity.verb_updated': 'actualizó',
'activity.verb_deleted': 'eliminó',
'activity.action_paired_device': 'vinculó un dispositivo',
'activity.action_added_remote_content': 'agregó contenido remoto',
'activity.noun_content': 'contenido',
'activity.noun_device': 'dispositivo',
'activity.noun_playlist_assignment': 'asignación de lista',
'activity.noun_assignment': 'asignación',
'activity.noun_layout': 'diseño',
'activity.noun_widget': 'widget',
'activity.noun_schedule': 'horario',
'activity.noun_video_wall': 'muro de video',
'activity.alert_device_offline': 'alerta: dispositivo desconectado',
// Help
'help.title': 'Centro de ayuda',
'help.subtitle': 'Guías rápidas y preguntas frecuentes',
'help.faq': 'Preguntas frecuentes',
'help.shortcuts': 'Atajos de teclado',
'help.shortcut_esc': 'Reiniciar reproductor web (en la página del reproductor)',
'help.shortcut_f': 'Alternar pantalla completa (reproductor web)',
};

View file

@ -983,4 +983,69 @@ export default {
'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.',
// Teams
'team.title': 'Équipes',
'team.subtitle': 'Gérez les équipes et l\'accès partagé',
'team.help_tip': 'Créez des équipes pour partager des appareils. Les propriétaires gèrent l\'équipe, les éditeurs modifient le contenu/listes, les spectateurs ne font que surveiller.',
'team.new_team': 'Nouvelle équipe',
'team.prompt_name': 'Nom de l\'équipe :',
'team.empty_title': 'Aucune équipe',
'team.empty_desc': 'Créez une équipe pour partager des appareils avec d\'autres utilisateurs.',
'team.your_role': 'Votre rôle : {role}',
'team.member_count_one': '1 membre',
'team.member_count_other': '{n} membres',
'team.not_found': 'Équipe introuvable',
'team.back': 'Retour aux équipes',
'team.delete_team': 'Supprimer l\'équipe',
'team.members_count': 'Membres ({n})',
'team.invite': '+ Inviter',
'team.role_viewer': 'Spectateur',
'team.role_editor': 'Éditeur',
'team.role_owner': 'Propriétaire',
'team.remove': 'Retirer',
'team.remove_from_team': 'Retirer de l\'équipe',
'team.no_members': 'Aucun membre',
'team.shared_devices': 'Appareils partagés ({n})',
'team.add_device': '+ Ajouter un appareil...',
'team.no_devices': 'Aucun appareil partagé avec cette équipe',
'team.prompt_email': 'E-mail à inviter :',
'team.prompt_role': 'Rôle (viewer, editor ou owner) :',
'team.toast.invalid_role': 'Rôle invalide',
'team.toast.invitation_sent': 'Invitation envoyée',
'team.toast.role_updated': 'Rôle mis à jour',
'team.toast.member_removed': 'Membre retiré',
'team.toast.device_added': 'Appareil ajouté à l\'équipe',
'team.toast.device_removed': 'Appareil retiré de l\'équipe',
'team.toast.deleted': 'Équipe supprimée',
// Activity
'activity.title': 'Journal d\'activité',
'activity.subtitle': 'Audit de toutes les actions',
'activity.load_more': 'Charger plus',
'activity.empty_title': 'Aucune activité',
'activity.empty_desc': 'Les actions apparaîtront ici au fur et à mesure de votre utilisation.',
'activity.system': 'Système',
'activity.verb_created': 'a créé',
'activity.verb_updated': 'a mis à jour',
'activity.verb_deleted': 'a supprimé',
'activity.action_paired_device': 'a apparié un appareil',
'activity.action_added_remote_content': 'a ajouté du contenu distant',
'activity.noun_content': 'contenu',
'activity.noun_device': 'appareil',
'activity.noun_playlist_assignment': 'attribution de liste',
'activity.noun_assignment': 'attribution',
'activity.noun_layout': 'mise en page',
'activity.noun_widget': 'widget',
'activity.noun_schedule': 'plage horaire',
'activity.noun_video_wall': 'mur vidéo',
'activity.alert_device_offline': 'alerte : appareil hors ligne',
// Help
'help.title': 'Centre d\'aide',
'help.subtitle': 'Guides rapides et FAQ',
'help.faq': 'Questions fréquentes',
'help.shortcuts': 'Raccourcis clavier',
'help.shortcut_esc': 'Réinitialiser le lecteur web (sur la page du lecteur)',
'help.shortcut_f': 'Basculer le plein écran (lecteur web)',
};

View file

@ -983,4 +983,69 @@ export default {
'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.',
// Teams
'team.title': 'Equipes',
'team.subtitle': 'Gerencie equipes e acesso compartilhado',
'team.help_tip': 'Crie equipes para compartilhar dispositivos com outros usuários. Proprietários gerenciam a equipe, editores podem alterar conteúdo/playlists, visualizadores apenas monitoram.',
'team.new_team': 'Nova equipe',
'team.prompt_name': 'Nome da equipe:',
'team.empty_title': 'Sem equipes ainda',
'team.empty_desc': 'Crie uma equipe para compartilhar dispositivos com outros usuários.',
'team.your_role': 'Sua função: {role}',
'team.member_count_one': '1 membro',
'team.member_count_other': '{n} membros',
'team.not_found': 'Equipe não encontrada',
'team.back': 'Voltar para equipes',
'team.delete_team': 'Excluir equipe',
'team.members_count': 'Membros ({n})',
'team.invite': '+ Convidar',
'team.role_viewer': 'Visualizador',
'team.role_editor': 'Editor',
'team.role_owner': 'Proprietário',
'team.remove': 'Remover',
'team.remove_from_team': 'Remover da equipe',
'team.no_members': 'Sem membros',
'team.shared_devices': 'Dispositivos compartilhados ({n})',
'team.add_device': '+ Adicionar dispositivo...',
'team.no_devices': 'Sem dispositivos compartilhados com esta equipe',
'team.prompt_email': 'E-mail para convidar:',
'team.prompt_role': 'Função (viewer, editor ou owner):',
'team.toast.invalid_role': 'Função inválida',
'team.toast.invitation_sent': 'Convite enviado',
'team.toast.role_updated': 'Função atualizada',
'team.toast.member_removed': 'Membro removido',
'team.toast.device_added': 'Dispositivo adicionado à equipe',
'team.toast.device_removed': 'Dispositivo removido da equipe',
'team.toast.deleted': 'Equipe excluída',
// Activity
'activity.title': 'Registro de atividades',
'activity.subtitle': 'Trilha de auditoria de todas as ações',
'activity.load_more': 'Carregar mais',
'activity.empty_title': 'Sem atividade ainda',
'activity.empty_desc': 'As ações aparecerão aqui conforme você usa o sistema.',
'activity.system': 'Sistema',
'activity.verb_created': 'criou',
'activity.verb_updated': 'atualizou',
'activity.verb_deleted': 'excluiu',
'activity.action_paired_device': 'pareou um dispositivo',
'activity.action_added_remote_content': 'adicionou conteúdo remoto',
'activity.noun_content': 'conteúdo',
'activity.noun_device': 'dispositivo',
'activity.noun_playlist_assignment': 'atribuição de playlist',
'activity.noun_assignment': 'atribuição',
'activity.noun_layout': 'layout',
'activity.noun_widget': 'widget',
'activity.noun_schedule': 'agenda',
'activity.noun_video_wall': 'parede de vídeo',
'activity.alert_device_offline': 'alerta: dispositivo offline',
// Help
'help.title': 'Central de ajuda',
'help.subtitle': 'Guias rápidos e perguntas frequentes',
'help.faq': 'Perguntas frequentes',
'help.shortcuts': 'Atalhos de teclado',
'help.shortcut_esc': 'Reiniciar player web (na página do player)',
'help.shortcut_f': 'Alternar tela cheia (player web)',
};

View file

@ -1,16 +1,17 @@
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t } from '../i18n.js';
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
export async function render(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>Activity Log</h1><div class="subtitle">Audit trail of all actions</div></div>
<div><h1>${t('activity.title')}</h1><div class="subtitle">${t('activity.subtitle')}</div></div>
</div>
<div id="activityList"><div class="empty-state"><h3>Loading...</h3></div></div>
<div id="activityList"><div class="empty-state"><h3>${t('common.loading')}</h3></div></div>
<div style="text-align:center;margin-top:16px">
<button class="btn btn-secondary btn-sm" id="loadMoreBtn" style="display:none">Load More</button>
<button class="btn btn-secondary btn-sm" id="loadMoreBtn" style="display:none">${t('activity.load_more')}</button>
</div>
`;
@ -25,14 +26,14 @@ export async function render(container) {
if (!append) list.innerHTML = '';
if (items.length === 0 && offset === 0) {
list.innerHTML = '<div class="empty-state"><h3>No activity yet</h3><p>Actions will appear here as you use the system.</p></div>';
list.innerHTML = `<div class="empty-state"><h3>${t('activity.empty_title')}</h3><p>${t('activity.empty_desc')}</p></div>`;
return;
}
const html = items.map(item => {
const time = new Date(item.created_at * 1000);
const timeStr = time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const timeStr = time.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const icon = getActionIcon(item.action);
return `
@ -40,7 +41,7 @@ export async function render(container) {
<div style="width:32px;height:32px;border-radius:50%;background:var(--bg-card);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:14px">${icon}</div>
<div style="flex:1;min-width:0">
<div style="font-size:13px">
<strong>${esc(item.user_name || item.user_email || 'System')}</strong>
<strong>${esc(item.user_name || item.user_email || t('activity.system'))}</strong>
<span style="color:var(--text-secondary)"> ${esc(formatAction(item.action))}</span>
</div>
${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(item.details)}</div>` : ''}
@ -81,22 +82,29 @@ function getActionIcon(action) {
return '&#128196;';
}
// Action verbs are user-visible; translate them through t() so they switch
// languages with the rest of the UI. The mapping below preserves the original
// verb-then-noun structure of the English version.
function formatAction(action) {
return action
.replace('POST /api/', 'created ')
.replace('PUT /api/', 'updated ')
.replace('DELETE /api/', 'deleted ')
.replace('/provision/pair', 'paired a device')
.replace('/content/remote', 'added remote content')
.replace('/content', 'content')
.replace('/devices/:id', 'device')
.replace('/assignments/device/:deviceId', 'playlist assignment')
.replace('/assignments/:id', 'assignment')
.replace('/layouts', 'layout')
.replace('/widgets', 'widget')
.replace('/schedules', 'schedule')
.replace('/walls', 'video wall')
.replace('alert:device_offline', 'alert: device went offline');
// Verbs
let s = action
.replace('POST /api/', t('activity.verb_created') + ' ')
.replace('PUT /api/', t('activity.verb_updated') + ' ')
.replace('DELETE /api/', t('activity.verb_deleted') + ' ');
// Specific endpoints
s = s
.replace('/provision/pair', t('activity.action_paired_device'))
.replace('/content/remote', t('activity.action_added_remote_content'))
.replace('/content', t('activity.noun_content'))
.replace('/devices/:id', t('activity.noun_device'))
.replace('/assignments/device/:deviceId', t('activity.noun_playlist_assignment'))
.replace('/assignments/:id', t('activity.noun_assignment'))
.replace('/layouts', t('activity.noun_layout'))
.replace('/widgets', t('activity.noun_widget'))
.replace('/schedules', t('activity.noun_schedule'))
.replace('/walls', t('activity.noun_video_wall'))
.replace('alert:device_offline', t('activity.alert_device_offline'));
return s;
}
export function cleanup() {}

View file

@ -1,7 +1,13 @@
import { t } from '../i18n.js';
// Help guides + FAQ are documentation. Page chrome is translated; the body
// content is intentionally left in English because partial machine
// translation of multi-paragraph docs reads worse than a single source of
// truth. A native-language docs site is the right long-term answer.
export function render(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>Help Center</h1><div class="subtitle">Quick guides and FAQ</div></div>
<div><h1>${t('help.title')}</h1><div class="subtitle">${t('help.subtitle')}</div></div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:32px">
@ -25,7 +31,7 @@ export function render(container) {
</div>
<div class="settings-section">
<h3>Frequently Asked Questions</h3>
<h3>${t('help.faq')}</h3>
${[
{ q: 'What devices are supported?', a: 'Android TV/tablets (APK), Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, Fire TV, and any device with a web browser.' },
{ q: 'How does the free trial work?', a: 'New accounts get a 14-day free trial of the Pro plan (15 devices, all features). After 14 days, you\'re moved to the Free plan (1 device) unless you upgrade.' },
@ -46,10 +52,10 @@ export function render(container) {
</div>
<div class="settings-section">
<h3>Keyboard Shortcuts</h3>
<h3>${t('help.shortcuts')}</h3>
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:13px">
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">Esc</kbd> <span style="color:var(--text-secondary)">Reset web player (on player page)</span>
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">F</kbd> <span style="color:var(--text-secondary)">Toggle fullscreen (web player)</span>
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">Esc</kbd> <span style="color:var(--text-secondary)">${t('help.shortcut_esc')}</span>
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">F</kbd> <span style="color:var(--text-secondary)">${t('help.shortcut_f')}</span>
</div>
</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,17 +16,17 @@ export async function render(container) {
async function renderList(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>Teams <span class="help-tip" data-tip="Create teams to share devices with other users. Owners manage the team, editors can change content/playlists, viewers can only monitor.">?</span></h1><div class="subtitle">Manage teams and shared access</div></div>
<div><h1>${t('team.title')} <span class="help-tip" data-tip="${t('team.help_tip')}">?</span></h1><div class="subtitle">${t('team.subtitle')}</div></div>
<button class="btn btn-primary" id="newTeamBtn">
<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 Team
${t('team.new_team')}
</button>
</div>
<div id="teamsList"></div>
`;
document.getElementById('newTeamBtn').onclick = async () => {
const name = prompt('Team name:');
const name = prompt(t('team.prompt_name'));
if (!name) return;
const team = await API('/teams', { method: 'POST', body: JSON.stringify({ name }) });
window.location.hash = `#/team/${team.id}`;
@ -36,19 +37,19 @@ async function renderList(container) {
const list = document.getElementById('teamsList');
if (!teams.length) {
list.innerHTML = '<div class="empty-state"><h3>No teams yet</h3><p>Create a team to share devices with other users.</p></div>';
list.innerHTML = `<div class="empty-state"><h3>${t('team.empty_title')}</h3><p>${t('team.empty_desc')}</p></div>`;
return;
}
list.innerHTML = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px">
${teams.map(t => `
<div class="content-item" style="cursor:pointer" onclick="window.location.hash='#/team/${t.id}'">
${teams.map(team => `
<div class="content-item" style="cursor:pointer" onclick="window.location.hash='#/team/${team.id}'">
<div style="padding:20px">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<div style="width:40px;height:40px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:white">${t.name[0].toUpperCase()}</div>
<div style="width:40px;height:40px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:white">${team.name[0].toUpperCase()}</div>
<div>
<div style="font-weight:600;font-size:16px">${t.name}</div>
<div style="font-size:12px;color:var(--text-muted)">Your role: ${t.my_role} &middot; ${t.member_count} member(s)</div>
<div style="font-weight:600;font-size:16px">${team.name}</div>
<div style="font-size:12px;color:var(--text-muted)">${t('team.your_role', { role: team.my_role })} &middot; ${tn('team.member_count', team.member_count)}</div>
</div>
</div>
</div>
@ -66,28 +67,27 @@ async function renderTeamDetail(container, teamId) {
API(`/teams/${teamId}/devices`),
api.getDevices()
]);
} catch { container.innerHTML = '<div class="empty-state"><h3>Team not found</h3></div>'; return; }
} catch { container.innerHTML = `<div class="empty-state"><h3>${t('team.not_found')}</h3></div>`; return; }
const unassignedDevices = allDevices.filter(d => !d.team_id || d.team_id !== teamId);
container.innerHTML = `
<a href="#/teams" 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 Teams
${t('team.back')}
</a>
<div class="page-header">
<h1>${team.name}</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-danger btn-sm" id="deleteTeamBtn">Delete Team</button>
<button class="btn btn-danger btn-sm" id="deleteTeamBtn">${t('team.delete_team')}</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
<!-- Members -->
<div class="settings-section" style="margin:0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="font-size:15px">Members (${team.members?.length || 0})</h3>
<button class="btn btn-secondary btn-sm" id="inviteMemberBtn">+ Invite</button>
<h3 style="font-size:15px">${t('team.members_count', { n: team.members?.length || 0 })}</h3>
<button class="btn btn-secondary btn-sm" id="inviteMemberBtn">${t('team.invite')}</button>
</div>
<div id="membersList">
${(team.members || []).map(m => `
@ -98,22 +98,21 @@ async function renderTeamDetail(container, teamId) {
<div style="font-size:11px;color:var(--text-muted)">${m.email}</div>
</div>
<select class="input" style="max-width:100px;width:100%;background:var(--bg-input);font-size:12px;padding:4px 8px" data-member-id="${m.user_id}" ${m.role === 'owner' ? 'disabled' : ''}>
<option value="viewer" ${m.role === 'viewer' ? 'selected' : ''}>Viewer</option>
<option value="editor" ${m.role === 'editor' ? 'selected' : ''}>Editor</option>
<option value="owner" ${m.role === 'owner' ? 'selected' : ''}>Owner</option>
<option value="viewer" ${m.role === 'viewer' ? 'selected' : ''}>${t('team.role_viewer')}</option>
<option value="editor" ${m.role === 'editor' ? 'selected' : ''}>${t('team.role_editor')}</option>
<option value="owner" ${m.role === 'owner' ? 'selected' : ''}>${t('team.role_owner')}</option>
</select>
${m.role !== 'owner' ? `<button class="btn-icon" data-remove-member="${m.user_id}" style="color:var(--danger)" title="Remove">&#10005;</button>` : ''}
${m.role !== 'owner' ? `<button class="btn-icon" data-remove-member="${m.user_id}" style="color:var(--danger)" title="${t('team.remove')}">&#10005;</button>` : ''}
</div>
`).join('') || '<p style="color:var(--text-muted);font-size:13px">No members yet</p>'}
`).join('') || `<p style="color:var(--text-muted);font-size:13px">${t('team.no_members')}</p>`}
</div>
</div>
<!-- Devices -->
<div class="settings-section" style="margin:0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="font-size:15px">Shared Devices (${devices.length})</h3>
<h3 style="font-size:15px">${t('team.shared_devices', { n: devices.length })}</h3>
<select id="addDeviceToTeam" class="input" style="max-width:200px;width:100%;background:var(--bg-input);font-size:12px">
<option value="">+ Add device...</option>
<option value="">${t('team.add_device')}</option>
${unassignedDevices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
</select>
</div>
@ -125,75 +124,69 @@ async function renderTeamDetail(container, teamId) {
<div style="font-size:13px;font-weight:500">${d.name}</div>
<div style="font-size:11px;color:var(--text-muted)">${d.status}</div>
</div>
<button class="btn-icon" data-remove-device="${d.id}" style="color:var(--danger)" title="Remove from team">&#10005;</button>
<button class="btn-icon" data-remove-device="${d.id}" style="color:var(--danger)" title="${t('team.remove_from_team')}">&#10005;</button>
</div>
`).join('') || '<p style="color:var(--text-muted);font-size:13px">No devices shared with this team</p>'}
`).join('') || `<p style="color:var(--text-muted);font-size:13px">${t('team.no_devices')}</p>`}
</div>
</div>
</div>
`;
// Invite member
document.getElementById('inviteMemberBtn').onclick = async () => {
const email = prompt('Email address to invite:');
const email = prompt(t('team.prompt_email'));
if (!email) return;
const role = prompt('Role (viewer, editor, or owner):', 'editor');
if (!['viewer', 'editor', 'owner'].includes(role)) { showToast('Invalid role', 'error'); return; }
const role = prompt(t('team.prompt_role'), 'editor');
if (!['viewer', 'editor', 'owner'].includes(role)) { showToast(t('team.toast.invalid_role'), 'error'); return; }
try {
await API(`/teams/${teamId}/invite`, { method: 'POST', body: JSON.stringify({ email, role }) });
showToast('Invitation sent', 'success');
showToast(t('team.toast.invitation_sent'), 'success');
renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); }
};
// Change member role
container.querySelectorAll('[data-member-id]').forEach(select => {
select.onchange = async () => {
try {
await API(`/teams/${teamId}/members/${select.dataset.memberId}`, { method: 'PUT', body: JSON.stringify({ role: select.value }) });
showToast('Role updated', 'success');
showToast(t('team.toast.role_updated'), 'success');
} catch (err) { showToast(err.message, 'error'); }
};
});
// Remove member
container.querySelectorAll('[data-remove-member]').forEach(btn => {
btn.onclick = async () => {
try {
await API(`/teams/${teamId}/members/${btn.dataset.removeMember}`, { method: 'DELETE' });
showToast('Member removed', 'success');
showToast(t('team.toast.member_removed'), 'success');
renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); }
};
});
// Add device to team
document.getElementById('addDeviceToTeam').onchange = async (e) => {
const deviceId = e.target.value;
if (!deviceId) return;
try {
await API(`/teams/${teamId}/devices`, { method: 'POST', body: JSON.stringify({ device_id: deviceId }) });
showToast('Device added to team', 'success');
showToast(t('team.toast.device_added'), 'success');
renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); }
};
// Remove device from team
container.querySelectorAll('[data-remove-device]').forEach(btn => {
btn.onclick = async () => {
try {
await API(`/teams/${teamId}/devices/${btn.dataset.removeDevice}`, { method: 'DELETE' });
showToast('Device removed from team', 'success');
showToast(t('team.toast.device_removed'), 'success');
renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); }
};
});
// Delete team
document.getElementById('deleteTeamBtn').onclick = async () => {
try {
await API(`/teams/${teamId}`, { method: 'DELETE' });
showToast('Team deleted', 'success');
showToast(t('team.toast.deleted'), 'success');
window.location.hash = '#/teams';
} catch (err) { showToast(err.message, 'error'); }
};