screentinker/frontend/js/i18n/en.js
ScreenTinker e97228a502 SSO: per-organization providers, configured by the customer
Instance-wide providers belong to whoever runs the server. These belong to a
CUSTOMER: an organization points ScreenTinker at its own identity provider from
Settings → Single sign-on, with no environment variable and no restart.

The login flow is unchanged. An org provider is resolved through the same
oidc-providers.get(slug) the env ones go through, so there is one authorization
request builder, one token exchange and one verifier — not a second, less
tested path for tenants. That seam is why Phase 1 put provider lookup behind a
single function.

⚠️ An org provider is NEVER published. It is not in /api/auth/providers, because
listing a customer's IdP would both offer it to people it does not belong to and
leak the customer list from the login page. It surfaces only when someone types
an address at one of that organization's domains; otherwise the instance-wide
buttons are what you get.

The discovery endpoint answers with a BOOLEAN and nothing else — no slug, no
display name. Returning "yes, Acme Corp SSO" would turn a guessed domain into
confirmation that Acme buys this product, and the slug would hand out a working
entry point to their tenant. POST /sso/start repeats the lookup server-side and
redirects, so the browser never learns which provider it is being sent to until
the provider says so, and the address travels in a body rather than in a URL
that lands in history, proxy logs and a Referer. Both endpoints rate limited to
10/min.

Other properties, each with a test:
  - slugs are RANDOM, not chosen, so two customers cannot collide on or guess
    each other's URL
  - a domain may be claimed by ONE organization; a second claim is refused, so a
    tenant cannot capture another company's logins
  - the issuer is verified by live discovery BEFORE the row is written, so a
    typo is caught at configuration rather than by a user staring at a failed
    login
  - client secrets are optional (PKCE), stored AES-256-GCM via lib/secretbox,
    never returned; an absent secret on update leaves the stored one alone,
    which is how a settings form that cannot show it avoids blanking it
  - cross-org access answers 404, not 403, so an outsider cannot confirm that an
    organization id exists
  - signing in through an org provider grants membership of that organization,
    but never changes an existing member's role

Verified live end to end: creation against a real issuer, domain normalisation
(`@Acme.CO.UK` → `acme.co.uk`), boolean-only discovery, a rejected domain
squat, a rejected bad issuer, and 404 for a foreign organization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 17:33:32 -05:00

1882 lines
101 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// English translations. This file is the source of truth for keys —
// every other locale should mirror its keys (or fall back to en).
export default {
'nav.members': 'Members',
'common.close': 'Close',
'switcher.manage_members': 'Manage members',
'switcher.rename': 'Rename workspace',
'wall.rename': 'Rename wall',
'wall.remove_from_wall': 'Remove from wall',
'dashboard.select_for_wall': 'Select for video wall',
'team.unavailable_desc': 'This feature is unavailable while it is redesigned to work with Workspaces. Existing team data is preserved.',
'team.unavailable_title': 'Teams is being rebuilt',
'settings.help_tip': 'Your profile, password and two-factor sign-in, plus API tokens for integrations and tools to export or import your data.',
'activity.help_tip': 'A record of what changed and who changed it — logins, content uploads, device commands and alerts. Useful for working out why a screen changed what it was showing.',
'playlist.help_tip': 'A playlist is the running order a screen loops through. Add content items, set how long each shows, then assign the playlist to a screen or group. A screen plays one playlist at a time unless a schedule overrides it.',
'schedule.overnight_no_drag': 'This schedule runs past midnight — open it to change its times.',
'schedule.overnight_note': 'Runs past midnight — shown as two blocks.',
// Recovered from dead `t(k) || 'default'` fallbacks: t() returns the key when a string is
// missing, so those defaults never rendered and users saw the raw key instead.
'admin.check_now': 'Check Now',
'admin.checking': 'Checking...',
'admin.copied': 'Copied!',
'admin.copy': 'Copy',
'admin.copy_command': 'Copy',
'admin.latest_version': 'Latest Version',
'admin.manual_update': 'Manual Update Required',
'admin.manual_update_desc': 'Run this command on the server:',
'admin.status': 'Status',
'admin.up_to_date': 'Up to Date',
'admin.update_available': 'Update Available',
'admin.update_failed': 'Update Failed',
'admin.update_now': 'Update Now',
'admin.update_success': 'Update Successful',
'admin.updating': 'Updating...',
'wall.no_playlist': 'No playlist',
'wall.playlist': 'Playlist',
'wall.set_playlist': 'Set Playlist',
'wall.toast.playlist_updated': 'Playlist updated',
// Calendar direct-manipulation strings. NOTE: t() returns the KEY when a string is missing,
// never undefined — so `t('x') || 'fallback'` can never fire and would ship the raw key to the
// user. These must exist here; a browser run caught 'schedule.ctx_new' rendering literally.
'schedule.ctx_new': 'New schedule here…',
'schedule.ctx_edit': 'Edit…',
'schedule.ctx_duplicate': 'Duplicate',
'schedule.ctx_delete': 'Delete',
'schedule.confirm_series': 'This schedule repeats. Changing it here updates every occurrence. Continue?',
'schedule.confirm_delete': 'Delete this schedule?',
'schedule.toast.deleted': 'Schedule deleted',
'schedule.drag_hint': 'Drag across a time to add a schedule, or right-click for options.',
// Getting-started checklist (components/getting-started.js). Driven by real account state,
// not a one-time flag, so it can tell someone what is actually left to do.
'gs.title': 'Get your first screen live',
'gs.progress': '{done} of {total} done',
'gs.dismiss': 'Hide',
'gs.device.title': 'Connect a screen',
'gs.device.desc': 'Open the player on your display, then enter the code it shows.',
'gs.device.cta': 'Add screen',
'gs.content.title': 'Add some content',
'gs.content.desc': 'Upload images or video, or add a web page or widget.',
'gs.content.cta': 'Add content',
'gs.playlist.title': 'Put content in a playlist',
'gs.playlist.desc': 'A playlist is the running order your screen loops through.',
'gs.playlist.cta': 'New playlist',
'gs.assign.title': 'Send it to the screen',
'gs.assign.desc': 'Open the screen and assign the playlist — it starts playing straight away.',
'gs.assign.cta': 'Assign',
'gs.reopen': 'Show getting-started checklist',
// #zone-orphan dashboard warnings
'device.pl_item.orphan_zone': 'Zone from a different layout — reassign',
'device.pl_item.orphan_zone_tip': "This item's zone isn't part of the device's current layout. It still plays (recovered into the largest zone), but reassign it to a zone in this layout.",
'dashboard.device_orphan_tip_one': "{n} item assigned to a zone that isn't in this device's layout — open the device to reassign",
'dashboard.device_orphan_tip_other': "{n} items assigned to a zone that isn't in this device's layout — open the device to reassign",
// #139: device stuck in OTA backoff (can't self-install — e.g. Fire TV) — needs a manual update.
'dashboard.device_ota_stuck': 'Update available (v{version}) — install failed {n}×, manual update required',
// Nav (sidebar)
'nav.displays': 'Displays',
'nav.content': 'Content',
'nav.playlists': 'Playlists',
'nav.layouts': 'Layouts',
'nav.widgets': 'Widgets',
'nav.schedule': 'Schedule',
'nav.walls': 'Video Walls',
'nav.reports': 'Reports',
'nav.kiosk': 'Kiosk',
'nav.designer': 'Designer',
'nav.activity': 'Activity',
'nav.teams': 'Teams',
'nav.help': 'Help',
'nav.settings': 'Settings',
'nav.subscription': 'Subscription',
'nav.admin': 'Admin',
// Common (shared across views)
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.done': 'Done',
'common.saving': 'Saving...',
'common.deleting': 'Deleting...',
'common.loading': 'Loading...',
'common.checking': 'Checking...',
'confirm_delete.type_label': 'Type "{name}" to confirm',
'confirm_delete.failed': 'Action failed',
'common.connected': 'Connected',
'common.disconnected': 'Disconnected',
'common.never': 'Never',
'common.just_now': 'Just now',
'common.minutes_ago': '{n}m ago',
'common.hours_ago': '{n}h ago',
'common.days_ago': '{n}d ago',
'common.unknown': 'Unknown',
// Auth (login view)
'auth.sign_in': 'Sign In',
'auth.sign_out': 'Sign out',
'auth.create_account': 'Create Account',
'auth.create_admin_account': 'Create Admin Account',
'auth.email': 'Email',
'auth.password': 'Password',
'auth.name': 'Name',
'auth.placeholder_email': 'you@example.com',
'auth.placeholder_password': '••••••••',
'auth.placeholder_name': 'Your name',
'auth.placeholder_register_password': 'At least 6 characters',
'auth.subtitle_setup': 'Create your admin account to get started',
'auth.subtitle_signin': 'Sign in to manage your displays',
'auth.trial_notice': 'New accounts get a 14-day free Pro trial',
'auth.divider_or': 'OR',
'auth.signin_google': 'Sign in with Google',
'auth.signin_with': 'Continue with {provider}',
'auth.sso_failed': 'Single sign-on failed. Please try again.',
'auth.sso_org_hint': 'Your organization uses single sign-on.',
'auth.signin_sso': 'Continue with single sign-on',
'sso.title': 'Single sign-on',
'sso.blurb': 'Let your team sign in with your own identity provider. Anyone using an email address at one of your domains will be sent there instead of being asked for a password.',
'sso.add': 'Add a provider',
'sso.none': 'No provider configured yet.',
'sso.create': 'Add provider',
'sso.saved': 'Saved',
'sso.save_failed': 'Could not save that provider.',
'sso.load_failed': 'Could not load single sign-on settings.',
'sso.missing_fields': 'Name, issuer and client ID are required.',
'sso.confirm_delete': 'Remove this provider? Anyone who signs in with it will lose that route.',
'sso.delete': 'Remove',
'sso.enable': 'Enable',
'sso.disable': 'Disable',
'sso.disabled': 'disabled',
'sso.domains_label': 'Email domains',
'sso.callback_label': 'Redirect URI — add this to your provider',
'sso.f_name': 'Display name',
'sso.f_issuer': 'Issuer URL',
'sso.f_issuer_hint': 'The base URL whose /.well-known/openid-configuration describes your provider. We check it before saving.',
'sso.f_client_id': 'Client ID',
'sso.f_client_secret': 'Client secret (optional)',
'sso.f_client_secret_hint': 'Leave blank for a public client — we use PKCE, so a secret is not required. Stored encrypted and never shown again.',
'sso.f_domains': 'Email domains',
'sso.f_domains_hint': 'Comma separated. Anyone with an address at these domains is sent to this provider.',
'auth.sso_err_expired': 'That sign-in took too long. Please try again.',
'auth.sso_err_bad_state': 'Sign-in could not be verified. Please start again.',
'auth.sso_err_no_code': 'The provider did not return an authorization code.',
'auth.sso_err_no_email': 'Your provider did not share an email address.',
'auth.sso_err_email_unverified': 'Your provider has not verified that email address.',
'auth.sso_err_verification_failed': 'We could not verify the sign-in with your provider.',
'auth.sso_err_provider_refused': 'Your provider declined the sign-in.',
'auth.sso_err_provider_unavailable': 'That provider is not reachable right now.',
'auth.sso_err_unknown_provider': 'That sign-in provider is not configured.',
'auth.sso_err_registration_disabled': 'New accounts are disabled on this instance.',
'auth.sso_err_account_exists_local': 'An account with this email already exists. Sign in with your password, then link your provider in Settings.',
'auth.sso_err_subject_mismatch': 'This email is already linked to a different account at your provider.',
'auth.sso_err_server_error': 'Something went wrong completing sign-in.',
'auth.signin_microsoft': 'Sign in with Microsoft',
'auth.back_to_signin': 'Back to Sign In',
// TOTP 2FA challenge (second login step)
'auth.mfa_title': 'Two-factor authentication',
'auth.mfa_prompt': 'Enter the 6-digit code from your authenticator app.',
'auth.mfa_code_label': 'Authentication code',
'auth.mfa_verify': 'Verify',
'auth.mfa_code_required': 'Enter your authentication code.',
'auth.mfa_invalid': 'Invalid code. Try again.',
'auth.mfa_recovery_hint': "Lost your device? Enter one of your recovery codes instead.",
// Email verification on signup
'auth.verify_title': 'Confirm your email',
'auth.verify_body': "We've sent a verification link to",
'auth.verify_resend': 'Resend the email',
'auth.forgot_password': 'Forgot your password?',
'auth.forgot_send': 'Send reset link',
'auth.forgot_sent': 'If that address has an account, a reset link is on its way. Check your inbox.',
'auth.back_to_signin': 'Back to sign in',
'auth.new_password': 'New password',
'auth.reset_submit': 'Set new password',
'auth.reset_done': 'Password updated — sign in with your new password.',
'auth.reset_failed': 'That reset link is invalid or has expired. Request a new one.',
'auth.verify_resent': "If that address needs confirming, we've sent a new link.",
'auth.verify_resend_failed': "Couldn't resend right now — try again in a moment.",
'auth.verify_ok': 'Email confirmed. You can sign in now.',
'auth.verify_failed': 'That verification link is invalid or has expired. Sign in to get a new one.',
'auth.verify_banner': 'Please confirm your email address.',
'auth.verify_banner_resend': 'Resend',
'auth.support_access': 'Support Access',
'auth.support_token_placeholder': 'Paste support token',
'auth.support_authenticate': 'Authenticate with Support Token',
'auth.terms': 'Terms of Service',
'auth.privacy': 'Privacy Policy',
'auth.error_email_password_required': 'Email and password required',
'auth.error_password_min_6': 'Password must be at least 6 characters',
'auth.error_login_failed': 'Login failed',
'auth.error_registration_failed': 'Registration failed',
'auth.error_paste_support_token': 'Paste a support token',
'auth.error_support_failed': 'Support login failed',
'auth.error_google_failed': 'Google sign-in failed',
'auth.error_microsoft_failed': 'Microsoft sign-in failed',
// Dashboard
'dashboard.title': 'Displays',
'dashboard.subtitle': 'Manage your remote displays',
'dashboard.help_tip': 'Your paired display devices. Green = online, red = offline. Click a device to manage its playlist, view telemetry, or use remote control.',
'dashboard.add': 'Add Display',
'dashboard.create_group': '+ Group',
'dashboard.search': 'Search displays...',
'dashboard.all_status': 'All Status',
'dashboard.online': 'Online',
'dashboard.offline': 'Offline',
'dashboard.awaiting_pairing': 'Awaiting Pairing',
'dashboard.no_preview': 'No preview available',
'dashboard.total_displays': 'Total Displays',
'dashboard.ungrouped': 'Ungrouped',
'dashboard.no_displays': 'No displays yet',
'dashboard.no_displays_desc': 'Install the ScreenTinker app on your TV and pair it using the button above.',
'dashboard.failed_to_load': 'Failed to load displays',
'dashboard.unknown_playlist': 'Unknown playlist',
'dashboard.mixed_playlists': 'Mixed playlists',
'dashboard.playlist_label': 'Playlist: {name}',
'dashboard.devices_count_one': '{n} device',
'dashboard.devices_count_other': '{n} devices',
'dashboard.online_count': '{n} online',
'dashboard.set_playlist_placeholder': 'Set Playlist...',
'dashboard.send_command_placeholder': 'Send Command...',
'dashboard.manage': 'Manage',
'dashboard.group_sync.label': 'Sync',
'dashboard.group_sync.hint': "Play this group's shared playlist in lockstep across its displays — items start and end together, and it keeps them aligned even with no internet (each display follows a shared clock + schedule). Requires a playlist assigned to the group; a display on a different playlist is ignored.",
'dashboard.group_sync.resync': 'Resync now',
'dashboard.group_sync.resync_hint': 'Nudge every display in this group to re-snap to the shared schedule immediately.',
'dashboard.group_sync.toast_on': 'Synchronized playback enabled',
'dashboard.group_sync.toast_off': 'Synchronized playback disabled',
'dashboard.group_sync.toast_resync': 'Resync sent to group',
'dashboard.group_sync.backend_auto': 'Sync: Auto',
'dashboard.group_sync.backend_screentinker': 'Sync: Standard',
'dashboard.group_sync.backend_brightsign': 'Sync: BrightSign',
'dashboard.group_sync.backend_hint': "Which synchronisation protocol this group uses. Standard works across every player type and keeps displays aligned to the second, with no leader and no internet needed. BrightSign is frame-accurate but only works when every display in the group is a BrightSign on the same network, and it synchronises video only. Auto picks BrightSign when the group can actually run it, and Standard otherwise.",
'dashboard.group_sync.toast_backend': 'Sync protocol updated',
'dashboard.group_sync.toast_downgraded': 'Saved, but this group cannot run that protocol:',
'dashboard.manage_tooltip': 'Add/remove devices',
'dashboard.delete_group_tooltip': 'Delete group',
'dashboard.no_devices_in_group': 'No devices in this group. Click Manage to add some.',
'dashboard.manage_group_subtitle': 'Check devices to add them to this group',
'dashboard.draft_suffix': '(draft)',
// Group commands
'dashboard.cmd.screen_on': 'Screen On',
'dashboard.cmd.screen_off': 'Screen Off',
'dashboard.cmd.restart_app': 'Restart App',
'dashboard.cmd.check_update': 'Check Update',
'dashboard.cmd.reboot': 'Reboot',
'dashboard.cmd.shutdown': 'Shutdown',
// Dashboard prompts/confirms
'dashboard.prompt_group_name': 'Group name:',
'dashboard.error_pairing_code': 'Enter a valid 6-digit pairing code',
'dashboard.confirm_add_to_group': '{name} is already in: {groups}\n\nAdd it to "{target}" too?',
'dashboard.confirm_move_to_group': '{name} is currently in: {groups}\n\nMove it to "{target}"?',
'dashboard.toast.move_partial': 'Could not remove it from {group} — it is still in both',
'dashboard.confirm_assign_playlist': 'Assign playlist "{playlist}" to all devices in "{group}"?',
'dashboard.confirm_destructive_command': '{cmd} all {n} devices in "{group}"?\n\nThis cannot be undone.',
'dashboard.confirm_delete_group': 'Delete this group? Devices will not be affected.',
// Dashboard toasts
'dashboard.toast.display_paired': 'Display paired successfully!',
'dashboard.toast.group_created': 'Group created',
'dashboard.toast.group_deleted': 'Group deleted',
'dashboard.toast.already_in_group': '{name} is already in {group}',
'dashboard.toast.moved_device': 'Moved {name} to {group}',
'dashboard.toast.removed_device_one': 'Removed {name} from 1 group',
'dashboard.toast.removed_device_other': 'Removed {name} from {n} groups',
'dashboard.toast.playlist_assigned_one': 'Playlist assigned to 1 device',
'dashboard.toast.playlist_assigned_other': 'Playlist assigned to {n} devices',
'dashboard.toast.command_sent': '{cmd} sent to {sent}/{total} devices',
'dashboard.toast.command_sent_with_offline': '{cmd} sent to {sent}/{total} devices ({offline} offline)',
'dashboard.toast.command_unsupported_n': '{n} skipped — their players do not support it.',
// Content library
'content.title': 'Content Library',
'content.subtitle': 'Upload and manage your media files',
'content.help_tip': 'Upload videos and images here. Select multiple files for bulk upload. Use Remote URL to stream from external sources. Click a thumbnail to preview.',
'content.drop': 'Drop files here, or click to select one or more',
'content.upload_hint': 'Supports MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP',
'content.upload_progress': 'Uploading...',
'content.upload_progress_named': 'Uploading {name}...',
'content.upload_progress_named_pct': 'Uploading {name}... {pct}%',
'content.upload_progress_count': 'Uploading {count} files...',
// Remote URL panel
'content.remote_url': 'Remote URL',
'content.remote_desc': 'Stream directly from a URL. Saves local bandwidth.',
'content.remote_url_placeholder': 'https://example.com/video.mp4',
'content.remote_name_placeholder': 'Display name (optional)',
'content.remote_add_btn': 'Add Remote URL',
// YouTube panel
'content.youtube': 'YouTube',
'content.youtube_desc': 'Embed a YouTube video on your displays.',
'content.youtube_url_placeholder': 'https://youtube.com/watch?v=...',
'content.youtube_name_placeholder': 'Display name (optional)',
'content.youtube_add_btn': 'Add YouTube Video',
// Search / folders
'content.search_placeholder': 'Search content...',
'content.filter_type_all': 'All types',
'content.filter_type_video': 'Videos',
'content.filter_type_image': 'Images',
'content.filter_type_youtube': 'YouTube',
'content.filter_type_web': 'Web / remote',
'content.sort_newest': 'Newest first',
'content.sort_oldest': 'Oldest first',
'content.sort_name': 'Name AZ',
'content.sort_size': 'Largest first',
'content.result_count': '{count} result(s)',
'content.batch_selected': '{count} selected',
'content.batch_select_all': 'Select all',
'content.batch_select_none': 'Select none',
'content.batch_move_placeholder': 'Move to…',
'content.batch_delete': 'Delete ({count})',
'content.batch_delete_confirm': 'Click again to delete {count}',
'content.toast.batch_moved': '{count} item(s) moved',
'content.toast.batch_deleted': '{count} item(s) deleted',
'content.new_folder_btn': '+ New Folder',
'content.breadcrumb_root': 'All Content',
'content.rename_btn': 'Rename',
'content.delete_folder_btn': 'Delete folder',
'content.prompt_folder_name': 'Folder name:',
'content.prompt_rename_folder': 'Rename folder:',
'content.confirm_delete_folder': 'Delete this folder? Content inside moves back to the root level. Subfolders will also be deleted.',
// Empty states
'content.empty_folder_title': 'This folder is empty',
'content.empty_folder_desc': 'Drag content here, or use the Move action.',
'content.no_content': 'No content yet',
'content.no_content_desc': 'Upload videos and images to get started.',
'content.failed_to_load': 'Failed to load content',
// Item type labels
'content.type_youtube': 'YouTube',
'content.type_remote': 'Remote URL',
'content.type_remote_short': 'Remote',
'content.type_video': 'Video',
'content.type_image': 'Image',
// Item action buttons
'content.btn_edit': 'Edit',
'content.btn_delete': 'Delete',
'content.btn_confirm_delete': 'Confirm Delete?',
'content.btn_deleting': 'Deleting...',
// Edit modal
'content.edit_modal_title': 'Edit Content',
'content.label_filename': 'Filename / Display Name',
'content.label_remote_url_field': 'Remote URL',
'content.label_mime_type': 'MIME Type',
'content.label_folder': 'Folder',
'content.label_expires_at': 'Expiry date',
'content.expires_hint': 'After this date the item stops serving and is dropped from playlists. Leave blank for no expiry.',
'content.label_unstable_connection': 'Unstable connection (cap at 720p)',
'content.unstable_connection_hint': 'Forces YouTube to a 720p ceiling so weak or unstable WiFi buffers less. Leave off for full quality.',
'content.label_captions_enabled': 'Enable captions',
'content.label_captions_lang': 'Caption language',
'content.captions_hint': 'Turns on YouTube captions in the chosen language on every display. Availability depends on the video having captions.',
'content.label_subtitle_file': 'Subtitle file (.vtt)',
'content.label_subtitle_lang': 'Subtitle language',
'content.subtitle_current': 'A subtitle track is attached. Choose a new file to replace it.',
'content.subtitle_remove': 'Remove the current subtitle',
'content.subtitle_hint': 'Upload a WebVTT (.vtt) file to show subtitles on this video across all displays.',
'content.label_replace_file': 'Replace File',
'content.replace_file_hint': 'Leave empty to keep current file',
'content.folder_root_option': '— Root —',
'content.save_changes': 'Save Changes',
'content.show_expired': 'Show expired',
'content.expired_badge': 'Expired',
'content.expires_label': 'Expires {date}',
// MIME options
'content.mime.video_mp4': 'Video (MP4)',
'content.mime.video_webm': 'Video (WebM)',
'content.mime.image_jpeg': 'Image (JPEG)',
'content.mime.image_png': 'Image (PNG)',
'content.mime.image_gif': 'Image (GIF)',
'content.mime.image_webp': 'Image (WebP)',
// Content errors / toasts
'content.error_enter_url': 'Enter a URL',
'content.error_enter_youtube_url': 'Enter a YouTube URL',
'content.error_update_failed': 'Update failed',
'content.toast.remote_added': 'Remote content added',
'content.toast.youtube_added': 'YouTube video added',
'content.toast.deleted': 'Content deleted',
'content.toast.updated': 'Content updated',
'content.toast.uploaded_named': '{name} uploaded successfully',
'content.toast.uploaded_count': '{count} files uploaded successfully',
'content.toast.upload_failed_named': 'Failed to upload {name}: {error}',
'content.toast.folder_created_named': 'Folder "{name}" created',
'content.toast.folder_renamed': 'Folder renamed',
'content.toast.folder_deleted': 'Folder deleted',
'content.toast.moved': 'Moved',
'content.toast.moved_to_root': 'Moved to root',
// Device detail
'device.back': 'Back to Displays',
'device.owner_label': 'Owner: {owner}',
'device.rename': 'Rename',
'device.preview_btn': 'Preview',
'device.screenshot_btn': 'Screenshot',
'device.remove': 'Remove',
'device.owner_provision.btn': 'Device owner',
'device.owner_provision.tip': 'Provision this panel as device owner (silent updates, reboot, kiosk, time control)',
'device.owner_provision.title': 'Provision as device owner',
'device.owner_provision.intro': 'Device owner is an optional power-up that unlocks silent updates, clean reboot, kiosk lock-task, and time control. Enroll a fresh / factory-reset panel by QR or ADB.',
'device.owner_provision.qr_hint': 'Factory-reset the panel, tap the setup-wizard Welcome screen 6×, then scan this.',
'device.owner_provision.adb_label': 'Or over ADB (fresh device, no accounts, USB debugging on):',
'device.owner_provision.copy': 'Copy',
'device.owner_provision.copied': 'Command copied',
'device.owner_provision.constraints': 'Requires a freshly set-up / factory-reset device with no accounts, done before other device-owner apps enroll. If an MDM already owns the device, ScreenTinker stays at Tier 0/1 and the MDM owns updates.',
'device.owner_provision.error': 'Failed to load provisioning info',
'device.owner_provision.verified_from_apk': 'Signature verified from the current server APK — always matches the build it installs.',
'device.owner_provision.fallback_checksum': 'No APK on the server yet — using the fallback checksum. Upload the player APK before provisioning.',
'device.tier2.label': 'Device owner:',
'device.tier2.tip': 'Privileged controls available because this panel is device owner',
'device.tier2.reboot': 'Reboot',
'device.tier2.reboot_confirm': 'Reboot this panel now?',
'device.tier2.lock': 'Lock screen',
'device.tier2.kiosk_on': 'Kiosk lock',
'device.tier2.kiosk_off': 'Kiosk unlock',
'device.tier2.sent': 'Command sent',
'device.sysctl.title': 'System control',
'device.sysctl.subtitle': 'no device owner needed',
'device.sysctl.volume': 'Volume',
'device.sysctl.brightness_window': 'Brightness (this app)',
'device.sysctl.brightness_system': 'System brightness',
'device.sysctl.sleep': 'Sleep after',
'device.sysctl.never': 'Never',
'device.sysctl.write_hint': 'Grant "System settings" on the panel\'s setup screen to unlock system-wide brightness + sleep timeout. Volume and this-app brightness work without it.',
'device.click_to_confirm': 'Click again to confirm',
'device.prompt_new_name': 'Enter new name:',
'device.confirm_discard_draft': 'Discard all unpublished changes and revert to the last published version?',
'device.failed_load': 'Failed to load device',
'device.no_screenshot': 'No screenshot available. Click "Screenshot" to capture one.',
// Shown instead of the line above on a player that cannot capture its own screen — pointing at
// a "Screenshot" button that is correctly not rendered reads as a broken dashboard.
'device.no_screenshot_unsupported': 'This player cannot capture its own screen.',
'device.no_content_assigned': 'No content assigned',
'device.now_playing_id': 'Playing: {id}',
'device.playlist_count_one': '1 item in playlist',
'device.playlist_count_other': '{n} items in playlist',
// Tabs
'device.tab.now_playing': 'Now Playing',
'device.tab.now_playing_tip': "Live screenshot of what's currently displaying on this device.",
'device.tab.playlist': 'Playlist',
'device.tab.playlist_tip': 'Content assigned to this device. Drag items to reorder. Add media, widgets, or kiosk pages.',
'device.tab.info': 'Device Info',
'device.tab.info_tip': 'Hardware telemetry, orientation settings, notes, and device controls.',
'device.tab.remote': 'Remote Control',
'device.tab.remote_tip': 'View the device screen in real-time and send key presses. Works on Android APK and web player.',
'device.tab.controls': 'Controls',
'device.tab.controls_tip': 'Volume, brightness and sleep timeout — no device owner needed. System brightness + sleep need the panel to grant "System settings" (or be a device owner).',
'device.tab.terminal': 'Terminal',
'device.tab.terminal_tip': 'Device-owner shell (runs as the app UID) + push an APK. Preset commands for quick diagnostics.',
'device.terminal.welcome': 'ScreenTinker shell — runs as the app UID (not root). Type a command or tap a preset.',
'device.terminal.placeholder': 'command, e.g. getprop ro.product.model',
'device.terminal.run': 'Run',
'device.terminal.clear': 'Clear',
'device.terminal.uid_note': 'Runs as the app UID (untrusted_app), not root — device owner grants no privileged shell. dumpsys/settings/ip/battery are OS-denied here; IP address & battery are on the Info tab (reported by the app).',
'device.terminal.push_apk': 'Push an APK',
'device.terminal.push_apk_hint': 'Downloads the URL and installs it silently (device owner). Any signing key — not just ours.',
// Draft banner
'device.draft.banner_title': 'Unpublished changes',
'device.draft.devices_showing_published': 'Devices are still showing the last published version.',
'device.draft.never_published': 'This playlist has never been published. Devices will show nothing until you publish.',
'device.draft.discard': 'Discard',
'device.draft.publish': 'Publish',
'device.draft.publishing': 'Publishing...',
// Layout selector
'device.layout.label': 'Screen Layout',
'device.layout.fullscreen_default': 'Fullscreen (default)',
'device.layout.zones_count': '{name} ({n} zones)',
'device.layout.template_zones_count': '[Template] {name} ({n} zones)',
'device.layout.apply': 'Apply',
// Playlist tab
'device.playlist.label': 'Playlist',
'device.playlist.no_playlist': 'No playlist',
'device.playlist.copy_to_btn': 'Copy To...',
'device.playlist.add_content_btn': 'Add Content',
'device.playlist.empty_title': 'No content assigned',
'device.playlist.empty_desc': "Add content from your library to this display's playlist.",
'device.playlist_picker.with_count': '{name} — {n} items',
'device.playlist_picker.with_auto': '{name} (auto) — {n} items',
// v4 liveness badge (server-derived 3-state)
'device.liveness.healthy': 'Healthy',
'device.liveness.degraded': 'Reconnecting',
'device.liveness.offline': 'Offline',
// exit-signal contract — manner-of-death annotation on Offline (reliability-aware, §10)
'device.exit.crashed': 'crashed',
'device.exit.clean': 'clean exit',
'device.exit.clean_besteffort': 'clean exit (best-effort)',
'device.exit.silent': 'silent (no signal)',
'device.exit.silent_short': 'silent',
// filter drill-in options (sub-dimension of Offline)
'dashboard.filter.offline_silent': 'Offline · silent',
'dashboard.filter.offline_crashed': 'Offline · crashed',
'dashboard.filter.offline_clean': 'Offline · clean exit',
'dashboard.filter.offline_by_reason': 'Offline by reason',
// honest hover explanations (carry the contract's reliability)
'device.exit.crashed.tip': 'The apps own error handler fired — it crashed before dying (our fault).',
'device.exit.clean.tip': 'An orderly shutdown signal fired — something closed the app cleanly.',
'device.exit.clean_besteffort.tip': 'Reported an orderly shutdown, but this platforms signal is best-effort — likely, not certain.',
'device.exit.silent.tip': 'No exit signal arrived — external or violent termination: power loss, network drop, force-stop, or MDM/kill. The honest catch-all.',
// Info cards
'device.info.status': 'Status',
'device.info.ip_address': 'IP Address',
'device.info.local_ip': 'Local IP',
'device.info.local_ip6': 'Local IPv6',
'device.info.wifi_needs_location': 'Needs location permission',
'device.info.battery': 'Battery',
'device.info.storage': 'Storage',
'device.info.size_free': '{size} free',
// "Player storage" rather than "Storage": on a browser-family player this is the widget's cache
// quota, not the device filesystem, and it sits in the same column as Android's real disk usage.
'device.info.player_storage': 'Player Storage',
'device.info.hardware_model': 'Model',
'device.info.os_version': 'OS Version',
'device.info.serial': 'Serial',
'device.info.temperature': 'Temperature',
'device.info.attached_display': 'Attached display',
'device.info.video_mode': 'Video mode',
'device.info.output_n': '(output {n})',
'device.info.player_type': 'Player Type',
'device.info.web_player': 'Web Player',
'device.info.brightsign_player': 'BrightSign',
'device.info.wifi': 'WiFi',
'device.info.uptime': 'Uptime',
'device.info.android_version': 'Android Version',
'device.info.app_version': 'App Version',
'device.pin.rotate': 'Rotate',
'device.pin.set': 'Set…',
'device.pin.rotate_confirm': 'Generate a new settings PIN for this display? The current PIN stops working immediately.',
'device.pin.set_prompt': 'New 6-digit settings PIN',
'device.pin.updated_live': 'PIN updated — the display has it now',
'device.pin.updated_offline': 'PIN saved — the display will pick it up when it reconnects',
'device.pin.failed': 'Could not update the PIN',
'device.info.settings_pin': 'Settings PIN',
'device.info.settings_pin_hint': 'On-device settings menu (2× Back)',
'device.info.screen_resolution': 'Screen Resolution',
'device.info.ram': 'RAM',
'device.info.cpu_usage': 'CPU Usage',
// Uptime timeline
'device.timeline.title': 'Uptime Timeline (Last 24 Hours)',
'device.timeline.h24_ago': '24h ago',
'device.timeline.now': 'Now',
'device.timeline.online': 'Online',
'device.timeline.offline': 'Offline',
'device.timeline.no_data': 'No data',
'device.timeline.uptime_pct_tracked': '{pct}% uptime ({n}min tracked)',
'device.timeline.uptime_pct_no_data': '{pct}% uptime (no data)',
// Recent incidents / device diagnostics (offline-cause log)
'device.incidents.title': 'Recent incidents',
'device.incidents.none': 'No incidents recorded',
'device.incidents.down_for': 'down {dur}',
// Friendly labels for device event / offline-reason tokens
'device.event.network': 'Network problem',
'device.event.server_down': 'Our server unreachable',
'device.event.no_internet': 'No internet (router/ISP)',
'device.event.transport_close': 'Connection lost',
'device.event.ping_timeout': 'No response (network stall)',
'device.event.transport_error': 'Connection error',
'device.event.heartbeat_timeout': 'Stopped reporting',
'device.event.reboot': 'Device restarted',
'device.event.crash': 'App crashed',
'device.event.app_error': 'App error',
'device.event.display_off': 'Screen off / sleep',
'device.event.display_on': 'Screen on',
'device.event.upgrade': 'Upgraded',
'device.event.silent': 'Unknown cause',
'device.event.offline': 'Went offline',
'device.event.online': 'Came online',
// Form
'device.form.orientation_label': 'Orientation / Rotation',
'device.form.orientation.landscape': 'Landscape (0°)',
'device.form.orientation.portrait': 'Portrait (90° CW)',
'device.form.orientation.landscape_flipped': 'Landscape Flipped (180°)',
'device.form.orientation.portrait_flipped': 'Portrait Flipped (270° CW)',
'device.form.default_content_label': 'Default Content',
'device.form.default_content_none': 'None (show "Waiting...")',
'device.form.notes_label': 'Notes',
'device.form.notes_placeholder': 'Location, setup details, etc.',
'device.debug.toggle': 'Debug logging (live)',
'device.debug.hint': 'Streams this display\'s log in real time, and replays what it buffered before you opened it. Turns off when you leave this screen, and on the device itself after 30 minutes.',
'device.debug.freeze': 'Freeze',
'device.debug.resume': 'Resume',
'device.debug.copy': 'Copy',
'device.debug.clear': 'Clear',
'device.debug.held': 'frozen — {n} new line(s) waiting',
'device.debug.held_max': 'frozen — {n} waiting (oldest now being dropped)',
'device.debug.copied': 'Copied {n} line(s) to the clipboard',
'device.debug.copy_empty': 'Nothing to copy yet',
'device.debug.copy_failed': 'Could not reach the clipboard — select the log and copy manually',
'device.ota.toggle': 'Self-update (OTA)',
'device.ota.beta': 'Accept pre-release builds',
'device.ota.beta_hint': 'Puts this display on the pre-release channel: it receives the beta build if the server has one published, and keeps a test build instead of being updated back to the current release. Untick to move it back to the release build. Does nothing if no beta is published.',
'device.ota.hint': 'When off, this device is never offered an update — an MDM or operator owns its updates instead. Turn OFF for MDM-managed panels (e.g. Pivot/MAXHUB) so the app never shows a self-install dialog.',
'device.reboot_schedule.label': 'Nightly reboot',
'device.reboot_schedule.hint': 'Reboot this panel once a day at this device-local time (leave blank for off). A clean nightly reboot clears memory leaks and re-syncs the clock. Silent on device-owner panels; a no-op on panels that can\'t self-reboot.',
'device.form.save_settings': 'Save Settings',
// #150 re-adopt: restore a removed device's saved settings onto this one
'device.readopt.button': 'Restore from removed device…',
'device.readopt.button_hint': "Apply a previously-removed device's saved settings onto this one (for a re-paired screen whose fingerprint changed)",
'device.readopt.title': 'Restore settings from a removed device',
'device.readopt.help': 'Pick a previously-removed device to copy its saved settings onto “{name}”. This overwrites the current settings.',
'device.readopt.empty': 'No previously-removed devices in this workspace.',
'device.readopt.unnamed': 'Unnamed device',
'device.readopt.blocked': 'Blocked',
'device.readopt.summary_orientation': 'Orientation',
'device.readopt.summary_timezone': 'Timezone',
'device.readopt.summary_playlist': 'Playlist',
'device.readopt.playlist_none': 'none',
'device.readopt.playlist_removed': '(playlist since deleted)',
'device.readopt.last_seen': 'Last seen',
'device.readopt.removed': 'Removed',
'device.readopt.apply': 'Apply',
'device.readopt.confirm': 'Apply saved settings from “{source}” onto “{target}”? This overwrites the current settings.',
'device.readopt.confirm_blocked': 'This device was BLOCKED. Applying it will re-block the target device — it will immediately refuse to connect and the screen will go dark. Continue?',
'device.readopt.success': 'Settings restored ({orientation})',
'device.readopt.error': 'Could not restore settings',
// Control buttons
'device.ctl.reboot_device': 'Reboot Device',
'device.ctl.screen_off': 'Screen Off',
'device.ctl.screen_on': 'Screen On',
'device.ctl.launch_player': 'Launch Player',
'device.ctl.force_update': 'Force Update',
'device.ctl.shutdown': 'Shutdown',
// Remote tab
'device.remote.start_prompt': 'Click "Start Remote" to begin',
'device.remote.start': 'Start Remote',
'device.remote.stop': 'Stop Remote',
'device.remote.vol_up': 'Vol +',
'device.remote.vol_down': 'Vol -',
'device.remote.home': 'Home',
'device.remote.back': 'Back',
'device.remote.recents': 'Recents',
'device.remote.power': 'Power',
'device.remote.ok': 'OK',
'device.remote.settings': 'Settings',
'device.remote.scrn_off': 'Scrn Off',
'device.remote.scrn_on': 'Scrn On',
'device.remote.enable_system_view': 'Enable System View',
'device.remote.system_view_tooltip': 'Prompts the device user to allow full screen capture - enables remote view of home screen, settings, and other apps',
'device.remote.system_view_hint': 'Requires one-time approval on device',
'device.remote.system_view_owner': 'System View auto-enabled (device owner) — full screen, no approval needed',
'device.owner_tools.label': 'Device owner tools',
'device.owner_tools.shell_ph': 'shell command (runs as app uid) e.g. getprop ro.build.version.release',
'device.owner_tools.run': 'Run',
'device.owner_tools.apk_ph': 'https://…/app.apk',
'device.owner_tools.install': 'Push APK',
'device.owner_tools.hint': 'Shell runs as the app UID (not root). Push APK installs silently on a device owner.',
'device.owner_tools.apk_sent': 'APK install sent',
'device.owner_tools.bad_url': 'Enter a valid http(s) APK URL',
'device.remote.waiting_for_approval': 'Waiting for device approval...',
'device.remote.system_view_enabled': 'System View Enabled',
'device.remote.unlocked_hint': 'Navigation and system controls unlocked',
// Playlist item
'device.pl_item.widget_with_type': 'Widget ({type})',
'device.pl_item.youtube': 'YouTube',
'device.pl_item.video': 'Video',
'device.pl_item.image': 'Image',
'device.pl_item.zone_label': 'Zone: {id}',
'device.pl_item.no_zone': 'No zone',
'device.pl_item.mute': 'Mute',
'device.pl_item.unmute': 'Unmute',
'device.pl_item.remove': 'Remove',
// Copy playlist
'device.copy.no_other_devices': 'No other devices to copy to',
'device.copy.prompt': 'Copy playlist to which device?\n\n{list}\n\nEnter number:',
'device.copy.invalid_selection': 'Invalid selection',
'device.copy.toast': 'Copied {n} items to {device}',
// Add-content modal
'device.assign.empty_all': 'No content, widgets, or kiosk pages yet. Create something first!',
'device.assign.modal_title': 'Add to Playlist',
'device.assign.zone_label': 'Zone',
'device.assign.zone_default': 'Default (fullscreen)',
'device.assign.zone_no_layout': 'This device has no layout assigned. Content will play fullscreen. Pick a layout from the Layout dropdown on this device to use zones.',
'device.assign.zone_load_failed': 'Layout zones could not be loaded. Try refreshing the page.',
'device.assign.zone_empty_layout': 'This layout has no zones defined.',
'device.assign.duration_label': 'Display Duration (seconds, for images/widgets)',
'device.assign.tab.media': 'Media ({n})',
'device.assign.tab.widgets': 'Widgets ({n})',
'device.assign.tab.kiosk': 'Kiosk ({n})',
'device.assign.no_media': 'No media uploaded yet',
'device.assign.no_widgets': 'No widgets created yet.',
'device.assign.no_kiosk': 'No kiosk pages yet.',
'device.assign.create_one': 'Create one',
'device.assign.add_selected': 'Add Selected',
'device.assign.select_first': 'Select something first',
'device.assign.kiosk_widget_name': 'Kiosk: {name}',
// Toasts
'device.toast.screenshot_requested': 'Screenshot requested',
'device.toast.screenshot_unsupported': 'This display\'s player can\'t take screenshots',
'device.toast.screenshot_offline': 'Display is offline — screenshot not requested',
'device.toast.screenshot_failed': 'Screenshot request failed — no response from server',
'device.toast.renamed': 'Display renamed',
'device.toast.removing': 'Removing...',
'device.toast.removed': 'Display removed',
'device.toast.settings_saved': 'Settings saved',
'device.toast.published': 'Playlist published — devices updated',
'device.toast.draft_discarded': 'Draft changes discarded',
'device.toast.playlist_changed': 'Playlist changed',
'device.toast.layout_applied': 'Layout applied',
'device.toast.switched_to_fullscreen': 'Switched to fullscreen',
'device.toast.added_to_playlist': 'Added to playlist',
'device.toast.unmuted': 'Unmuted',
'device.toast.muted': 'Muted',
'device.toast.zone_updated': 'Zone updated',
'device.toast.removed_from_playlist': 'Content removed from playlist',
'device.toast.playlist_reordered': 'Playlist reordered',
'device.toast.reboot_sent': 'Reboot command sent',
'device.toast.shutdown_sent': 'Shutdown command sent',
'device.toast.screen_off_sent': 'Screen off command sent',
'device.toast.screen_on_sent': 'Screen on command sent',
'device.toast.launch_sent': 'Launch command sent',
'device.toast.update_triggered': 'Update check triggered',
'device.toast.remote_started': 'Remote session started',
'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect',
'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable',
'device.toast.command_no_ack': '{cmd} — no server response',
'device.toast.command_unsupported': '{cmd} — this player does not support it ({cap}). Reload the page to refresh the controls.',
'device.caps.title': 'Player capabilities',
'device.caps.declared': 'Reported by the player itself. Controls this display cannot honour are hidden.',
'device.caps.assumed': 'This player has not reported its capabilities, so the defaults for its platform are assumed. They update the next time it connects.',
'device.caps.none': 'The player reports it can do nothing.',
// Settings
'settings.title': 'Settings',
'settings.subtitle': 'Server configuration and setup information',
'settings.account': 'Account',
// API Tokens
'apitoken.title': 'API Tokens',
'apitoken.desc': 'Personal access tokens for the public API, scoped to this workspace. Treat them like passwords — anyone with the token can act as you here.',
'apitoken.docs_link': 'New to the API? See the full documentation →',
'apitoken.portal_url_label': 'Agency portal URL',
'apitoken.invite_label': 'Copyable invite — send this to the agency:',
'apitoken.invite_text': 'Go to {url} and paste this access key: {key}',
'apitoken.copy_invite': 'Copy invite',
'apitoken.name_placeholder': 'e.g. Agency integration',
'apitoken.scope_read': 'Read only',
'apitoken.scope_write': 'Read & write',
'apitoken.scope_full': 'Full (incl. device commands)',
'apitoken.scope_agency': 'Agency (upload to chosen playlists only)',
'apitoken.agency_playlists_label': 'Playlists this agency token may post to',
'apitoken.agency_playlists_hint': 'The token can upload and add date-bounded items to these playlists only. Additions land as drafts for you to publish.',
'apitoken.agency_needs_playlists': 'Select at least one playlist for an agency token.',
'apitoken.agency_no_playlists': 'Create a playlist first — an agency token must target one.',
'apitoken.targets_label': 'Designated:',
'apitoken.edit_targets': 'Edit playlists',
'apitoken.zoned_playlist_reason': 'Assigned to a zone — agencies need a full-screen playlist',
'apitoken.targets_updated': 'Designations updated',
'apitoken.auto_publish_label': 'Auto-publish (skip my approval)',
'apitoken.auto_publish_hint': 'Off (default): additions wait as drafts for you to publish. On: they go live immediately — only for agencies you fully trust.',
'apitoken.auto_publish_on': 'auto-publish on',
'apitoken.agency_folder_label': 'Upload folder',
'apitoken.agency_folder_hint': "Where this agency's uploads land. Leave on auto to create a folder named after the token; the agency can target subfolders of it but nothing else.",
'apitoken.agency_folder_auto': 'Create one automatically (Agency — <name>)',
'apitoken.folder_label': 'Folder:',
'apitoken.folder_root': 'Library root',
'apitoken.edit_folder': 'Folder',
'apitoken.folder_updated': 'Upload folder updated',
'apitoken.create': 'Create token',
'apitoken.none': 'No tokens yet.',
'apitoken.col_token': 'Token',
'apitoken.col_name': 'Name',
'apitoken.col_scope': 'Scope',
'apitoken.col_created': 'Created',
'apitoken.col_last_used': 'Last used',
'apitoken.never': 'Never',
'apitoken.revoke': 'Revoke',
'apitoken.revoked': 'Revoked',
'apitoken.secret_title': 'Copy your token now',
'apitoken.secret_warning': "This is the only time the full token is shown. Store it somewhere safe — you won't be able to see it again.",
'apitoken.copy': 'Copy',
'apitoken.copied': 'Copied to clipboard',
'apitoken.created_toast': 'Token created',
'apitoken.revoked_toast': 'Token revoked',
'apitoken.revoke_confirm': 'Revoke this token? Any integration using it stops working immediately.',
'settings.save_profile': 'Save Profile',
'settings.email_alerts': 'Email me when devices go offline',
'settings.change_password': 'Change Password',
'settings.password_min_8': 'Must be at least 8 characters.',
'settings.current_password': 'Current Password',
'settings.new_password': 'New Password',
'settings.confirm_new_password': 'Confirm New Password',
'settings.sso_note': 'You sign in via {provider}. Manage your password there.',
// Two-factor authentication (TOTP) — Account section
'settings.2fa_title': 'Two-factor authentication',
'settings.2fa_desc': 'Add a one-time code from an authenticator app to your password login.',
'settings.2fa_sso_note': 'Two-factor authentication is managed by your identity provider ({provider}).',
'settings.2fa_status_on': 'Enabled',
'settings.2fa_status_off': 'Not enabled',
'settings.2fa_enable': 'Enable two-factor authentication',
'settings.2fa_disable': 'Disable',
'settings.2fa_recovery_remaining': '{n} recovery codes remaining',
'settings.2fa_recovery_regenerate': 'Regenerate recovery codes',
'settings.2fa_setup_scan': 'Scan this QR code with your authenticator app (Google Authenticator, 1Password, Authy…).',
'settings.2fa_setup_manual': "Can't scan? Enter this key manually:",
'settings.2fa_setup_confirm': 'Then enter the 6-digit code to confirm:',
'settings.2fa_verify_enable': 'Verify & enable',
'settings.2fa_cancel': 'Cancel',
'settings.2fa_code_placeholder': '123456',
'settings.2fa_code_required': 'Enter the 6-digit code from your app.',
'settings.2fa_enabled_toast': 'Two-factor authentication enabled.',
'settings.2fa_disabled_toast': 'Two-factor authentication disabled.',
'settings.2fa_recovery_title': 'Save your recovery codes',
'settings.2fa_recovery_warning': 'Store these somewhere safe. Each code works once, and this is the only time they are shown. Use one to sign in if you lose your authenticator.',
'settings.2fa_recovery_copy': 'Copy',
'settings.2fa_recovery_download': 'Download',
'settings.2fa_recovery_done': "I've saved them",
'settings.2fa_disable_prompt': 'Enter a current code (or a recovery code) to turn off two-factor authentication:',
'settings.2fa_disable_confirm': 'Confirm disable',
'settings.2fa_regen_prompt': 'Enter a current code to generate a fresh set. Your old recovery codes stop working immediately:',
'settings.2fa_regen_confirm': 'Regenerate',
'settings.license': 'License',
'settings.license_mit': 'MIT License - all features included.',
'settings.platform_admin_link': 'Platform admin tools are in the',
'settings.platform_admin_page_suffix': 'page.',
'settings.user_management': 'User Management',
'settings.loading_users': 'Loading users...',
'settings.white_label': 'White Label / Branding',
'settings.white_label_desc': 'Customize the look of your dashboard and player for your clients.',
'settings.brand_name': 'Brand Name',
'settings.logo_url': 'Logo URL',
'settings.primary_color': 'Primary Color',
'settings.bg_color': 'Background Color',
'settings.custom_domain': 'Custom Domain',
'settings.favicon_url': 'Favicon URL',
'settings.custom_css': 'Custom CSS (optional)',
'settings.hide_branding': 'Hide "ScreenTinker" branding',
'settings.save_branding': 'Save Branding',
'settings.preview': 'Preview',
'settings.white_label_enterprise_only': 'Custom branding is available on the Enterprise plan',
'settings.view_plans': 'View Plans',
'settings.server_info': 'Server Information',
'settings.server_url': 'Server URL',
'settings.api_endpoint': 'API Endpoint',
'settings.server_url_hint': 'Use this URL when setting up the Android app',
'settings.setup_guide': 'Setup Guide',
'settings.setup_step_1': 'Install the ScreenTinker APK on your TV via sideloading',
'settings.setup_step_2_prefix': 'Open the app and enter this server URL:',
'settings.setup_step_3': 'The app will display a 6-digit pairing code',
'settings.setup_step_4': 'Click "Add Display" on the dashboard and enter the pairing code',
'settings.setup_step_5': 'Upload content in the Content Library',
'settings.setup_step_6': "Assign content to the display's Playlist",
'settings.your_data': 'Your Data',
'settings.your_data_desc': 'Export or import your devices, content, layouts, schedules, and all settings. Use this to migrate between cloud and self-hosted instances.',
'settings.export_my_data': 'Export My Data',
'settings.include_media_zip': 'Include media files (ZIP)',
'settings.import_data': 'Import Data',
'settings.language': 'Language',
'settings.about': 'About',
'settings.about_tagline': 'Digital signage management system.',
'settings.third_party_licenses': 'Third-Party Licenses',
// Import flow
'settings.import.reading_file': 'Reading file...',
'settings.import.zip_detected': 'ZIP export detected: <strong>{name}</strong> ({size} MB)<br>Contains data + media files.',
'settings.import.confirm': 'Confirm Import',
'settings.import.invalid_file': 'Invalid file. Must be a ScreenTinker export JSON or ZIP.',
'settings.import.summary_devices': '{n} devices',
'settings.import.summary_content': '{n} content items',
'settings.import.summary_widgets': '{n} widgets',
'settings.import.summary_layouts': '{n} layouts',
'settings.import.summary_schedules': '{n} schedules',
'settings.import.summary_walls': '{n} video walls',
'settings.import.summary_kiosk': '{n} kiosk pages',
'settings.import.found_summary': 'Found: {summary}.<br>From: {email} (exported {date})',
'settings.import.empty_export': 'empty export',
'settings.import.uploading_zip': 'Uploading and importing... This may take a moment for large files.',
'settings.import.importing': 'Importing...',
'settings.import.complete': 'Import complete: {imported}.',
'settings.import.pairing_codes_title': 'Device Pairing Codes:',
'settings.import.pairing_codes_hint': 'Enter these codes on each device to re-link them. All assignments and schedules will be preserved.',
'settings.import.failed': 'Import failed',
'settings.import.failed_with_error': 'Import failed: {error}',
'settings.import.read_failed': 'Failed to read file: {error}',
// Settings toasts
'settings.toast.support_token_generated': 'Support token generated (valid {hours}h)',
'settings.toast.import_success': 'Data imported successfully',
'settings.toast.name_required': 'Name cannot be empty',
'settings.toast.profile_saved': 'Profile saved',
'settings.toast.current_password_required': 'Enter your current password',
'settings.toast.new_password_min_8': 'New password must be at least 8 characters',
'settings.toast.passwords_dont_match': 'New passwords do not match',
'settings.toast.password_changed': 'Password changed',
'settings.toast.branding_saved': 'Branding saved',
'settings.toast.preview_applied': 'Preview applied (refresh to reset)',
'settings.toast.plan_updated': 'Plan updated',
'settings.toast.user_removed': 'User removed',
// User management table
'settings.user.col_user': 'User',
'settings.user.col_auth': 'Auth',
'settings.user.col_role': 'Role',
'settings.user.col_plan': 'Plan',
'settings.user.col_actions': 'Actions',
'settings.user.remove': 'Remove',
'settings.user.you': 'You',
'settings.user.confirm': 'Confirm?',
'settings.user.count_one': '1 user registered',
'settings.user.count_other': '{n} users registered',
'settings.user.reset_password': 'Reset Password',
'settings.user.prompt_reset_password': 'Enter a new password for {email} (minimum 8 characters):',
'settings.toast.password_reset_for_user': 'Password reset',
// Widgets
'widget.title': 'Widgets',
'widget.subtitle': 'Add dynamic content to your layouts',
'widget.help_tip': 'Dynamic content elements: live clocks, weather, RSS tickers, text, webpages, and social feeds. Create a widget then assign it to a device playlist.',
'widget.new_widget': 'New Widget',
'widget.configure': 'Configure Widget',
'widget.preview': 'Preview',
'widget.preview_title': 'Preview',
'widget.webpage_blocked_note': 'If this area is blank, the site blocks being embedded and wont display on the device either. Try a page that allows embedding.',
'widget.close': 'Close',
'widget.edit_x': 'Edit {type}',
'widget.new_x': 'New {type}',
'widget.empty_title': 'No widgets yet',
'widget.empty_desc': 'Create a widget to add dynamic content to your layouts.',
'widget.this_widget': 'this widget',
'widget.confirm_delete': 'Delete "{name}"? This cannot be undone.',
'widget.toast.saved': 'Widget saved',
'widget.toast.deleted': 'Widget deleted',
'widget.toast.preview_failed': 'Preview failed',
// Widget types
'widget.type.clock.name': 'Clock',
'widget.type.clock.desc': 'Digital clock with date',
'widget.type.weather.name': 'Weather',
'widget.type.weather.desc': 'Current weather conditions',
'widget.type.rss.name': 'News Ticker',
'widget.type.rss.desc': 'Scrolling RSS feed',
'widget.type.text.name': 'Text/HTML',
'widget.type.text.desc': 'Custom text or HTML content',
'widget.type.webpage.name': 'Webpage',
'widget.type.webpage.desc': 'Embed a webpage',
'widget.type.social.name': 'Social Feed',
'widget.type.social.desc': 'Social media feed',
'widget.type.directory_board.name': 'Directory Board',
'widget.type.directory_board.desc': 'Scrolling tenant/room directory for lobbies',
'widget.type.directory_search.name': 'Directory Search',
'widget.type.directory_search.desc': 'Walk-up search of a directory board',
'widget.type.transition.name': 'Transition',
'widget.type.transition.desc': 'Animated crossover between playlist items',
// transition widget
'widget.trans.shader': 'Effect',
'widget.trans.multi_hint': 'Pick one, or several — with more than one, the player uses a different effect each time (click a name to preview and tune it).',
'widget.trans.params': 'Parameters',
'widget.trans.duration': 'Duration (ms)',
'widget.trans.scope': 'Applies to',
'widget.trans.scope_next': 'Only the next item (advanced)',
'widget.trans.scope_all': 'Every item in the playlist (recommended)',
'widget.trans.scope_hint': 'One transition covers the whole playlist — drop this widget in anywhere and every image will transition. Use “only the next item” if you want a different effect at one specific spot.',
'widget.trans.play': 'Play',
'widget.trans.pause': 'Pause',
'widget.trans.unavailable': 'Transition preview unavailable',
'widget.trans.compile_error': 'Shader failed to compile',
// directory-search widget
'widget.dirsearch.source_label': 'Directory board',
'widget.dirsearch.source_hint': 'Reads entries live from the selected board — nothing is copied.',
'widget.dirsearch.source_empty': 'Create a directory board first — this widget searches an existing board.',
'widget.dirsearch.title_label': 'Title',
'widget.dirsearch.title_placeholder': 'Find a Tenant',
'widget.dirsearch.logo_label': 'Logo (optional)',
'widget.dirsearch.placeholder_label': 'Search box placeholder',
'widget.dirsearch.placeholder_hint': 'Type a name, suite, or department…',
'widget.dirsearch.theme': 'Theme',
'widget.dirsearch.keyboard_label': 'Show on-screen keyboard',
'widget.dirsearch.no_matches': 'No matches',
'widget.dirsearch.hint': 'Start typing to search the directory…',
// Widget config form fields
'widget.field.name': 'Widget Name',
'widget.field.format': 'Format',
'widget.field.format_12h': '12 Hour',
'widget.field.format_24h': '24 Hour',
'widget.field.timezone': 'Timezone',
'widget.field.font_size': 'Font Size',
'widget.field.font_size_px': 'Font Size (px)',
'widget.field.color': 'Color',
'widget.field.background': 'Background',
'widget.field.location': 'Location',
'widget.field.location_placeholder': 'City, State',
'widget.field.units': 'Units',
'widget.field.units_imperial': 'Imperial (°F)',
'widget.field.units_metric': 'Metric (°C)',
'widget.field.feed_url': 'Feed URL',
'widget.field.scroll_speed_seconds': 'Scroll Speed (seconds)',
'widget.field.max_items': 'Max Items',
'widget.field.html_content': 'HTML Content',
'widget.field.css_optional': 'CSS (optional)',
'widget.field.url': 'URL',
'widget.field.zoom_pct': 'Zoom (%)',
'widget.field.refresh_interval': 'Refresh Interval (seconds, 0 = never)',
'widget.field.platform': 'Platform',
'widget.field.platform_twitter': 'Twitter/X',
'widget.field.platform_instagram': 'Instagram',
'widget.field.query': 'Query',
'widget.field.query_placeholder': '@handle or #hashtag',
// Content picker
'widget.picker.default_title': 'Select Image',
'widget.picker.select_logo': 'Select Logo',
'widget.picker.select_bg_images': 'Select Background Images',
'widget.picker.search': 'Search images...',
'widget.picker.no_matches': 'No matches.',
'widget.picker.no_images': 'No images in your content library. Upload images first from Content Library.',
'widget.picker.selected_count': '{n} selected',
// Directory Board
'widget.dir.title_label': 'Title',
'widget.dir.title_placeholder': 'Lincoln Warehouse',
'widget.dir.logo_label': 'Logo (optional)',
'widget.dir.footer_text_label': 'Footer Text',
'widget.dir.footer_placeholder': 'For Leasing Inquiries: Contact...',
'widget.dir.bg_images_label': 'Background Images (optional)',
'widget.dir.bg_images_hint': 'Images crossfade every 15 seconds at 30% opacity. Add multiple for rotation.',
'widget.dir.add_bg_image': '+ Add Background Image',
'widget.dir.theme': 'Theme',
'widget.dir.theme_dark': 'Dark',
'widget.dir.theme_light': 'Light',
'widget.dir.scroll_speed': 'Scroll Speed',
'widget.dir.speed_slow': 'Slow',
'widget.dir.speed_medium': 'Medium',
'widget.dir.speed_fast': 'Fast',
'widget.dir.columns': 'Columns',
'widget.dir.columns_auto': 'Auto',
'widget.dir.categories': 'Categories',
'widget.dir.add_category': '+ Add Category',
'widget.dir.import_btn': 'Import from JSON / CSV',
'widget.dir.import_hint': 'Paste JSON, CSV/TSV, or a simple tenant list to auto-fill the fields below.',
'widget.dir.import_title': 'Import Directory Data',
'widget.dir.import_desc': 'Paste JSON, a CSV/TSV table, or a "room name" list. Floors or sections become categories; rooms, names, details and availability are detected automatically.',
'widget.dir.import_placeholder': 'Paste here — e.g.\n{ "company": "…", "tenantsByFloor": { "First Floor": [ { "room": "110", "name": "…" } ] } }\n\n…or CSV:\nFloor,Room,Name,Details,Available\nFirst Floor,110,Bodied By Rico,,no',
'widget.dir.import_replace': 'Replace existing categories (uncheck to append)',
'widget.dir.import_populate': 'Populate Form',
'widget.dir.import_done': 'Imported {cats} categories and {entries} entries.',
'widget.dir.import_warn_bg': '{n} background image name(s) were skipped — they are bare filenames, not URLs. Add them via "Add Background Image".',
'widget.dir.import_warn_noheader': 'No header row detected — columns were guessed by position. Add a header (e.g. Floor,Room,Name,Details) for exact mapping.',
'widget.dir.import_warn_nosections': 'No sections detected — everything was placed in one category. Use headings like "Second Floor:" to split by floor.',
'widget.dir.import_err_empty': 'Nothing to import — paste JSON, CSV, or a tenant list.',
'widget.dir.import_err_norows': 'No rows found. Check the format — JSON, CSV/TSV, or a "room name" list.',
'widget.dir.add_entry': '+ Add Entry',
'widget.dir.empty_categories': 'Add your first floor or department to get started',
'widget.dir.no_entries': 'No entries yet',
'widget.dir.entry': 'entry',
'widget.dir.entries': 'entries',
'widget.dir.collapse': 'Collapse',
'widget.dir.expand': 'Expand',
'widget.dir.move_up': 'Move up',
'widget.dir.move_down': 'Move down',
'widget.dir.delete_category': 'Delete category',
'widget.dir.delete_entry': 'Delete entry',
'widget.dir.unnamed': '(unnamed)',
'widget.dir.confirm_delete_category': 'Delete category "{name}" and all its entries?',
'widget.dir.category_name_placeholder': 'e.g. First Floor',
'widget.dir.entry_id_placeholder': '101',
'widget.dir.entry_name_placeholder': 'Tenant name',
'widget.dir.entry_subtitle_placeholder': 'Details (optional)',
'widget.dir.available': 'Available',
'widget.dir.change': 'Change',
'widget.dir.choose_logo': 'Choose Logo',
'widget.dir.remove_logo': 'Remove',
'widget.dir.no_bg_images': 'No background images selected',
'widget.dir.remove_bg': 'Remove',
// Designer
'designer.title': 'Content Designer',
'designer.subtitle': 'Create dynamic signage content',
'designer.ai.title': '✨ AI generate',
'designer.ai.settings': 'AI settings',
'designer.ai.placeholder': "Describe your sign — e.g. 'Summer sale, 20% off mains, bright modern'",
'designer.ai.generate': 'Generate design',
'designer.ai.generating': 'Generating…',
'designer.ai.contacting': 'Generating… text is quick; images add ~1030s',
'designer.ai.done': 'Generated {n} element(s) — tweak and Publish.',
'designer.ai.done_imgwarn': 'Generated {n} element(s) — an image couldnt be generated (text is ready). Publish.',
'designer.ai.images_title': 'AI images (optional)',
'designer.ai.images_desc': 'Generate a background (and a foreground graphic) from your prompt. Point at a local sd.cpp / ComfyUI server or OpenAI. Off = text + shapes only.',
'designer.ai.image_provider': 'Image provider',
'designer.ai.image_off': 'Off (text + shapes only)',
'designer.ai.image_base_url': 'Image endpoint URL',
'designer.ai.image_model': 'Image model',
'designer.ai.image_model_ph': 'optional — e.g. dall-e-3; blank for sd.cpp / ComfyUI',
'designer.ai.image_api_key': 'Image API key (optional)',
'designer.ai.image_key_ph': 'blank = reuse the key above',
'designer.ai.image_key_hint': 'Only if your image provider needs a different key than your text endpoint (e.g. local LLM + OpenAI images). Blank reuses the key above; local sd.cpp / ComfyUI need none.',
'designer.ai.failed': 'Generation failed',
'designer.ai.need_prompt': 'Enter a prompt first',
'designer.ai.settings_title': 'AI design settings',
'designer.ai.settings_desc': 'Bring your own OpenAI-compatible endpoint — OpenAI cloud, or self-hosted Ollama / LM Studio. Your provider bills you; the key is stored encrypted and never shown again.',
'designer.ai.base_url': 'Endpoint base URL',
'designer.ai.model': 'Model',
'designer.ai.load_models': 'Load models',
'designer.ai.loading_models': 'Loading models…',
'designer.ai.models_loaded': 'Loaded {n} model(s) — pick one above',
'designer.ai.models_failed': 'Could not load models',
'designer.ai.need_base_url': 'Enter the endpoint URL first',
'designer.ai.api_key': 'API key',
'designer.ai.key_set': '•••••• saved — leave blank to keep',
'designer.ai.key_placeholder': 'leave blank if your endpoint needs none',
'designer.ai.key_hint': 'Stored encrypted, server-side. Local endpoints (Ollama) usually need no key.',
'designer.ai.saved': 'AI settings saved',
'designer.ai.save_failed': 'Could not save AI settings',
'designer.help_tip': 'Create custom signage with live elements: clocks, weather, RSS tickers, countdowns, QR codes. Publish as a widget or export as PNG.',
'designer.load_design': 'Load Design',
'designer.export_png': 'Export PNG',
'designer.publish': 'Publish to Library',
'designer.preview_hint': 'Click elements to select. Drag to reposition. Live preview updates in real-time.',
'designer.add_element': 'Add Element',
'designer.background': 'Background',
'designer.bg_image': 'Image',
'designer.properties': 'Properties',
'designer.layers': 'Layers',
'designer.no_elements': 'No elements yet',
'designer.save_design_file': 'Save Design File',
'designer.qr_label': 'QR CODE',
'designer.loading_news': 'Loading news...',
'designer.no_items': 'No items',
'designer.feed_unavailable': 'Feed unavailable',
'designer.countdown_now': 'NOW!',
'designer.widget_name': 'Design {date}',
// Element buttons
'designer.el.text': 'Text',
'designer.el.heading': 'Heading',
'designer.el.image': 'Image',
'designer.el.video': 'Video',
'designer.el.clock': 'Clock',
'designer.el.date': 'Date',
'designer.el.weather': 'Weather',
'designer.el.ticker': 'Ticker',
'designer.el.shape': 'Shape',
'designer.el.qr': 'QR Code',
'designer.el.countdown': 'Countdown',
'designer.el.webpage': 'Webpage',
// Backgrounds
'designer.bg.black': 'Black',
'designer.bg.dark_blue': 'Dark Blue',
'designer.bg.dark_gradient': 'Dark Gradient',
'designer.bg.blue_gradient': 'Blue Gradient',
'designer.bg.sunset': 'Sunset',
'designer.bg.ocean': 'Ocean',
'designer.bg.forest': 'Forest',
'designer.bg.dark_red': 'Dark Red',
'designer.bg.white': 'White',
// Defaults / prompts
'designer.default.text': 'Your text here',
'designer.default.heading': 'HEADING',
'designer.default.coming_soon': 'Coming Soon',
'designer.prompt.video_url': 'Video URL (MP4):',
'designer.prompt.weather_location': 'City, State:',
'designer.prompt.rss_url': 'RSS Feed URL:',
'designer.prompt.qr_url': 'QR Code URL:',
'designer.prompt.countdown_date': 'Target date (YYYY-MM-DD):',
'designer.prompt.webpage_url': 'Webpage URL:',
// Properties
'designer.prop.text': 'Text',
'designer.prop.size': 'Size',
'designer.prop.font': 'Font',
'designer.prop.color': 'Color',
'designer.prop.bold': 'Bold',
'designer.prop.shadow': 'Shadow',
'designer.prop.format': 'Format',
'designer.prop.show_seconds': 'Show seconds',
'designer.prop.muted': 'Muted',
'designer.prop.loop': 'Loop',
'designer.prop.opacity': 'Opacity',
'designer.prop.shape': 'Shape',
'designer.prop.location': 'Location',
'designer.prop.feed_url': 'Feed URL',
'designer.prop.speed': 'Speed (seconds)',
'designer.prop.text_color': 'Text Color',
'designer.prop.bg_color': 'BG Color',
'designer.prop.target_date': 'Target Date',
'designer.prop.label': 'Label',
// Toasts
'designer.toast.published': 'Published as widget! Assign it to a layout zone.',
'designer.toast.publish_failed': 'Publish failed',
'designer.toast.export_failed': 'Export failed: {error}',
'designer.toast.loaded': 'Design loaded',
'designer.toast.invalid_file': 'Invalid design file',
// Playlists
'playlist.title': 'Playlists',
'playlist.subtitle': 'Create and manage content playlists',
'playlist.show_auto_generated': 'Show auto-generated',
'playlist.new_playlist_btn': '+ New Playlist',
'playlist.new_playlist': 'New Playlist',
'playlist.empty_title': 'No playlists yet',
'playlist.empty_desc': 'Create your first playlist to organize content for your displays.',
'playlist.all_auto_generated': 'All playlists are auto-generated. Toggle "Show auto-generated" to see them.',
'playlist.tag_auto': 'auto',
'playlist.tag_draft': 'draft',
'playlist.item_count_one': '1 item',
'playlist.item_count_other': '{n} items',
'playlist.created_at': 'Created {date}',
'playlist.display_count_one': '1 display',
'playlist.display_count_other': '{n} displays',
'playlist.assigned_to_one': 'Assigned to 1 display',
'playlist.assigned_to_other': 'Assigned to {n} displays',
'playlist.load_failed': 'Failed to load playlists: {error}',
'playlist.back_to_playlists': 'Back to Playlists',
'playlist.name_placeholder': 'Playlist name',
'playlist.desc_placeholder': 'Description (optional)',
'playlist.create_btn': 'Create',
'playlist.add_desc_placeholder': 'Add a description...',
'playlist.click_to_rename': 'Click to rename',
'playlist.click_to_edit_desc': 'Click to edit description',
'playlist.add_content': '+ Add Content',
'playlist.delete_playlist': 'Delete Playlist',
'playlist.layout_fullscreen': 'Fullscreen — all content shares the whole screen',
'playlist.zones_count_one': '1 zone',
'playlist.zones_count_other': '{n} zones',
'playlist.layout_ambiguous': 'Items reference zones from more than one layout',
'playlist.preview_prev': 'Previous',
'playlist.preview_next': 'Next',
'playlist.preview_position': '{current} of {total}',
'playlist.preview_zoned': 'Zones play together',
'playlist.back': 'Back',
'playlist.items_empty': 'This playlist is empty',
'playlist.items_empty_hint': 'Click "Add Content" to add items.',
'playlist.duration': 'Duration',
'playlist.sec': 'sec',
'playlist.move_up': 'Move up',
'playlist.move_down': 'Move down',
'playlist.remove_item': 'Remove item',
'playlist.item_widget': 'Widget',
'playlist.unknown_type': 'Unknown type',
'playlist.confirm_delete': 'Delete "{name}"? This cannot be undone.',
'playlist.confirm_discard_draft': 'Discard all unpublished changes and revert to the last published version?',
'playlist.draft.banner_title': 'Unpublished changes',
'playlist.draft.devices_showing_published': 'Devices are still showing the last published version.',
'playlist.draft.never_published': 'This playlist has never been published. Devices will show nothing until you publish.',
'playlist.draft.discard_changes': 'Discard Changes',
'playlist.draft.publish': 'Publish',
'playlist.draft.publishing': 'Publishing...',
'playlist.toast.created': 'Playlist created',
'playlist.toast.deleted': 'Playlist deleted',
'playlist.toast.published': 'Playlist published — devices updated',
'playlist.toast.draft_discarded': 'Draft changes discarded',
'playlist.toast.item_removed': 'Item removed',
'playlist.add_modal_title': 'Add Content to Playlist',
'playlist.tab_content': 'Content',
'playlist.tab_widgets': 'Widgets',
'playlist.search_placeholder': 'Search...',
'playlist.close': 'Close',
'playlist.no_content_found': 'No content found',
'playlist.no_widgets_found': 'No widgets found',
'playlist.add_btn': 'Add',
'playlist.replace_item': 'Replace content',
'playlist.duplicate_item': 'Duplicate item',
'playlist.replace_modal_title': 'Replace content',
'playlist.replace_btn': 'Replace',
'playlist.replacing': 'Replacing…',
'playlist.toast.item_duplicated': 'Item duplicated',
'playlist.toast.item_replaced': 'Content replaced',
'playlist.adding': 'Adding...',
'playlist.added': 'Added',
// Per-item schedule editor (#74/#75)
'itemsched.title': 'Schedule',
'itemsched.hint': "Items without a schedule always play. Add blocks to limit when this item appears — evaluated in each screen's local time.",
'itemsched.none': 'No schedule — this item always plays.',
'itemsched.block': 'Block {n}',
'itemsched.remove_block': 'Remove block',
'itemsched.from': 'From',
'itemsched.to': 'To',
'itemsched.end_of_day': 'end of day',
'itemsched.starts': 'Starts',
'itemsched.ends': 'Ends',
'itemsched.dates_hint': '(dates optional, inclusive)',
'itemsched.add_block': '+ Add schedule block',
'itemsched.cancel': 'Cancel',
'itemsched.save': 'Save schedule',
'itemsched.toast.saved': 'Schedule saved — publish the playlist to push it to devices',
'itemsched.toast.cleared': 'Schedule cleared — publish to update devices',
'itemsched.every_day': 'Every day',
'itemsched.mon_fri': 'Mon-Fri',
'itemsched.sat_sun': 'Sat-Sun',
'itemsched.dow_short': 'Sun,Mon,Tue,Wed,Thu,Fri,Sat',
'itemsched.err.days': 'Each schedule block needs at least one active day',
'itemsched.err.start': 'Start time must be HH:MM',
'itemsched.err.end': 'End time must be HH:MM (or end of day)',
'itemsched.err.start_date': 'Start date must be YYYY-MM-DD',
'itemsched.err.end_date': 'End date must be YYYY-MM-DD',
'device.clock.label': 'Device clock',
'device.clock.reported': '{time} reported',
'device.clock.skew': '⚠ clock off by {amount} — schedules may fire at the wrong local time',
// Onboarding
'onboarding.back': 'Back',
'onboarding.next': 'Next',
'onboarding.skip': 'Skip Wizard',
'onboarding.go_to_dashboard': 'Go to Dashboard',
'onboarding.pair_display': 'Pair Display',
'onboarding.step.welcome.title': 'Welcome to ScreenTinker!',
'onboarding.step.welcome.intro': "Let's get you set up in under 5 minutes.",
'onboarding.step.welcome.guide_through': 'This wizard will guide you through:',
'onboarding.step.welcome.bullet_download': 'Downloading the player app',
'onboarding.step.welcome.bullet_pair': 'Pairing your first display',
'onboarding.step.welcome.bullet_upload': 'Uploading and assigning content',
'onboarding.step.player.title': 'Step 1: Get the Player App',
'onboarding.step.player.intro': 'Install the player on your display device.',
'onboarding.step.player.android_label': 'Android APK',
'onboarding.step.player.android_desc': 'TV boxes, tablets, Fire TV',
'onboarding.step.player.web_label': 'Web Player',
'onboarding.step.player.web_desc': 'Any browser, Pi, ChromeOS',
'onboarding.step.player.url_hint': 'Open the app on your display and enter this server URL:',
'onboarding.step.pair.title': 'Step 2: Pair Your Display',
'onboarding.step.pair.intro': 'Enter the 6-digit code shown on your display.',
'onboarding.step.pair.name_placeholder': 'Display name (e.g., Lobby TV)',
'onboarding.step.upload.title': 'Step 3: Upload Content',
'onboarding.step.upload.intro': 'Upload a video or image to display.',
'onboarding.step.upload.click_to_select': 'Click to select a file',
'onboarding.step.upload.formats': 'MP4, WebM, JPEG, PNG, GIF',
'onboarding.step.upload.uploading': 'Uploading...',
'onboarding.step.done.title': "You're All Set!",
'onboarding.step.done.intro': 'Your display is paired and content is playing!',
'onboarding.step.done.whats_next': "What's next?",
'onboarding.step.done.next_content': 'Add more content in the <strong>Content Library</strong>',
'onboarding.step.done.next_layouts': 'Create multi-zone layouts in <strong>Layouts</strong>',
'onboarding.step.done.next_schedule': 'Set up a schedule in the <strong>Schedule</strong> calendar',
'onboarding.step.done.next_widgets': 'Add live widgets (clock, weather, ticker) in <strong>Widgets</strong>',
'onboarding.step.done.next_kiosk': 'Create interactive screens in <strong>Kiosk</strong>',
'onboarding.step.done.next_designer': 'Design custom content in the <strong>Designer</strong>',
'onboarding.toast.invalid_code': 'Enter a valid 6-digit code',
'onboarding.toast.pairing': 'Pairing...',
'onboarding.toast.pair_failed': 'Pairing failed',
'onboarding.toast.pair_failed_with_error': 'Pairing failed: {error}',
'onboarding.toast.paired': 'Display paired!',
'onboarding.toast.uploaded_assigning': 'Uploaded! Assigning to display...',
'onboarding.toast.content_assigned': 'Content uploaded and assigned!',
'onboarding.toast.upload_failed': 'Upload failed',
'onboarding.toast.error_with_error': 'Error: {error}',
// Admin (platform admin panel)
'admin.title': 'Platform Admin',
'admin.subtitle': 'Superadmin controls - only you can see this',
'admin.add_user': 'Add user',
'admin.create_org.button': 'Create organization',
'admin.create_org.title': 'Create organization',
'admin.create_org.name': 'Organization name',
'admin.create_org.placeholder': 'e.g. Acme Corp',
'admin.create_org.hint': "Creates the organization and its first workspace, owned by you. Add the customer's users afterward with Add User.",
'admin.create_org.submit': 'Create',
'admin.create_org.success': 'Organization "{name}" created',
'admin.create_org.err_empty': 'Organization name cannot be empty',
'admin.create_org.err_failed': 'Could not create organization',
'admin.orgs.title': 'Organizations',
'admin.orgs.desc': 'Every organization and its workspaces. Deleting cascades all devices, content, playlists and memberships — this is irreversible.',
'admin.orgs.empty': 'No organizations yet.',
'admin.orgs.owner': 'Owner',
'admin.orgs.workspaces': 'workspaces',
'admin.orgs.devices': 'devices',
'admin.orgs.members': 'members',
'admin.orgs.delete_org': 'Delete org',
'admin.orgs.delete_ws': 'Delete',
'admin.orgs.delete_org_title': 'Delete organization',
'admin.orgs.delete_org_body': 'This permanently deletes <b>{name}</b> and all of its workspaces, devices, content, playlists and memberships. This cannot be undone.',
'admin.orgs.delete_ws_title': 'Delete workspace',
'admin.orgs.delete_ws_body': 'This permanently deletes workspace <b>{name}</b> and all of its devices, content and playlists. The organization is kept. This cannot be undone.',
'admin.orgs.org_deleted': 'Organization "{name}" deleted',
'admin.orgs.ws_deleted': 'Workspace "{name}" deleted',
'admin.access_denied': 'Access Denied',
'admin.access_denied_desc': 'Platform admin access required.',
'admin.all_users': 'All Users',
'admin.plans': 'Subscription Plans',
'admin.col.accounts': 'Accounts',
'admin.col.screens': 'Screens',
'admin.plan_hidden': 'hidden',
'admin.plan_orphaned': 'Accounts on a plan that no longer exists',
'admin.system': 'System',
// #15: instance-level default branding
'admin.branding.title': 'Default branding',
'admin.branding.desc': "Instance-wide default. Every workspace that hasn't set its own white-label inherits this, as does the login page.",
'admin.branding.brand_name': 'Brand name',
'admin.branding.primary_color': 'Primary color',
'admin.branding.bg_color': 'Background color',
'admin.branding.logo_url': 'Logo URL',
'admin.branding.favicon_url': 'Favicon URL',
'admin.branding.custom_css': 'Custom CSS',
'admin.branding.hide_branding': 'Hide "Powered by" branding',
'admin.branding.save': 'Save branding',
'admin.branding.saved': 'Default branding saved',
'admin.col.user': 'User',
'admin.col.auth': 'Auth',
'admin.col.last_login': 'Last Login',
'admin.col.role': 'Role',
'admin.col.plan': 'Plan',
'admin.col.workspace': 'Workspace',
'admin.col.actions': 'Actions',
'admin.workspace.unassigned': 'Unassigned',
'admin.workspace.multi': '{n} workspaces',
'admin.workspace.platform_all': 'Platform (all)',
'admin.workspace.manage': 'Manage',
// "Manage workspaces" modal (per-user membership management)
'manage_ws.title': 'Manage workspaces — {user}',
'manage_ws.staff_note': 'This user has platform-wide access; the memberships below are in addition to that.',
'manage_ws.current': 'Current workspaces',
'manage_ws.empty': 'Not a member of any workspace.',
'manage_ws.add': 'Add to workspace',
'manage_ws.filter': 'Filter workspaces…',
'manage_ws.pick': 'Select a workspace…',
'manage_ws.pick_required': 'Pick a workspace to add.',
'manage_ws.add_btn': 'Add',
'manage_ws.remove': 'Remove',
'manage_ws.done': 'Done',
'manage_ws.toast.added': 'Added to workspace',
'manage_ws.toast.removed': 'Removed from workspace',
'manage_ws.toast.role': 'Role updated',
'admin.col.devices': 'Devices',
'admin.col.storage': 'Storage',
'admin.col.monthly': 'Monthly',
'admin.col.yearly': 'Yearly',
'admin.role.user': 'User',
'admin.role.platform_operator': 'Platform operator',
'admin.role.platform_admin': 'Platform admin',
// Legacy labels kept for back-compat with any not-yet-normalized data; the
// role dropdown no longer offers these (#14 normalization).
'admin.role.admin': 'Admin',
'admin.role.superadmin': 'Superadmin',
'admin.remove': 'Remove',
'admin.owner': 'Owner',
'admin.confirm': 'Confirm?',
'admin.total_users': '{n} total users',
'admin.unlimited': 'Unlimited',
'admin.free': 'Free',
'admin.version': 'Version',
'admin.frontend_hash': 'Frontend Hash',
'admin.download_db_backup': 'Download DB Backup',
'admin.server_status': 'Server Status',
'admin.toast.role_updated': 'Role updated',
'admin.toast.plan_updated': 'Plan updated',
'admin.toast.workspace_updated': 'Workspace updated',
'admin.toast.user_removed': 'User removed',
'admin.reset_password': 'Reset Password',
'admin.prompt_reset_password': 'Enter a new password for {email} (minimum 8 characters):',
'admin.toast.password_min_8': 'Password must be at least 8 characters',
'admin.toast.password_reset': 'Password reset',
// Schedule
'schedule.title': 'Schedule',
'schedule.subtitle': 'Content scheduling calendar',
'schedule.help_tip': 'Weekly calendar. Drag across a time to create a schedule, drag a block to move it, drag its lower edge to change when it ends, or right-click for more. Higher priority wins, and a screen\'s own schedule overrides its group\'s.',
'schedule.prev_week': '< Prev',
'schedule.next_week': 'Next >',
'schedule.add_schedule': 'Add Schedule',
'schedule.edit_schedule': 'Edit Schedule',
'schedule.apply_to': 'Apply to',
'schedule.target_device': 'Device',
'schedule.target_group': 'Group',
'schedule.group_devices_count': '{n} devices',
'schedule.no_groups_msg': 'No groups created yet. Create groups in the Displays page.',
'schedule.zone_note': 'Note: Zone-based schedules are layout-specific. Ensure all devices in the group use the same layout.',
'schedule.playlist_override': 'Playlist override',
'schedule.no_playlist_override': '— No playlist override —',
'schedule.draft_suffix': '(draft)',
'schedule.layout_override': 'Layout override',
'schedule.no_layout_override': '— No layout override —',
'schedule.content_label': 'Content',
'schedule.content_hint': '(single item, optional)',
'schedule.content_none': '— None —',
'schedule.title_label': 'Title (optional)',
'schedule.title_placeholder': 'e.g., Morning Playlist',
'schedule.tz_device': 'Times are in {zone} — the screen\u2019s timezone, not your {local}.',
'schedule.tz_same': 'Times are in {zone}.',
'schedule.tz_unknown': 'Times use the screen\u2019s own timezone once it reports one.',
'schedule.all_screens': 'All screens',
'schedule.start_time': 'Start Time',
'schedule.end_time': 'End Time',
'schedule.repeat': 'Repeat',
'schedule.repeat_none': 'No repeat',
'schedule.repeat_daily': 'Daily',
'schedule.repeat_weekdays': 'Weekdays',
'schedule.repeat_weekends': 'Weekends',
'schedule.repeat_weekly': 'Weekly',
'schedule.priority': 'Priority',
'schedule.color': 'Color',
'schedule.scheduled_label': 'Scheduled',
'schedule.tooltip_group_prefix': 'Group: ',
'schedule.tooltip_priority': 'Priority: {n}',
'schedule.day.sun': 'Sun',
'schedule.day.mon': 'Mon',
'schedule.day.tue': 'Tue',
'schedule.day.wed': 'Wed',
'schedule.day.thu': 'Thu',
'schedule.day.fri': 'Fri',
'schedule.day.sat': 'Sat',
'schedule.hour_12am': '12am',
'schedule.hour_am': 'am',
'schedule.hour_12pm': '12pm',
'schedule.hour_pm': 'pm',
'schedule.toast.no_groups': 'No groups available. Create a group first.',
'schedule.toast.saved': 'Schedule saved',
// Reports
'report.title': 'Reports',
'report.subtitle': 'Proof-of-play analytics and device uptime',
'report.help_tip': 'Proof-of-play analytics. See what played, when, and on which device. Filter by date range and device. Export to CSV for ad verification.',
'report.export_csv': 'Export CSV',
'report.device': 'Device',
'report.all_devices': 'All Devices',
'report.start_date': 'Start Date',
'report.end_date': 'End Date',
'report.load_report': 'Load Report',
'report.select_range': 'Select a date range and click Load Report',
'report.error': 'Error',
'report.total_plays': 'Total Plays',
'report.total_hours': 'Total Hours',
'report.unique_content': 'Unique Content',
'report.active_devices': 'Active Devices',
'report.avg_duration': 'Avg Duration',
'report.plays_per_day': 'Plays per Day',
'report.plays_by_hour': 'Plays by Hour',
'report.top_content': 'Top Content',
'report.by_device': 'By Device',
'report.no_data': 'No data',
'report.col.content': 'Content',
'report.col.device': 'Device',
'report.col.plays': 'Plays',
'report.col.total_hours': 'Total Hours',
'report.col.completion': 'Completion',
// Kiosk
'kiosk.title': 'Kiosk Pages',
'kiosk.subtitle': 'Create interactive touchscreen interfaces',
'kiosk.help_tip': 'Create interactive touchscreen interfaces. Add buttons with icons and actions. Includes idle screen that shows after inactivity. Assign to devices as a widget.',
'kiosk.new_page': 'New Kiosk Page',
'kiosk.prompt_name': 'Kiosk page name:',
'kiosk.empty_title': 'No kiosk pages yet',
'kiosk.empty_desc': 'Create an interactive touchscreen interface for your displays.',
'kiosk.label': 'Kiosk Page',
'kiosk.preview': 'Preview',
'kiosk.confirm_delete': 'Delete kiosk page "{name}"? This cannot be undone.',
'kiosk.toast.deleted': 'Kiosk page deleted',
'kiosk.toast.delete_failed': 'Failed to delete',
'kiosk.toast.saved': 'Kiosk page saved',
'kiosk.not_found': 'Page not found',
'kiosk.back': 'Back to Kiosk Pages',
'kiosk.page_settings': 'Page Settings',
'kiosk.title_label': 'Title',
'kiosk.subtitle_label': 'Subtitle',
'kiosk.logo_url': 'Logo URL',
'kiosk.footer_text': 'Footer Text',
'kiosk.idle_title': 'Idle Screen Title',
'kiosk.idle_default': 'Touch to Begin',
'kiosk.idle_timeout': 'Idle Timeout (seconds)',
'kiosk.style': 'Style',
'kiosk.background': 'Background',
'kiosk.text_color': 'Text Color',
'kiosk.columns': 'Columns',
'kiosk.button_color': 'Button Color',
'kiosk.button_hover': 'Button Hover Color',
'kiosk.buttons': 'Buttons',
'kiosk.add_btn': '+ Add',
'kiosk.icon_placeholder': 'Emoji',
'kiosk.label_placeholder': 'Label',
'kiosk.sublabel_placeholder': 'Sublabel',
'kiosk.action_none': 'No action',
'kiosk.action_url': 'Open URL',
'kiosk.action_page': 'Go to page',
'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.rename': 'Layout name — click to rename',
'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',
'layout.prop.fit': 'Fit',
'layout.fit_contain': 'Contain (whole image, may letterbox)',
'layout.fit_cover': 'Cover (fill zone, may crop)',
'layout.fit_fill': 'Stretch (fill zone, may distort)',
'layout.fit_hint': 'How video/images scale to the zone. Contain shows the whole frame without cropping.',
// 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.render_note': 'Tile is render size {render} (panel reports {panel} — rotated mount)',
'wall.h_bezel': 'H Bezel (px)',
'wall.v_bezel': 'V Bezel (px)',
'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.',
// 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)',
// Add Display modal (in index.html)
'add_display.title': 'Add Display',
'add_display.intro': 'Enter the 6-digit pairing code shown on the display.',
'add_display.pairing_code': 'Pairing Code',
'add_display.display_name': 'Display Name (optional)',
'add_display.name_placeholder': 'e.g., Lobby TV',
'add_display.need_player': 'Need a player app? Install one to get a pairing code:',
'add_display.android_apk': 'Android APK',
'add_display.web_player': 'Web Player',
'add_display.raspberry_pi': 'Raspberry Pi',
'add_display.windows': 'Windows',
'add_display.owner_prompt': 'Provisioning a fresh / factory-reset Android panel? Enroll it as device owner for silent updates, reboot & kiosk:',
'add_display.owner_qr_btn': 'Show device-owner QR',
'add_display.pair_btn': 'Pair Display',
'add_display.server_url_label': 'Server URL',
'add_display.server_url_hint': 'Use this URL when configuring the Android app',
'add_display.smart_tv_prefix': 'Smart TVs (LG/Samsung): open the built-in browser and navigate to',
// Workspace switcher (Phase 3 MVP). devices_count is the only count exposed
// today; matching pattern for users/playlists/etc. when those land later.
'switcher.devices_count_one': '1 device',
'switcher.devices_count_other': '{n} devices',
'switcher.no_devices': 'No devices',
// #16: searchable org/workspace switcher
'switcher.search_placeholder': 'Search organizations…',
'switcher.no_matches': 'No matches',
// Workspace members (Slice 2A - read-only listing; 2B adds mutation keys).
'members.title': 'Workspace members',
'members.loading': 'Loading...',
'members.section.direct': 'Members',
'members.section.via_org': 'Organization access',
'members.section.pending': 'Pending invites',
'members.col.name': 'Name',
'members.col.email': 'Email',
'members.col.role': 'Role',
'members.col.joined': 'Joined',
'members.role.workspace_admin': 'Admin',
'members.role.workspace_editor': 'Editor',
'members.role.workspace_viewer': 'Viewer',
'members.role.org_owner': 'Org owner',
'members.role.org_admin': 'Org admin',
'members.via_org_label': 'via organization',
'members.invited_label': 'Invited',
'members.invited_by': 'Invited by {email}',
'members.expires_in': 'Expires {when}',
'members.empty.members': 'No direct members yet.',
'members.empty.invites': 'No pending invites.',
'members.load_error': 'Failed to load members: {error}',
'members.workspace_not_found': 'Workspace not found or no access.',
// Mutation UI (Slice 2B): invite modal, action buttons, confirms, error
// toasts, success toasts. Grouped under five sub-namespaces for clarity.
// Modal — invite form
'members.modal.invite_title': 'Invite to {workspace}',
'members.modal.email_label': 'Email',
'members.modal.email_placeholder': 'user@example.com',
'members.modal.role_label': 'Role',
'members.modal.cancel': 'Cancel',
'members.modal.send': 'Send invite',
'members.modal.sending': 'Sending...',
// Modal — Add User form (#10, admin-provisioned account)
'members.modal.add_user_title': 'Add user to {workspace}',
'members.modal.add_user_title_generic': 'Add user',
// Add User picker mode (platform Users admin page): choose the target workspace.
'members.modal.workspace_label': 'Organization / Workspace',
'members.modal.workspace_filter_placeholder': 'Filter workspaces…',
'members.modal.workspace_placeholder': 'Select a workspace…',
'members.modal.workspace_loading': 'Loading workspaces…',
'members.modal.workspace_none': 'No workspaces available',
'members.modal.workspace_load_error': 'Failed to load workspaces',
'members.modal.workspace_required': 'Please select a workspace.',
'members.modal.name_label': 'Name',
'members.modal.name_placeholder': 'Full name (optional)',
'members.modal.password_label': 'Password',
'members.modal.password_placeholder': 'Set a password',
'members.modal.generate': 'Generate',
'members.modal.must_change_label': 'Require a password change on first login',
'members.modal.create': 'Create user',
'members.modal.creating': 'Creating...',
// Buttons — page header + per-row action affordances (titles double as
// ARIA labels for the icon-only buttons).
'members.button.invite': 'Invite member',
'members.button.add_user': 'Add user',
'members.button.remove': 'Remove member',
'members.button.cancel_invite': 'Cancel invite',
// Native confirm() text for destructive actions.
'members.confirm.remove_member': 'Remove {name} from this workspace?',
'members.confirm.cancel_invite': 'Cancel invite for {email}?',
// Errors mapped from server response text by mapMutationError().
'members.error.rate_limit': 'Invite rate limit reached. Try again later.',
'members.error.invite_exists': 'An invite for that email is already pending.',
'members.error.last_admin_demote': 'Cannot change role - this user is the only admin.',
'members.error.last_admin_remove': 'Cannot remove the last admin.',
'members.error.already_member': 'That user is already a member of this workspace.',
'members.error.invalid_email': 'Please enter a valid email address.',
'members.error.org_owner_remove': 'Cannot remove the organization owner.',
'members.error.email_send_failed': 'Email send failed. Try again.',
'members.error.user_exists': 'A user with that email already exists.',
'members.error.password_min_8': 'Password must be at least 8 characters.',
'members.error.mutation_generic': 'Action failed: {error}',
// Success toasts fired post-mutation.
'members.success.invite_sent': 'Invite sent to {email}',
'members.success.invite_cancelled': 'Invite cancelled',
'members.success.role_changed': 'Role updated',
'members.success.member_removed': '{name} removed',
'members.success.user_created': 'User {email} created',
// Forced first-login password change (#10). Shown when an admin-provisioned
// user (must_change_password) is routed to #/change-password.
'forcepw.title': 'Set a new password',
'forcepw.subtitle': 'Your account was set up by an administrator. Choose your own password to continue.',
'forcepw.current': 'Current password',
'forcepw.new': 'New password',
'forcepw.confirm': 'Confirm new password',
'forcepw.hint': 'At least 8 characters.',
'forcepw.submit': 'Set password',
'forcepw.submitting': 'Saving...',
'forcepw.success': 'Password updated',
'forcepw.error_required': 'Enter your current and new password.',
'forcepw.error_min8': 'Password must be at least 8 characters.',
'forcepw.error_mismatch': "Passwords don't match.",
'forcepw.error_generic': 'Could not update password. Try again.',
// No-workspace empty state (#12): shown to an org-less signed-in user.
'noworkspace.title': 'No workspaces yet',
'noworkspace.body': "Your account isn't part of any workspace yet. Ask your administrator to add you to one, then sign in again.",
'noworkspace.sign_out': 'Sign out',
// Accept-invite flow (Slice 2C). Toasts that fire post-accept on the
// dashboard. Error variants share one helper in app.js's mapAcceptError().
'accept.success': "You've joined {name}",
'accept.already_member': "You're already a member of {name}",
'accept.error.not_found': 'Invite no longer valid',
'accept.error.expired': 'This invite has expired - ask the admin for a new one',
'accept.error.wrong_account': 'This invite is for a different email address. Sign out and sign in with the right account.',
'accept.error.generic': 'Failed to accept invite. Try again or contact your admin.',
};