feat(phase3): PWA, auto-discovery, bookmarklet, multi-tab sync

- PWA: manifest, service worker (cache-first static, network-first API),
  offline page, install prompt banner
- Auto-discovery: Docker socket + Traefik API scanning, approval UI
- Quick-add bookmarklet: popup-based add page, favicon auto-detect
- Multi-tab sync: BroadcastChannel for theme + data changes
- i18n translations for all new strings (EN/RU)
This commit is contained in:
2026-03-25 00:59:19 +03:00
parent c6a7de895d
commit dd6958b4d6
28 changed files with 1712 additions and 266 deletions
+4
View File
@@ -24,5 +24,9 @@ GUEST_MODE="true"
HEALTHCHECK_CRON="*/5 * * * *"
HEALTHCHECK_TIMEOUT_MS="5000"
# Service Discovery (optional — configure here or in Admin > Settings)
DOCKER_SOCKET_PATH="/var/run/docker.sock"
TRAEFIK_API_URL=""
# Node environment
NODE_ENV="production"
+1 -1
View File
@@ -448,7 +448,7 @@ To avoid scope creep, the MVP should include:
- Additional widget types
### Phase 3
- Auto-discovery (Docker/Traefik)
- ~~Auto-discovery (Docker/Traefik)~~ **DONE** — Phase 5 implementation: discoveryService.ts, /api/admin/discover endpoints, DiscoveryPanel.svelte, SettingsForm discovery config, i18n EN/RU
- Import/Export
- PWA
- Ping history sparklines
+6
View File
@@ -4,6 +4,12 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<meta name="theme-color" content="#6366f1" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Launcher" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon.svg" />
<script>
// Inline script to prevent FOUC — set theme class before first paint
(function () {
@@ -0,0 +1,247 @@
<script lang="ts">
import { t } from 'svelte-i18n';
interface DiscoveredService {
name: string;
url: string;
source: 'docker' | 'traefik';
icon?: string;
description?: string;
alreadyRegistered: boolean;
}
let {
dockerSocketPath = $bindable('/var/run/docker.sock'),
traefikApiUrl = $bindable('')
}: {
dockerSocketPath?: string;
traefikApiUrl?: string;
} = $props();
let scanning = $state(false);
let approving = $state(false);
let services = $state<DiscoveredService[]>([]);
let scanErrors = $state<string[]>([]);
let selected = $state<Set<number>>(new Set());
let statusMessage = $state('');
let statusType: 'success' | 'error' | '' = $state('');
function clearStatus() {
statusMessage = '';
statusType = '';
}
async function handleScan() {
clearStatus();
scanning = true;
services = [];
scanErrors = [];
selected = new Set();
try {
const response = await fetch('/api/admin/discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dockerSocketPath: dockerSocketPath || undefined,
traefikApiUrl: traefikApiUrl || undefined
})
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Discovery scan failed');
}
services = result.data.services;
scanErrors = result.data.errors;
if (services.length === 0) {
statusMessage = $t('admin.discovery_no_results');
statusType = 'error';
}
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Scan failed';
statusType = 'error';
} finally {
scanning = false;
}
}
function toggleSelect(index: number) {
const next = new Set(selected);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
selected = next;
}
function toggleSelectAll() {
const selectableIndices = services
.map((s, i) => (s.alreadyRegistered ? -1 : i))
.filter((i) => i >= 0);
if (selected.size === selectableIndices.length) {
selected = new Set();
} else {
selected = new Set(selectableIndices);
}
}
async function handleApprove() {
if (selected.size === 0) return;
clearStatus();
approving = true;
const toApprove = Array.from(selected).map((i) => services[i]);
try {
const response = await fetch('/api/admin/discover/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ services: toApprove })
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Approval failed');
}
const { created, errors: approveErrors } = result.data;
const parts: string[] = [];
if (created > 0) parts.push(`${created} app(s) created`);
if (approveErrors.length > 0) parts.push(approveErrors.join('; '));
statusMessage = `${$t('admin.discovery_approve')}: ${parts.join('. ')}`;
statusType = approveErrors.length > 0 ? 'error' : 'success';
// Mark approved services as registered
services = services.map((s, i) =>
selected.has(i) ? { ...s, alreadyRegistered: true } : s
);
selected = new Set();
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Approval failed';
statusType = 'error';
} finally {
approving = false;
}
}
const selectableCount = $derived(services.filter((s) => !s.alreadyRegistered).length);
</script>
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.discovery_description')}</p>
<!-- Scan Button -->
<div class="mb-6">
<button
type="button"
onclick={handleScan}
disabled={scanning || (!dockerSocketPath && !traefikApiUrl)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
</button>
</div>
<!-- Scan Errors -->
{#if scanErrors.length > 0}
<div class="mb-4 rounded-md bg-yellow-100 p-3 text-sm text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
{#each scanErrors as scanError}
<p>{scanError}</p>
{/each}
</div>
{/if}
<!-- Results Table -->
{#if services.length > 0}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border">
<th class="px-2 py-2 text-left">
<input
type="checkbox"
checked={selected.size === selectableCount && selectableCount > 0}
onchange={toggleSelectAll}
disabled={selectableCount === 0}
class="h-4 w-4 rounded border-input"
/>
</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('common.name')}</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">URL</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('admin.discovery_source')}</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('admin.discovery_status')}</th>
</tr>
</thead>
<tbody>
{#each services as service, i}
<tr class="border-b border-border/50 hover:bg-muted/50">
<td class="px-2 py-2">
<input
type="checkbox"
checked={selected.has(i)}
onchange={() => toggleSelect(i)}
disabled={service.alreadyRegistered}
class="h-4 w-4 rounded border-input"
/>
</td>
<td class="px-2 py-2 font-medium text-foreground">{service.name}</td>
<td class="px-2 py-2">
<a href={service.url} target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">
{service.url}
</a>
</td>
<td class="px-2 py-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{service.source === 'docker'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}"
>
{service.source === 'docker' ? $t('admin.discovery_source_docker') : $t('admin.discovery_source_traefik')}
</span>
</td>
<td class="px-2 py-2">
{#if service.alreadyRegistered}
<span class="text-xs text-muted-foreground">{$t('admin.discovery_already_registered')}</span>
{:else}
<span class="text-xs font-medium text-green-600 dark:text-green-400">{$t('admin.discovery_new')}</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Approve button -->
{#if selectableCount > 0}
<div class="mt-4">
<button
type="button"
onclick={handleApprove}
disabled={approving || selected.size === 0}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
</button>
</div>
{/if}
{/if}
<!-- Status Message -->
{#if statusMessage}
<div class="mt-4 rounded-md p-3 text-sm {statusType === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}">
{statusMessage}
</div>
{/if}
</section>
+39 -1
View File
@@ -4,7 +4,15 @@
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import type { z } from 'zod';
let { form: formData }: { form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>> } = $props();
let {
form: formData,
dockerSocketPath = $bindable('/var/run/docker.sock'),
traefikApiUrl = $bindable('')
}: {
form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>>;
dockerSocketPath?: string;
traefikApiUrl?: string;
} = $props();
const { form, errors, enhance, delayed } = superForm(formData);
@@ -186,6 +194,36 @@
</div>
</section>
<!-- Service Discovery Configuration -->
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_config')}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.discovery_config_description')}</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="dockerSocketPath" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.discovery_docker_socket')}</label>
<input
id="dockerSocketPath"
type="text"
bind:value={dockerSocketPath}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="/var/run/docker.sock"
/>
<p class="mt-1 text-xs text-muted-foreground">{$t('admin.discovery_docker_socket_hint')}</p>
</div>
<div>
<label for="traefikApiUrl" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.discovery_traefik_url')}</label>
<input
id="traefikApiUrl"
type="url"
bind:value={traefikApiUrl}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
placeholder="http://traefik:8080"
/>
<p class="mt-1 text-xs text-muted-foreground">{$t('admin.discovery_traefik_url_hint')}</p>
</div>
</div>
</section>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
@@ -0,0 +1,103 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { browser } from '$app/environment';
import { Download, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
const DISMISS_KEY = 'wal-install-prompt-dismissed';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
let deferredPrompt: BeforeInstallPromptEvent | null = $state(null);
let dismissed = $state(false);
let installed = $state(false);
const visible = $derived(deferredPrompt !== null && !dismissed && !installed);
function isDismissed(): boolean {
if (!browser) return false;
try {
return localStorage.getItem(DISMISS_KEY) === 'true';
} catch {
return false;
}
}
function dismiss() {
dismissed = true;
if (browser) {
try {
localStorage.setItem(DISMISS_KEY, 'true');
} catch {
// localStorage unavailable
}
}
}
async function install() {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
installed = true;
}
deferredPrompt = null;
}
if (browser) {
dismissed = isDismissed();
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;
});
window.addEventListener('appinstalled', () => {
installed = true;
deferredPrompt = null;
});
}
</script>
{#if visible}
<div
transition:fade={{ duration: 200 }}
class="fixed bottom-4 left-4 right-4 z-50 mx-auto flex max-w-lg items-center gap-3 rounded-xl border border-border bg-card p-4 shadow-lg"
role="alert"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Download class="h-5 w-5 text-primary" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">
{$t('install.title')}
</p>
<p class="text-xs text-muted-foreground">
{$t('install.description')}
</p>
</div>
<button
type="button"
onclick={install}
class="shrink-0 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('install.button')}
</button>
<button
type="button"
onclick={dismiss}
class="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={$t('install.dismiss')}
>
<X class="h-4 w-4" />
</button>
</div>
{/if}
@@ -3,6 +3,7 @@
import type { Snippet } from 'svelte';
import Sidebar from './Sidebar.svelte';
import Header from './Header.svelte';
import InstallPrompt from './InstallPrompt.svelte';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import SearchDialog from '$lib/components/search/SearchDialog.svelte';
import { ui } from '$lib/stores/ui.svelte.js';
@@ -66,3 +67,6 @@
<!-- Search Dialog (modal, z-50) -->
<SearchDialog />
<!-- PWA Install Prompt -->
<InstallPrompt />
@@ -0,0 +1,63 @@
<script lang="ts">
import { t } from 'svelte-i18n';
let origin = $state('');
let bookmarkletCode = $state('');
let bookmarkletHref = $state('');
$effect(() => {
if (typeof window !== 'undefined') {
origin = window.location.origin;
}
});
$effect(() => {
if (!origin) return;
// The bookmarklet opens the quick-add page with URL and title pre-filled
const code = `javascript:void(window.open('${origin}/apps/quick-add?url='+encodeURIComponent(location.href)+'&name='+encodeURIComponent(document.title),'_blank','width=600,height=500'))`;
bookmarkletCode = code;
bookmarkletHref = code;
});
</script>
<div class="rounded-xl border border-border bg-card p-6 shadow-sm">
<h2 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('settings.bookmarklet_title')}
</h2>
<p class="mb-4 text-sm text-muted-foreground">
{$t('settings.bookmarklet_instructions')}
</p>
<div class="mb-4 flex items-center gap-3">
<a
href={bookmarkletHref}
class="inline-flex items-center gap-2 rounded-lg border-2 border-dashed border-primary/50 bg-primary/10 px-4 py-2 text-sm font-medium text-primary transition-colors hover:border-primary hover:bg-primary/20"
onclick={(e) => { e.preventDefault(); }}
draggable="true"
>
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
</svg>
{$t('settings.bookmarklet_drag')}
</a>
<span class="text-xs text-muted-foreground">
{$t('settings.bookmarklet_drag_hint')}
</span>
</div>
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground">
{$t('settings.bookmarklet_show_code')}
</summary>
<pre class="mt-2 overflow-x-auto rounded-md border border-border bg-background p-3 text-xs text-foreground"><code>{bookmarkletCode}</code></pre>
</details>
</div>
+42 -1
View File
@@ -215,6 +215,26 @@
"admin.perm_none": "No permissions configured.",
"admin.perm_search_placeholder": "Type to search...",
"admin.discovery_title": "Service Discovery",
"admin.discovery_description": "Scan Docker containers and Traefik routers to automatically discover running services and register them as apps.",
"admin.discovery_scan": "Scan for Services",
"admin.discovery_scanning": "Scanning...",
"admin.discovery_approve": "Approve Selected",
"admin.discovery_approving": "Approving...",
"admin.discovery_source": "Source",
"admin.discovery_status": "Status",
"admin.discovery_source_docker": "Docker",
"admin.discovery_source_traefik": "Traefik",
"admin.discovery_already_registered": "Already registered",
"admin.discovery_new": "New",
"admin.discovery_no_results": "No services discovered. Check your Docker socket path or Traefik API URL.",
"admin.discovery_config": "Service Discovery Configuration",
"admin.discovery_config_description": "Configure Docker and Traefik endpoints for automatic service discovery. These settings are used by the Discovery panel below.",
"admin.discovery_docker_socket": "Docker Socket Path",
"admin.discovery_docker_socket_hint": "Path to Docker socket (e.g. /var/run/docker.sock). Set via DOCKER_SOCKET_PATH env var.",
"admin.discovery_traefik_url": "Traefik API URL",
"admin.discovery_traefik_url_hint": "Traefik dashboard API base URL (e.g. http://traefik:8080). Set via TRAEFIK_API_URL env var.",
"admin.import_export_title": "Import / Export",
"admin.import_export_description": "Export all data (apps, boards, groups, settings) as JSON, or import from a previously exported file.",
"admin.export_section": "Export Data",
@@ -291,5 +311,26 @@
"settings.language": "Language",
"settings.save": "Save Preferences",
"settings.saving": "Saving...",
"settings.saved": "Preferences saved!"
"settings.saved": "Preferences saved!",
"offline.title": "You're Offline",
"offline.description": "It looks like you've lost your internet connection. Check your network and try again.",
"offline.retry": "Retry",
"install.title": "Install App",
"install.description": "Add Web App Launcher to your home screen for quick access.",
"install.button": "Install",
"install.dismiss": "Dismiss install prompt",
"settings.bookmarklet_title": "Quick-Add Bookmarklet",
"settings.bookmarklet_instructions": "Drag the button below to your browser's bookmarks bar. When visiting any web page, click it to quickly add that site to your App Launcher.",
"settings.bookmarklet_drag": "Add to Launcher",
"settings.bookmarklet_drag_hint": "Drag this to your bookmarks bar",
"settings.bookmarklet_show_code": "Show bookmarklet code",
"app.quick_add_title": "Quick Add App",
"app.quick_add_description": "Review the details below and save to add this app to your launcher.",
"app.quick_add_success": "App added successfully!",
"app.quick_add_view_apps": "View Apps",
"app.quick_add_close": "Close Window"
}
+277 -260
View File
@@ -1,219 +1,228 @@
{
"app_name": "App Launcher",
"app_title": "Web App Launcher",
"nav.navigation": "\u041d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u044f",
"nav.boards": "\u0414\u043e\u0441\u043a\u0438",
"nav.apps": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f",
"nav.admin": "\u0410\u0434\u043c\u0438\u043d",
"nav.admin_panel": "\u041f\u0430\u043d\u0435\u043b\u044c \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430",
"auth.login": "\u0412\u043e\u0439\u0442\u0438",
"auth.login_title": "\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c",
"auth.login_subtitle": "\u0412\u043e\u0439\u0434\u0438\u0442\u0435 \u0432 \u0441\u0432\u043e\u0439 \u0430\u043a\u043a\u0430\u0443\u043d\u0442",
"auth.login_submit": "\u0412\u043e\u0439\u0442\u0438",
"auth.login_submitting": "\u0412\u0445\u043e\u0434...",
"auth.register": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f",
"auth.register_title": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442",
"auth.register_subtitle": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0440\u0430\u0431\u043e\u0442\u0443 \u0441 App Launcher",
"auth.register_submit": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442",
"auth.register_submitting": "\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430...",
"auth.email": "\u042d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u0430\u044f \u043f\u043e\u0447\u0442\u0430",
"nav.navigation": "Навигация",
"nav.boards": "Доски",
"nav.apps": "Приложения",
"nav.admin": "Админ",
"nav.admin_panel": "Панель администратора",
"auth.login": "Войти",
"auth.login_title": "Добро пожаловать",
"auth.login_subtitle": "Войдите в свой аккаунт",
"auth.login_submit": "Войти",
"auth.login_submitting": "Вход...",
"auth.register": "Регистрация",
"auth.register_title": "Создать аккаунт",
"auth.register_subtitle": "Начните работу с App Launcher",
"auth.register_submit": "Создать аккаунт",
"auth.register_submitting": "Создание аккаунта...",
"auth.email": "Электронная почта",
"auth.email_placeholder": "you@example.com",
"auth.password": "\u041f\u0430\u0440\u043e\u043b\u044c",
"auth.password_placeholder": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c",
"auth.password_placeholder_register": "\u041d\u0435 \u043c\u0435\u043d\u0435\u0435 6 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432",
"auth.display_name": "\u0418\u043c\u044f",
"auth.display_name_placeholder": "\u0412\u0430\u0448\u0435 \u0438\u043c\u044f",
"auth.logout": "\u0412\u044b\u0445\u043e\u0434",
"auth.oauth_signin": "\u0412\u043e\u0439\u0442\u0438 \u0447\u0435\u0440\u0435\u0437 OAuth",
"auth.or": "\u0438\u043b\u0438",
"auth.no_account": "\u041d\u0435\u0442 \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430?",
"auth.have_account": "\u0423\u0436\u0435 \u0435\u0441\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442?",
"auth.sign_in_link": "\u0412\u043e\u0439\u0442\u0438",
"board.title": "\u0414\u043e\u0441\u043a\u0438",
"board.boards_available": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u0434\u043e\u0441\u043e\u043a: {count}",
"board.new": "\u041d\u043e\u0432\u0430\u044f \u0434\u043e\u0441\u043a\u0430",
"board.edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c",
"board.edit_board": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0434\u043e\u0441\u043a\u0438",
"board.all_boards": "\u0412\u0441\u0435 \u0434\u043e\u0441\u043a\u0438",
"board.back_to_boards": "\u041d\u0430\u0437\u0430\u0434 \u043a \u0434\u043e\u0441\u043a\u0430\u043c",
"board.back_to_board": "\u041d\u0430\u0437\u0430\u0434 \u043a \u0434\u043e\u0441\u043a\u0435",
"board.no_boards": "\u0414\u043e\u0441\u043a\u0438 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.",
"board.sign_in_more": "\u0412\u043e\u0439\u0434\u0438\u0442\u0435, \u0447\u0442\u043e\u0431\u044b \u0443\u0432\u0438\u0434\u0435\u0442\u044c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u043e\u0441\u043e\u043a.",
"board.no_sections": "\u041d\u0430 \u044d\u0442\u043e\u0439 \u0434\u043e\u0441\u043a\u0435 \u043f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432.",
"board.default": "\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e",
"board.guest": "\u0413\u043e\u0441\u0442\u0435\u0432\u0430\u044f",
"board.sections_count": "\u0420\u0430\u0437\u0434\u0435\u043b\u043e\u0432: {count}",
"board.properties": "\u0421\u0432\u043e\u0439\u0441\u0442\u0432\u0430 \u0434\u043e\u0441\u043a\u0438",
"board.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u0434\u043e\u0441\u043a\u0443",
"board.create": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0434\u043e\u0441\u043a\u0443",
"board.creating": "\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435...",
"board.default_board": "\u0414\u043e\u0441\u043a\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e",
"board.guest_accessible": "\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0433\u043e\u0441\u0442\u044f\u043c",
"board.guest_access_title": "\u0413\u043e\u0441\u0442\u0435\u0432\u043e\u0439 \u0434\u043e\u0441\u0442\u0443\u043f",
"board.guest_access_description": "\u041f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u044d\u0442\u0430 \u0434\u043e\u0441\u043a\u0430 \u0432\u0438\u0434\u043d\u0430 \u043d\u0435\u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u044b\u043c \u043f\u043e\u0441\u0435\u0442\u0438\u0442\u0435\u043b\u044f\u043c \u0431\u0435\u0437 \u0432\u0445\u043e\u0434\u0430 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443.",
"board.guest_access_enabled": "\u042d\u0442\u0430 \u0434\u043e\u0441\u043a\u0430 \u043e\u0431\u0449\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430",
"board.guest_access_disabled": "\u042d\u0442\u0430 \u0434\u043e\u0441\u043a\u0430 \u043f\u0440\u0438\u0432\u0430\u0442\u043d\u0430",
"board.permissions_title": "\u041f\u0440\u0430\u0432\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430",
"board.permissions_description": "\u0423\u043f\u0440\u0430\u0432\u043b\u044f\u0439\u0442\u0435, \u043a\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u043f\u0440\u043e\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u0442\u044c, \u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0438\u043b\u0438 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u0443 \u0434\u043e\u0441\u043a\u0443.",
"board.access_grant": "\u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f",
"board.access_search_placeholder": "\u041f\u043e\u0438\u0441\u043a...",
"board.access_loading": "\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u043f\u0440\u0430\u0432...",
"board.access_none": "\u041f\u0440\u0430\u0432\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0434\u043e\u0441\u043a\u0438 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.",
"board.access_private": "\u041f\u0440\u0438\u0432\u0430\u0442\u043d\u0430\u044f",
"board.access_shared": "\u041e\u0431\u0449\u0430\u044f",
"board.share": "\u041f\u043e\u0434\u0435\u043b\u0438\u0442\u044c\u0441\u044f",
"board.share_title": "\u041f\u043e\u0434\u0435\u043b\u0438\u0442\u044c\u0441\u044f \u00ab{name}\u00bb",
"board.share_copy_link": "\u041a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u0441\u044b\u043b\u043a\u0443",
"board.share_copied": "\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u043e!",
"board.share_guest_description": "\u041b\u044e\u0431\u043e\u0439 \u0441 \u044d\u0442\u043e\u0439 \u0441\u0441\u044b\u043b\u043a\u043e\u0439 \u043c\u043e\u0436\u0435\u0442 \u043f\u0440\u043e\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u0442\u044c \u0434\u043e\u0441\u043a\u0443 \u0431\u0435\u0437 \u0432\u0445\u043e\u0434\u0430.",
"board.share_add_access": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043b\u044e\u0434\u0435\u0439 \u0438\u043b\u0438 \u0433\u0440\u0443\u043f\u043f\u044b",
"board.share_current_access": "\u0422\u0435\u043a\u0443\u0449\u0438\u0439 \u0434\u043e\u0441\u0442\u0443\u043f",
"section.title_label": "\u0417\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a",
"section.icon_label": "\u0418\u043a\u043e\u043d\u043a\u0430",
"section.icon_placeholder": "\u041d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e",
"section.sections": "\u0420\u0430\u0437\u0434\u0435\u043b\u044b",
"section.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0440\u0430\u0437\u0434\u0435\u043b",
"section.create": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0440\u0430\u0437\u0434\u0435\u043b",
"section.order": "\u041f\u043e\u0440\u044f\u0434\u043e\u043a: {order}",
"widget.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0432\u0438\u0434\u0436\u0435\u0442",
"widget.select_app": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435",
"widget.choose_app": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435...",
"widget.no_widgets": "\u0412 \u044d\u0442\u043e\u043c \u0440\u0430\u0437\u0434\u0435\u043b\u0435 \u043d\u0435\u0442 \u0432\u0438\u0434\u0436\u0435\u0442\u043e\u0432.",
"widget.no_widgets_dnd": "\u041d\u0435\u0442 \u0432\u0438\u0434\u0436\u0435\u0442\u043e\u0432. \u041f\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0441\u044e\u0434\u0430 \u0438\u043b\u0438 \u0434\u043e\u0431\u0430\u0432\u044c\u0442\u0435 \u0432\u044b\u0448\u0435.",
"widget.type": "\u0412\u0438\u0434\u0436\u0435\u0442 {type}",
"widget.number": "\u0412\u0438\u0434\u0436\u0435\u0442 #{order}",
"widget.remove": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c",
"app.title": "\u0420\u0435\u0435\u0441\u0442\u0440 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439",
"app.apps_registered": "\u0417\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439: {count}",
"app.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435",
"app.new": "\u041d\u043e\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435",
"app.no_apps": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0435\u0449\u0451 \u043d\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u044b.",
"app.no_apps_hint": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u00bb, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0435\u0440\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435.",
"app.all_categories": "\u0412\u0441\u0435",
"app.name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
"app.name_placeholder": "\u041c\u043e\u0451 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435",
"auth.password": "Пароль",
"auth.password_placeholder": "Введите пароль",
"auth.password_placeholder_register": "Не менее 6 символов",
"auth.display_name": "Имя",
"auth.display_name_placeholder": "Ваше имя",
"auth.logout": "Выход",
"auth.oauth_signin": "Войти через OAuth",
"auth.or": "или",
"auth.no_account": "Нет аккаунта?",
"auth.have_account": "Уже есть аккаунт?",
"auth.sign_in_link": "Войти",
"board.title": "Доски",
"board.boards_available": "Доступно досок: {count}",
"board.new": "Новая доска",
"board.edit": "Редактировать",
"board.edit_board": "Редактирование доски",
"board.all_boards": "Все доски",
"board.back_to_boards": "Назад к доскам",
"board.back_to_board": "Назад к доске",
"board.no_boards": "Доски не найдены.",
"board.sign_in_more": "Войдите, чтобы увидеть больше досок.",
"board.no_sections": "На этой доске пока нет разделов.",
"board.default": "По умолчанию",
"board.guest": "Гостевая",
"board.sections_count": "Разделов: {count}",
"board.properties": "Свойства доски",
"board.save": "Сохранить доску",
"board.create": "Создать доску",
"board.creating": "Создание...",
"board.default_board": "Доска по умолчанию",
"board.guest_accessible": "Доступна гостям",
"board.guest_access_title": "Гостевой доступ",
"board.guest_access_description": "При включении эта доска видна неавторизованным посетителям без входа в систему.",
"board.guest_access_enabled": "Эта доска общедоступна",
"board.guest_access_disabled": "Эта доска приватна",
"board.permissions_title": "Права доступа",
"board.permissions_description": "Управляйте, кто может просматривать, редактировать или администрировать эту доску.",
"board.access_grant": "Назначить доступ",
"board.access_search_placeholder": "Поиск...",
"board.access_loading": "Загрузка прав...",
"board.access_none": "Права доступа для этой доски не настроены.",
"board.access_private": "Приватная",
"board.access_shared": "Общая",
"board.share": "Поделиться",
"board.share_title": "Поделиться «{name}»",
"board.share_copy_link": "Копировать ссылку",
"board.share_copied": "Скопировано!",
"board.share_guest_description": "Любой с этой ссылкой может просматривать доску без входа.",
"board.share_add_access": "Добавить людей или группы",
"board.share_current_access": "Текущий доступ",
"section.title_label": "Заголовок",
"section.icon_label": "Иконка",
"section.icon_placeholder": "Необязательно",
"section.sections": "Разделы",
"section.add": "Добавить раздел",
"section.create": "Создать раздел",
"section.order": "Порядок: {order}",
"widget.add": "Добавить виджет",
"widget.select_app": "Выберите приложение",
"widget.choose_app": "Выберите приложение...",
"widget.no_widgets": "В этом разделе нет виджетов.",
"widget.no_widgets_dnd": "Нет виджетов. Перетащите сюда или добавьте выше.",
"widget.type": "Виджет {type}",
"widget.number": "Виджет #{order}",
"widget.remove": "Удалить",
"app.title": "Реестр приложений",
"app.apps_registered": "Зарегистрировано приложений: {count}",
"app.add": "Добавить приложение",
"app.new": "Новое приложение",
"app.no_apps": "Приложения ещё не зарегистрированы.",
"app.no_apps_hint": "Нажмите «Добавить приложение», чтобы зарегистрировать первое приложение.",
"app.all_categories": "Все",
"app.name": "Название",
"app.name_placeholder": "Моё приложение",
"app.url": "URL",
"app.url_placeholder": "https://my-app.local:8080",
"app.description": "\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435",
"app.description_placeholder": "\u041a\u0440\u0430\u0442\u043a\u043e\u0435 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f",
"app.category": "\u041a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f",
"app.category_placeholder": "\u043d\u0430\u043f\u0440. \u041c\u0435\u0434\u0438\u0430, \u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433, \u0425\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435",
"app.tags": "\u0422\u0435\u0433\u0438",
"app.tags_placeholder": "\u0422\u0435\u0433\u0438 \u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e",
"app.icon": "\u0418\u043a\u043e\u043d\u043a\u0430",
"app.description": "Описание",
"app.description_placeholder": "Краткое описание приложения",
"app.category": "Категория",
"app.category_placeholder": "напр. Медиа, Мониторинг, Хранилище",
"app.tags": "Теги",
"app.tags_placeholder": "Теги через запятую",
"app.icon": "Иконка",
"app.icon_lucide": "Lucide",
"app.icon_simple": "Simple Icons",
"app.icon_url": "URL \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f",
"app.icon_emoji": "\u042d\u043c\u043e\u0434\u0437\u0438",
"app.icon_lucide_placeholder": "\u043d\u0430\u043f\u0440. globe, server, home",
"app.icon_simple_placeholder": "\u043d\u0430\u043f\u0440. github, docker",
"app.icon_url": "URL изображения",
"app.icon_emoji": "Эмодзи",
"app.icon_lucide_placeholder": "напр. globe, server, home",
"app.icon_simple_placeholder": "напр. github, docker",
"app.icon_url_placeholder": "https://example.com/icon.png",
"app.icon_emoji_placeholder": "\u043d\u0430\u043f\u0440. \ud83c\udf10",
"app.icon_preview": "\u041f\u0440\u0435\u0432\u044c\u044e \u0438\u043a\u043e\u043d\u043a\u0438",
"app.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c",
"app.saving": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...",
"app.healthcheck_toggle": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f",
"app.healthcheck_show": "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c",
"app.healthcheck_hide": "\u0421\u043a\u0440\u044b\u0442\u044c",
"app.healthcheck_enabled": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f",
"app.healthcheck_method": "\u041c\u0435\u0442\u043e\u0434",
"app.healthcheck_expected_status": "\u041e\u0436\u0438\u0434\u0430\u0435\u043c\u044b\u0439 \u0441\u0442\u0430\u0442\u0443\u0441",
"app.healthcheck_timeout": "\u0422\u0430\u0439\u043c\u0430\u0443\u0442 (\u043c\u0441)",
"app.healthcheck_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b (\u0441\u0435\u043a\u0443\u043d\u0434\u044b)",
"app.icon_board_label": "\u0418\u043a\u043e\u043d\u043a\u0430 (Lucide)",
"app.uptime": "\u0430\u043f\u0442\u0430\u0439\u043c",
"app.history_loading": "\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0438\u0441\u0442\u043e\u0440\u0438\u0438...",
"admin.panel": "\u041f\u0430\u043d\u0435\u043b\u044c \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430",
"admin.users": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438",
"admin.groups": "\u0413\u0440\u0443\u043f\u043f\u044b",
"admin.settings": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438",
"admin.user_management": "\u0423\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f\u043c\u0438",
"admin.create_user": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f",
"admin.new_user": "\u041d\u043e\u0432\u044b\u0439 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c",
"admin.user_column": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c",
"admin.email_column": "\u042d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u0430\u044f \u043f\u043e\u0447\u0442\u0430",
"admin.role_column": "\u0420\u043e\u043b\u044c",
"admin.provider_column": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440",
"admin.groups_column": "\u0413\u0440\u0443\u043f\u043f\u044b",
"admin.actions_column": "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044f",
"admin.role_user": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c",
"admin.role_admin": "\u0410\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440",
"admin.select_group": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u0443",
"admin.add_to_group": "+ \u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c",
"admin.remove_from_group": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0438\u0437 \u0433\u0440\u0443\u043f\u043f\u044b",
"admin.no_users": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.",
"admin.group_management": "\u0423\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0433\u0440\u0443\u043f\u043f\u0430\u043c\u0438",
"admin.create_group": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u0443",
"admin.new_group": "\u041d\u043e\u0432\u0430\u044f \u0433\u0440\u0443\u043f\u043f\u0430",
"admin.name_column": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
"admin.description_column": "\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435",
"admin.members_column": "\u0423\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u0438",
"admin.default_column": "\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e",
"admin.default_group_hint": "\u0413\u0440\u0443\u043f\u043f\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e (\u0430\u0432\u0442\u043e-\u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u043e\u0432\u044b\u043c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f\u043c)",
"admin.no_groups": "\u0413\u0440\u0443\u043f\u043f\u044b \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.",
"admin.yes": "\u0414\u0430",
"admin.no": "\u041d\u0435\u0442",
"admin.system_settings": "\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438",
"admin.settings_description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0433\u043b\u043e\u0431\u0430\u043b\u044c\u043d\u044b\u0445 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.",
"admin.authentication": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f",
"admin.auth_mode": "\u0420\u0435\u0436\u0438\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438",
"admin.auth_local": "\u041b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0439",
"app.icon_emoji_placeholder": "напр. 🌐",
"app.icon_preview": "Превью иконки",
"app.save": "Сохранить",
"app.saving": "Сохранение...",
"app.healthcheck_toggle": "Настройки проверки здоровья",
"app.healthcheck_show": "Показать",
"app.healthcheck_hide": "Скрыть",
"app.healthcheck_enabled": "Включить проверку здоровья",
"app.healthcheck_method": "Метод",
"app.healthcheck_expected_status": "Ожидаемый статус",
"app.healthcheck_timeout": "Таймаут (мс)",
"app.healthcheck_interval": "Интервал (секунды)",
"app.icon_board_label": "Иконка (Lucide)",
"app.uptime": "аптайм",
"app.history_loading": "Загрузка истории...",
"admin.panel": "Панель администратора",
"admin.users": "Пользователи",
"admin.groups": "Группы",
"admin.settings": "Настройки",
"admin.user_management": "Управление пользователями",
"admin.create_user": "Создать пользователя",
"admin.new_user": "Новый пользователь",
"admin.user_column": "Пользователь",
"admin.email_column": "Электронная почта",
"admin.role_column": "Роль",
"admin.provider_column": "Провайдер",
"admin.groups_column": "Группы",
"admin.actions_column": "Действия",
"admin.role_user": "Пользователь",
"admin.role_admin": "Администратор",
"admin.select_group": "Выбрать группу",
"admin.add_to_group": "+ Добавить",
"admin.remove_from_group": "Удалить из группы",
"admin.no_users": "Пользователи не найдены.",
"admin.group_management": "Управление группами",
"admin.create_group": "Создать группу",
"admin.new_group": "Новая группа",
"admin.name_column": "Название",
"admin.description_column": "Описание",
"admin.members_column": "Участники",
"admin.default_column": "По умолчанию",
"admin.default_group_hint": "Группа по умолчанию (авто-назначение новым пользователям)",
"admin.no_groups": "Группы не найдены.",
"admin.yes": "Да",
"admin.no": "Нет",
"admin.system_settings": "Системные настройки",
"admin.settings_description": "Настройка глобальных параметров приложения.",
"admin.authentication": "Аутентификация",
"admin.auth_mode": "Режим аутентификации",
"admin.auth_local": "Локальный",
"admin.auth_oauth": "OAuth",
"admin.auth_both": "\u041e\u0431\u0430",
"admin.registration_enabled": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439",
"admin.oauth_config": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 OAuth",
"admin.oauth_description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 OIDC (\u043d\u0430\u043f\u0440. Authentik, Keycloak). \u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0435 \u0440\u0435\u0436\u0438\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u00abOAuth\u00bb \u0438\u043b\u0438 \u00ab\u041e\u0431\u0430\u00bb \u0432\u044b\u0448\u0435, \u0447\u0442\u043e\u0431\u044b \u0432\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0447\u0435\u0440\u0435\u0437 OAuth.",
"admin.auth_both": "Оба",
"admin.registration_enabled": "Разрешить регистрацию пользователей",
"admin.oauth_config": "Настройка OAuth",
"admin.oauth_description": "Настройте провайдер OIDC (напр. Authentik, Keycloak). Установите режим аутентификации «OAuth» или «Оба» выше, чтобы включить вход через OAuth.",
"admin.oauth_client_id": "Client ID",
"admin.oauth_client_id_placeholder": "OAuth client ID",
"admin.oauth_client_secret": "\u0421\u0435\u043a\u0440\u0435\u0442 \u043a\u043b\u0438\u0435\u043d\u0442\u0430",
"admin.oauth_client_secret_placeholder": "\u0421\u0435\u043a\u0440\u0435\u0442 OAuth \u043a\u043b\u0438\u0435\u043d\u0442\u0430",
"admin.oauth_client_secret": "Секрет клиента",
"admin.oauth_client_secret_placeholder": "Секрет OAuth клиента",
"admin.oauth_discovery_url": "Discovery URL",
"admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration",
"admin.oauth_test": "\u0422\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435",
"admin.oauth_testing": "\u0422\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435...",
"admin.oauth_connected": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a: {issuer}",
"admin.oauth_network_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0442\u0438 \u2014 \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0441\u0432\u044f\u0437\u0430\u0442\u044c\u0441\u044f \u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u043e\u043c",
"admin.theme_defaults": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0442\u0435\u043c\u044b",
"admin.default_theme": "\u0422\u0435\u043c\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e",
"admin.default_primary_color": "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0446\u0432\u0435\u0442 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e",
"admin.healthcheck_defaults": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f",
"admin.healthcheck_defaults_description": "JSON-\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u044f \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e (\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b, \u0442\u0430\u0439\u043c\u0430\u0443\u0442, \u043c\u0435\u0442\u043e\u0434).",
"admin.healthcheck_defaults_label": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 (JSON)",
"admin.save_settings": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438",
"admin.saving_settings": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...",
"admin.oauth_test": "Тестировать подключение",
"admin.oauth_testing": "Тестирование...",
"admin.oauth_connected": "Подключено к: {issuer}",
"admin.oauth_network_error": "Ошибка сети — не удалось связаться с сервером",
"admin.theme_defaults": "Настройки темы",
"admin.default_theme": "Тема по умолчанию",
"admin.default_primary_color": "Основной цвет по умолчанию",
"admin.healthcheck_defaults": "Настройки проверки здоровья",
"admin.healthcheck_defaults_description": "JSON-конфигурация проверки здоровья по умолчанию (интервал, таймаут, метод).",
"admin.healthcheck_defaults_label": "Настройки (JSON)",
"admin.save_settings": "Сохранить настройки",
"admin.saving_settings": "Сохранение...",
"admin.perm_title": "Назначить права",
"admin.perm_entity_type": "Тип объекта",
"admin.perm_entity": "Объект",
"admin.perm_target_type": "Тип цели",
"admin.perm_target": "Цель",
"admin.perm_level": "Уровень",
"admin.perm_board": "Доска",
"admin.perm_app": "Приложение",
"admin.perm_user": "Пользователь",
"admin.perm_group": "Группа",
"admin.perm_view": "Просмотр",
"admin.perm_edit": "Редактирование",
"admin.perm_admin": "Администратор",
"admin.perm_grant": "Назначить",
"admin.perm_revoke": "Отозвать",
"admin.perm_select": "Выбрать...",
"admin.perm_entity_column": "Объект",
"admin.perm_target_column": "Цель",
"admin.perm_level_column": "Уровень",
"admin.perm_action_column": "Действие",
"admin.perm_none": "Права не настроены.",
"admin.perm_search_placeholder": "Начните вводить...",
"admin.perm_title": "\u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c \u043f\u0440\u0430\u0432\u0430",
"admin.perm_entity_type": "\u0422\u0438\u043f \u043e\u0431\u044a\u0435\u043a\u0442\u0430",
"admin.perm_entity": "\u041e\u0431\u044a\u0435\u043a\u0442",
"admin.perm_target_type": "\u0422\u0438\u043f \u0446\u0435\u043b\u0438",
"admin.perm_target": "\u0426\u0435\u043b\u044c",
"admin.perm_level": "\u0423\u0440\u043e\u0432\u0435\u043d\u044c",
"admin.perm_board": "\u0414\u043e\u0441\u043a\u0430",
"admin.perm_app": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435",
"admin.perm_user": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c",
"admin.perm_group": "\u0413\u0440\u0443\u043f\u043f\u0430",
"admin.perm_view": "\u041f\u0440\u043e\u0441\u043c\u043e\u0442\u0440",
"admin.perm_edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435",
"admin.perm_admin": "\u0410\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440",
"admin.perm_grant": "\u041d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c",
"admin.perm_revoke": "\u041e\u0442\u043e\u0437\u0432\u0430\u0442\u044c",
"admin.perm_select": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c...",
"admin.perm_entity_column": "\u041e\u0431\u044a\u0435\u043a\u0442",
"admin.perm_target_column": "\u0426\u0435\u043b\u044c",
"admin.perm_level_column": "\u0423\u0440\u043e\u0432\u0435\u043d\u044c",
"admin.perm_action_column": "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u0435",
"admin.perm_none": "\u041f\u0440\u0430\u0432\u0430 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.",
"admin.perm_search_placeholder": "\u041d\u0430\u0447\u043d\u0438\u0442\u0435 \u0432\u0432\u043e\u0434\u0438\u0442\u044c...",
"admin.discovery_title": "Обнаружение сервисов",
"admin.discovery_description": "Сканируйте Docker-контейнеры и маршруты Traefik для автоматического обнаружения работающих сервисов и их регистрации как приложений.",
"admin.discovery_scan": "Сканировать сервисы",
"admin.discovery_scanning": "Сканирование...",
"admin.discovery_approve": "Одобрить выбранные",
"admin.discovery_approving": "Одобрение...",
"admin.discovery_source": "Источник",
"admin.discovery_status": "Статус",
"admin.discovery_source_docker": "Docker",
"admin.discovery_source_traefik": "Traefik",
"admin.discovery_already_registered": "Уже зарегистрировано",
"admin.discovery_new": "Новый",
"admin.discovery_no_results": "Сервисы не обнаружены. Проверьте путь к Docker-сокету или URL API Traefik.",
"admin.discovery_config": "Настройка обнаружения сервисов",
"admin.discovery_config_description": "Настройте конечные точки Docker и Traefik для автоматического обнаружения сервисов. Эти настройки используются панелью обнаружения ниже.",
"admin.discovery_docker_socket": "Путь к Docker-сокету",
"admin.discovery_docker_socket_hint": "Путь к Docker-сокету (напр. /var/run/docker.sock). Задаётся через DOCKER_SOCKET_PATH.",
"admin.discovery_traefik_url": "URL API Traefik",
"admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.",
"admin.import_export_title": "Импорт / Экспорт",
"admin.import_export_description": "Экспортируйте все данные (приложения, доски, группы, настройки) в формате JSON или импортируйте из ранее экспортированного файла.",
@@ -231,65 +240,73 @@
"admin.import_importing": "Импорт...",
"admin.import_success": "Импорт завершён.",
"admin.import_invalid_json": "Выбранный файл не является корректным JSON.",
"search.placeholder": "Поиск приложений и досок...",
"search.trigger": "\u041f\u043e\u0438\u0441\u043a...",
"search.min_chars": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043c\u0438\u043d\u0438\u043c\u0443\u043c 2 \u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u0434\u043b\u044f \u043f\u043e\u0438\u0441\u043a\u0430",
"search.no_results": "\u041d\u0438\u0447\u0435\u0433\u043e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u043f\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u0443 \u00ab{query}\u00bb",
"search.apps": "\u041f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f",
"search.boards": "\u0414\u043e\u0441\u043a\u0438",
"common.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c",
"common.cancel": "\u041e\u0442\u043c\u0435\u043d\u0430",
"common.delete": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c",
"common.create": "\u0421\u043e\u0437\u0434\u0430\u0442\u044c",
"common.back": "\u041d\u0430\u0437\u0430\u0434",
"common.edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c",
"common.add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c",
"common.confirm": "\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c?",
"common.yes": "\u0414\u0430",
"common.no": "\u041d\u0435\u0442",
"common.name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
"common.description": "\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435",
"search.trigger": "Поиск...",
"search.min_chars": "Введите минимум 2 символа для поиска",
"search.no_results": "Ничего не найдено по запросу «{query}»",
"search.apps": "Приложения",
"search.boards": "Доски",
"common.save": "Сохранить",
"common.cancel": "Отмена",
"common.delete": "Удалить",
"common.create": "Создать",
"common.back": "Назад",
"common.edit": "Редактировать",
"common.add": "Добавить",
"common.confirm": "Подтвердить?",
"common.yes": "Да",
"common.no": "Нет",
"common.name": "Название",
"common.description": "Описание",
"common.required": "*",
"status.online": "\u041e\u043d\u043b\u0430\u0439\u043d",
"status.offline": "\u041e\u0444\u0444\u043b\u0430\u0439\u043d",
"status.degraded": "\u041d\u0435\u0441\u0442\u0430\u0431\u0438\u043b\u044c\u043d\u043e",
"status.unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e",
"theme.dark": "\u0422\u0451\u043c\u043d\u0430\u044f",
"theme.light": "\u0421\u0432\u0435\u0442\u043b\u0430\u044f",
"theme.system": "\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u0430\u044f",
"theme.toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0442\u0435\u043c\u0443 (\u0442\u0435\u043a\u0443\u0449\u0430\u044f: {mode})",
"theme.title": "\u0422\u0435\u043c\u0430: {mode}",
"bg.mesh": "\u041c\u0435\u0448-\u0433\u0440\u0430\u0434\u0438\u0435\u043d\u0442",
"bg.particles": "\u0427\u0430\u0441\u0442\u0438\u0446\u044b",
"bg.aurora": "\u0421\u0438\u044f\u043d\u0438\u0435",
"bg.none": "\u041d\u0435\u0442",
"bg.title": "\u042d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430",
"bg.aria_label": "\u0418\u0437\u043c\u0435\u043d\u0438\u0442\u044c \u044d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430",
"sidebar.expand": "\u0420\u0430\u0437\u0432\u0435\u0440\u043d\u0443\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c",
"sidebar.collapse": "\u0421\u0432\u0435\u0440\u043d\u0443\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c",
"sidebar.toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c",
"sidebar.close": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u0431\u043e\u043a\u043e\u0432\u0443\u044e \u043f\u0430\u043d\u0435\u043b\u044c",
"home.welcome": "\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c, {name}. \u0414\u043e\u0441\u043a\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0435\u0449\u0451 \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.",
"home.view_boards": "\u041f\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0434\u043e\u0441\u043a\u0438",
"home.browse_apps": "\u041e\u0431\u0437\u043e\u0440 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439",
"language.label": "\u042f\u0437\u044b\u043a",
"settings.title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438",
"settings.theme": "\u0420\u0435\u0436\u0438\u043c \u0442\u0435\u043c\u044b",
"settings.primary_color": "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0446\u0432\u0435\u0442",
"settings.hue": "\u041e\u0442\u0442\u0435\u043d\u043e\u043a",
"settings.saturation": "\u041d\u0430\u0441\u044b\u0449\u0435\u043d\u043d\u043e\u0441\u0442\u044c",
"settings.background": "\u042d\u0444\u0444\u0435\u043a\u0442 \u0444\u043e\u043d\u0430",
"settings.language": "\u042f\u0437\u044b\u043a",
"settings.save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438",
"settings.saving": "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...",
"settings.saved": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u044b!"
"status.online": "Онлайн",
"status.offline": "Оффлайн",
"status.degraded": "Нестабильно",
"status.unknown": "Неизвестно",
"theme.dark": "Тёмная",
"theme.light": "Светлая",
"theme.system": "Системная",
"theme.toggle": "Переключить тему (текущая: {mode})",
"theme.title": "Тема: {mode}",
"bg.mesh": "Меш-градиент",
"bg.particles": "Частицы",
"bg.aurora": "Сияние",
"bg.none": "Нет",
"bg.title": "Эффект фона",
"bg.aria_label": "Изменить эффект фона",
"sidebar.expand": "Развернуть боковую панель",
"sidebar.collapse": "Свернуть боковую панель",
"sidebar.toggle": "Переключить боковую панель",
"sidebar.close": "Закрыть боковую панель",
"home.welcome": "Добро пожаловать, {name}. Доска по умолчанию ещё не настроена.",
"home.view_boards": "Посмотреть доски",
"home.browse_apps": "Обзор приложений",
"language.label": "Язык",
"settings.title": "Настройки",
"settings.theme": "Режим темы",
"settings.primary_color": "Основной цвет",
"settings.hue": "Оттенок",
"settings.saturation": "Насыщенность",
"settings.background": "Эффект фона",
"settings.language": "Язык",
"settings.save": "Сохранить настройки",
"settings.saving": "Сохранение...",
"settings.saved": "Настройки сохранены!",
"settings.bookmarklet_title": "Быстрое добавление (букмарклет)",
"settings.bookmarklet_instructions": "Перетащите кнопку ниже на панель закладок браузера. При посещении любой страницы нажмите её, чтобы быстро добавить сайт в App Launcher.",
"settings.bookmarklet_drag": "Добавить в Launcher",
"settings.bookmarklet_drag_hint": "Перетащите на панель закладок",
"settings.bookmarklet_show_code": "Показать код букмарклета",
"app.quick_add_title": "Быстрое добавление приложения",
"app.quick_add_description": "Проверьте данные ниже и сохраните, чтобы добавить приложение в лаунчер.",
"app.quick_add_success": "Приложение успешно добавлено!",
"app.quick_add_view_apps": "Посмотреть приложения",
"app.quick_add_close": "Закрыть окно",
"offline.title": "Нет подключения",
"offline.description": "Похоже, вы потеряли подключение к интернету. Проверьте сеть и попробуйте снова.",
"offline.retry": "Повторить",
"install.title": "Установить приложение",
"install.description": "Добавьте Web App Launcher на главный экран для быстрого доступа.",
"install.button": "Установить",
"install.dismiss": "Скрыть предложение установки"
}
+262
View File
@@ -0,0 +1,262 @@
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { findAll as findAllApps } from './appService.js';
const execAsync = promisify(exec);
// --- Types ---
export interface DiscoveredService {
readonly name: string;
readonly url: string;
readonly source: 'docker' | 'traefik';
readonly icon?: string;
readonly description?: string;
readonly alreadyRegistered: boolean;
}
export interface DiscoveryConfig {
readonly dockerSocketPath?: string;
readonly traefikApiUrl?: string;
}
export interface DiscoveryResult {
readonly services: readonly DiscoveredService[];
readonly errors: readonly string[];
}
// --- Docker types ---
interface DockerContainer {
readonly Id: string;
readonly Names: readonly string[];
readonly Image: string;
readonly Ports: readonly DockerPort[];
readonly Labels: Record<string, string>;
readonly State: string;
}
interface DockerPort {
readonly IP?: string;
readonly PrivatePort: number;
readonly PublicPort?: number;
readonly Type: string;
}
// --- Traefik types ---
interface TraefikRouter {
readonly name: string;
readonly rule: string;
readonly service: string;
readonly entryPoints?: readonly string[];
readonly status?: string;
}
interface TraefikService {
readonly name: string;
readonly loadBalancer?: {
readonly servers?: readonly { readonly url: string }[];
};
}
// --- Docker Discovery ---
function extractUrlFromDockerLabels(labels: Record<string, string>): string | null {
// Check for Traefik Host rule in labels
for (const [key, value] of Object.entries(labels)) {
if (key.match(/traefik\.http\.routers\..+\.rule/)) {
const hostMatch = value.match(/Host\(`([^`]+)`\)/);
if (hostMatch) {
const scheme = labels[key.replace('.rule', '.entrypoints')]?.includes('websecure')
? 'https'
: 'http';
return `${scheme}://${hostMatch[1]}`;
}
}
}
return null;
}
function extractNameFromContainer(container: DockerContainer): string {
const rawName = container.Names[0] ?? container.Id.slice(0, 12);
// Docker container names start with /
return rawName.replace(/^\//, '');
}
function buildUrlFromPorts(ports: readonly DockerPort[]): string | null {
const publicPort = ports.find((p) => p.PublicPort && p.Type === 'tcp');
if (publicPort?.PublicPort) {
const host = publicPort.IP && publicPort.IP !== '0.0.0.0' ? publicPort.IP : 'localhost';
return `http://${host}:${publicPort.PublicPort}`;
}
return null;
}
export async function discoverDocker(socketPath: string): Promise<{
readonly services: readonly DiscoveredService[];
readonly error?: string;
}> {
try {
// Use curl with Unix socket to query Docker API
const { stdout } = await execAsync(
`curl -s --unix-socket "${socketPath}" http://localhost/containers/json?all=false`,
{ timeout: 10000 }
);
const containers: DockerContainer[] = JSON.parse(stdout);
const services: DiscoveredService[] = [];
for (const container of containers) {
const name = extractNameFromContainer(container);
const labelUrl = extractUrlFromDockerLabels(container.Labels);
const portUrl = buildUrlFromPorts(container.Ports);
const url = labelUrl ?? portUrl;
if (!url) {
continue; // Skip containers without accessible URLs
}
const description = container.Labels['org.opencontainers.image.description']
?? `Docker container: ${container.Image}`;
services.push({
name,
url,
source: 'docker',
icon: container.Labels['org.opencontainers.image.title']?.toLowerCase(),
description,
alreadyRegistered: false // Will be resolved in discoverAll
});
}
return { services };
} catch (err) {
const message = err instanceof Error ? err.message : 'Docker discovery failed';
return { services: [], error: message };
}
}
// --- Traefik Discovery ---
function extractHostFromRule(rule: string): string | null {
const hostMatch = rule.match(/Host\(`([^`]+)`\)/);
return hostMatch ? hostMatch[1] : null;
}
export async function discoverTraefik(apiUrl: string): Promise<{
readonly services: readonly DiscoveredService[];
readonly error?: string;
}> {
try {
const normalizedUrl = apiUrl.replace(/\/+$/, '');
const [routersRes, servicesRes] = await Promise.all([
fetch(`${normalizedUrl}/api/http/routers`),
fetch(`${normalizedUrl}/api/http/services`)
]);
if (!routersRes.ok) {
return {
services: [],
error: `Traefik routers API returned ${routersRes.status}`
};
}
const routers: TraefikRouter[] = await routersRes.json();
const traefikServices: TraefikService[] = servicesRes.ok ? await servicesRes.json() : [];
// Build a map of service name -> backend URL
const serviceUrlMap = new Map<string, string>();
for (const svc of traefikServices) {
const backendUrl = svc.loadBalancer?.servers?.[0]?.url;
if (backendUrl) {
serviceUrlMap.set(svc.name, backendUrl);
}
}
const services: DiscoveredService[] = [];
for (const router of routers) {
const host = extractHostFromRule(router.rule);
if (!host) continue;
const isSecure = router.entryPoints?.some(
(ep) => ep === 'websecure' || ep === 'https'
);
const frontendUrl = `${isSecure ? 'https' : 'http'}://${host}`;
// Derive a clean name from the router name (strip @provider suffix)
const name = router.name.replace(/@.*$/, '');
services.push({
name,
url: frontendUrl,
source: 'traefik',
description: serviceUrlMap.get(router.service)
? `Backend: ${serviceUrlMap.get(router.service)}`
: undefined,
alreadyRegistered: false // Will be resolved in discoverAll
});
}
return { services };
} catch (err) {
const message = err instanceof Error ? err.message : 'Traefik discovery failed';
return { services: [], error: message };
}
}
// --- Combined Discovery ---
export async function discoverAll(config: DiscoveryConfig): Promise<DiscoveryResult> {
const errors: string[] = [];
const allServices: DiscoveredService[] = [];
// Run discovery in parallel
const [dockerResult, traefikResult] = await Promise.all([
config.dockerSocketPath
? discoverDocker(config.dockerSocketPath)
: Promise.resolve({ services: [] as DiscoveredService[] }),
config.traefikApiUrl
? discoverTraefik(config.traefikApiUrl)
: Promise.resolve({ services: [] as DiscoveredService[] })
]);
if ('error' in dockerResult && dockerResult.error) {
errors.push(`Docker: ${dockerResult.error}`);
}
allServices.push(...dockerResult.services);
if ('error' in traefikResult && traefikResult.error) {
errors.push(`Traefik: ${traefikResult.error}`);
}
allServices.push(...traefikResult.services);
// Deduplicate by URL (prefer Traefik entries since they have frontend URLs)
const urlMap = new Map<string, DiscoveredService>();
for (const service of allServices) {
const normalizedUrl = service.url.replace(/\/+$/, '').toLowerCase();
const existing = urlMap.get(normalizedUrl);
if (!existing || (service.source === 'traefik' && existing.source === 'docker')) {
urlMap.set(normalizedUrl, service);
}
}
// Check which services are already registered as apps
const existingApps = await findAllApps();
const existingUrls = new Set(
existingApps.map((app) => app.url.replace(/\/+$/, '').toLowerCase())
);
const services = Array.from(urlMap.values()).map((service) => {
const normalizedUrl = service.url.replace(/\/+$/, '').toLowerCase();
return {
...service,
alreadyRegistered: existingUrls.has(normalizedUrl)
};
});
return { services, errors };
}
+38
View File
@@ -1,3 +1,5 @@
import { broadcastThemeChange } from '$lib/utils/broadcastSync.js';
const THEME_STORAGE_KEY = 'wal-theme-mode';
const PRIMARY_HUE_KEY = 'wal-primary-hue';
const PRIMARY_SAT_KEY = 'wal-primary-sat';
@@ -36,6 +38,7 @@ class ThemeStore {
backgroundType = $state<BackgroundType>('mesh');
#systemPreference: 'dark' | 'light' = 'dark';
#suppressBroadcast = false;
resolvedMode = $derived<'dark' | 'light'>(
this.mode === 'system' ? this.#systemPreference : this.mode
@@ -98,6 +101,20 @@ class ThemeStore {
html.style.setProperty('--primary-h', String(this.primaryHue));
html.style.setProperty('--primary-s', `${this.primarySaturation}%`);
});
// Broadcast theme changes to other tabs
$effect(() => {
// Read all reactive values to track them
const snapshot = {
mode: this.mode,
primaryHue: this.primaryHue,
primarySaturation: this.primarySaturation,
backgroundType: this.backgroundType
};
if (typeof window === 'undefined') return;
if (this.#suppressBroadcast) return;
broadcastThemeChange(snapshot);
});
}
cycleMode() {
@@ -119,6 +136,27 @@ class ThemeStore {
this.primarySaturation = Math.max(0, Math.min(100, saturation));
}
/**
* Apply theme values received from another tab via BroadcastChannel.
* Suppresses re-broadcasting to avoid echo loops.
*/
applyFromBroadcast(values: {
mode: ThemeMode;
primaryHue: number;
primarySaturation: number;
backgroundType: BackgroundType;
}) {
this.#suppressBroadcast = true;
this.mode = values.mode;
this.primaryHue = values.primaryHue;
this.primarySaturation = values.primarySaturation;
this.backgroundType = values.backgroundType;
// Re-enable on next microtask so the effect reads suppressBroadcast=true
queueMicrotask(() => {
this.#suppressBroadcast = false;
});
}
/**
* Apply non-null server-stored user preferences over localStorage defaults.
* Call from +layout.svelte when user data is available.
+75
View File
@@ -0,0 +1,75 @@
import type { ThemeMode, BackgroundType } from '$lib/stores/theme.svelte';
const CHANNEL_NAME = 'wal-sync';
export interface ThemeChangeMessage {
readonly type: 'theme-change';
readonly payload: {
readonly mode: ThemeMode;
readonly primaryHue: number;
readonly primarySaturation: number;
readonly backgroundType: BackgroundType;
};
}
export interface DataChangeMessage {
readonly type: 'data-change';
readonly payload: {
readonly entity: 'board' | 'app' | 'widget';
};
}
export type SyncMessage = ThemeChangeMessage | DataChangeMessage;
function getChannel(): BroadcastChannel | null {
if (typeof window === 'undefined') return null;
try {
return new BroadcastChannel(CHANNEL_NAME);
} catch {
return null;
}
}
/**
* Broadcast a theme change to other tabs.
*/
export function broadcastThemeChange(theme: ThemeChangeMessage['payload']): void {
const channel = getChannel();
if (!channel) return;
const message: ThemeChangeMessage = { type: 'theme-change', payload: theme };
channel.postMessage(message);
channel.close();
}
/**
* Broadcast a data change (board/app/widget CRUD) to other tabs.
*/
export function broadcastDataChange(entity: 'board' | 'app' | 'widget'): void {
const channel = getChannel();
if (!channel) return;
const message: DataChangeMessage = { type: 'data-change', payload: { entity } };
channel.postMessage(message);
channel.close();
}
/**
* Listen for sync messages from other tabs.
* Returns a cleanup function to stop listening.
*/
export function onSyncMessage(callback: (msg: SyncMessage) => void): () => void {
const channel = getChannel();
if (!channel) return () => {};
const handler = (event: MessageEvent<SyncMessage>) => {
if (event.data && typeof event.data.type === 'string') {
callback(event.data);
}
};
channel.addEventListener('message', handler);
return () => {
channel.removeEventListener('message', handler);
channel.close();
};
}
+14
View File
@@ -10,6 +10,9 @@
import { ui } from '$lib/stores/ui.svelte';
import { search } from '$lib/stores/search.svelte';
import { locale as i18nLocale } from 'svelte-i18n';
import { onSyncMessage } from '$lib/utils/broadcastSync.js';
import { invalidateAll } from '$app/navigation';
import { onDestroy } from 'svelte';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
@@ -26,6 +29,17 @@
ui.initEffects();
search.initEffects();
// Listen for cross-tab sync messages (theme changes & data invalidation)
const cleanupSync = onSyncMessage((msg) => {
if (msg.type === 'theme-change') {
theme.applyFromBroadcast(msg.payload);
} else if (msg.type === 'data-change') {
invalidateAll();
}
});
onDestroy(cleanupSync);
// Pages that should NOT have the main layout (login, register)
const noLayoutPaths = ['/login', '/register'];
const showLayout = $derived(!noLayoutPaths.includes($page.url.pathname));
+8 -1
View File
@@ -34,7 +34,14 @@ export const load: PageServerLoad = async (event) => {
zod(updateSystemSettingsSchema)
);
return { settings, form };
return {
settings,
form,
discoveryConfig: {
dockerSocketPath: process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock',
traefikApiUrl: process.env.TRAEFIK_API_URL || ''
}
};
};
export const actions: Actions = {
+7 -1
View File
@@ -3,8 +3,12 @@
import type { PageData } from './$types.js';
import SettingsForm from '$lib/components/admin/SettingsForm.svelte';
import ImportExportPanel from '$lib/components/admin/ImportExportPanel.svelte';
import DiscoveryPanel from '$lib/components/admin/DiscoveryPanel.svelte';
let { data }: { data: PageData } = $props();
let dockerSocketPath = $state(data.discoveryConfig?.dockerSocketPath ?? '/var/run/docker.sock');
let traefikApiUrl = $state(data.discoveryConfig?.traefikApiUrl ?? '');
</script>
<svelte:head>
@@ -17,7 +21,9 @@
<p class="mt-1 text-sm text-muted-foreground">{$t('admin.settings_description')}</p>
</div>
<SettingsForm form={data.form} />
<SettingsForm form={data.form} bind:dockerSocketPath bind:traefikApiUrl />
<DiscoveryPanel bind:dockerSocketPath bind:traefikApiUrl />
<ImportExportPanel />
</div>
+41
View File
@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { discoverAll, type DiscoveryConfig } from '$lib/server/services/discoveryService.js';
import { success, error } from '$lib/server/utils/response.js';
/**
* POST /api/admin/discover — Scan Docker and Traefik for services. Admin only.
*
* Body: { dockerSocketPath?: string, traefikApiUrl?: string }
*/
export const POST: RequestHandler = async (event) => {
requireAdmin(event);
let body: DiscoveryConfig;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const config: DiscoveryConfig = {
dockerSocketPath: body.dockerSocketPath || undefined,
traefikApiUrl: body.traefikApiUrl || undefined
};
if (!config.dockerSocketPath && !config.traefikApiUrl) {
return json(
error('At least one discovery source must be configured (dockerSocketPath or traefikApiUrl)'),
{ status: 400 }
);
}
try {
const result = await discoverAll(config);
return json(success(result));
} catch (err) {
const message = err instanceof Error ? err.message : 'Discovery scan failed';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,70 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { create } from '$lib/server/services/appService.js';
import { success, error } from '$lib/server/utils/response.js';
interface ApproveServiceInput {
readonly name: string;
readonly url: string;
readonly source: 'docker' | 'traefik';
readonly icon?: string;
readonly description?: string;
}
interface ApproveBody {
readonly services: readonly ApproveServiceInput[];
}
/**
* POST /api/admin/discover/approve — Approve discovered services and create app entries. Admin only.
*
* Body: { services: DiscoveredService[] }
*/
export const POST: RequestHandler = async (event) => {
const user = requireAdmin(event);
let body: ApproveBody;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
if (!body.services || !Array.isArray(body.services) || body.services.length === 0) {
return json(error('At least one service must be provided for approval'), { status: 400 });
}
const created: string[] = [];
const errors: string[] = [];
for (const service of body.services) {
if (!service.name || !service.url) {
errors.push(`Skipped invalid service entry (missing name or url)`);
continue;
}
try {
const app = await create({
name: service.name,
url: service.url,
icon: service.icon,
description: service.description ?? `Discovered via ${service.source}`,
category: 'Discovered',
healthcheckEnabled: true,
createdById: user.id
});
created.push(app.id);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
errors.push(`Failed to create "${service.name}": ${message}`);
}
}
return json(
success({
created: created.length,
errors
})
);
};
+71
View File
@@ -0,0 +1,71 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as appService from '$lib/server/services/appService.js';
import { success, error } from '$lib/server/utils/response.js';
import { z } from 'zod';
const quickAddSchema = z.object({
url: z
.string()
.url('Invalid URL')
.refine(
(u) => u.startsWith('http://') || u.startsWith('https://'),
'URL must use http or https protocol'
),
name: z.string().min(1, 'Name is required').max(200),
description: z.string().max(1000).optional()
});
/**
* POST /api/apps/quick-add — Quick-add an app with sensible defaults.
* Accepts { url, name, description? }, creates app with healthcheck enabled
* and attempts to auto-detect a favicon icon from the URL's domain.
*/
export const POST: RequestHandler = async (event) => {
const user = requireAuth(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
return json(error('Invalid JSON body'), { status: 400 });
}
const parsed = quickAddSchema.safeParse(body);
if (!parsed.success) {
const messages = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(messages), { status: 400 });
}
const { url, name, description } = parsed.data;
// Attempt to derive a favicon URL from the domain
let faviconUrl: string | undefined;
try {
const parsedUrl = new URL(url);
faviconUrl = `${parsedUrl.origin}/favicon.ico`;
} catch {
// URL parsing failed — skip icon detection
}
try {
const app = await appService.create({
name,
url,
description,
icon: faviconUrl,
iconType: faviconUrl ? 'url' : 'lucide',
healthcheckEnabled: true,
healthcheckInterval: 300,
healthcheckMethod: 'GET',
healthcheckExpectedStatus: 200,
healthcheckTimeout: 5000,
createdById: user.id
});
return json(success(app), { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create app';
return json(error(message), { status: 500 });
}
};
+11
View File
@@ -3,10 +3,21 @@
import type { PageData } from './$types.js';
import AppCard from '$lib/components/app/AppCard.svelte';
import AppForm from '$lib/components/app/AppForm.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
let { data }: { data: PageData } = $props();
let showForm = $state(false);
// Track app count to detect CRUD changes and broadcast to other tabs
let previousAppCount = $state(data.apps.length);
$effect(() => {
const currentCount = data.apps.length;
if (currentCount !== previousAppCount) {
broadcastDataChange('app');
previousAppCount = currentCount;
}
});
</script>
<svelte:head>
+64
View File
@@ -0,0 +1,64 @@
import type { Actions, PageServerLoad } from './$types.js';
import { superValidate, setError } from 'sveltekit-superforms';
import { zod } from '$lib/utils/zod-adapter.js';
import { fail } from '@sveltejs/kit';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as appService from '$lib/server/services/appService.js';
import { createAppSchema } from '$lib/utils/validators.js';
export const load: PageServerLoad = async (event) => {
requireAuth(event);
const url = event.url.searchParams.get('url') ?? '';
const name = event.url.searchParams.get('name') ?? '';
const form = await superValidate(zod(createAppSchema));
// Pre-fill from query params
if (url) form.data.url = url;
if (name) form.data.name = name;
// Set quick-add defaults
form.data.healthcheckEnabled = true;
form.data.healthcheckInterval = 300;
form.data.healthcheckMethod = 'GET';
form.data.healthcheckExpectedStatus = 200;
form.data.healthcheckTimeout = 5000;
// Attempt to auto-detect favicon
if (url) {
try {
const parsedUrl = new URL(url);
form.data.icon = `${parsedUrl.origin}/favicon.ico`;
form.data.iconType = 'url';
} catch {
// Invalid URL — skip icon detection
}
}
return { form };
};
export const actions: Actions = {
create: async (event) => {
const user = requireAuth(event);
const form = await superValidate(event.request, zod(createAppSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await appService.create({
...form.data,
createdById: user.id
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create app';
return setError(form, '', message);
}
return { form, created: true };
}
};
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import AppForm from '$lib/components/app/AppForm.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
import { page } from '$app/stores';
let { data }: { data: PageData } = $props();
// If successfully created, broadcast the change and offer to close
let created = $derived('created' in ($page.form ?? {}) && $page.form?.created === true);
$effect(() => {
if (created) {
broadcastDataChange('app');
}
});
function closeWindow() {
window.close();
}
</script>
<svelte:head>
<title>{$t('app.quick_add_title')} | {$t('app_name')}</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="mb-2 text-2xl font-bold text-foreground">{$t('app.quick_add_title')}</h1>
<p class="mb-6 text-sm text-muted-foreground">{$t('app.quick_add_description')}</p>
{#if created}
<div class="mb-6 rounded-lg border border-green-500/30 bg-green-500/10 p-4">
<p class="text-sm font-medium text-green-700 dark:text-green-300">
{$t('app.quick_add_success')}
</p>
<div class="mt-3 flex gap-3">
<a
href="/apps"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$t('app.quick_add_view_apps')}
</a>
<button
type="button"
onclick={closeWindow}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent"
>
{$t('app.quick_add_close')}
</button>
</div>
</div>
{:else}
<div class="rounded-xl border border-border bg-card p-6 shadow-sm">
<AppForm form={data.form} action="?/create" />
</div>
{/if}
</div>
+2
View File
@@ -5,6 +5,7 @@
import Board from '$lib/components/board/Board.svelte';
import BoardHeader from '$lib/components/board/BoardHeader.svelte';
import BoardShareDialog from '$lib/components/board/BoardShareDialog.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
let { data }: { data: PageData } = $props();
@@ -20,6 +21,7 @@
body: JSON.stringify({ isGuestAccessible: value })
});
if (res.ok) {
broadcastDataChange('board');
await invalidateAll();
} else {
guestToggleError = 'Failed to update guest access';
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { WifiOff, RefreshCw } from 'lucide-svelte';
function retry() {
window.location.reload();
}
</script>
<svelte:head>
<title>{$t('offline.title')}</title>
</svelte:head>
<div class="flex min-h-[60vh] flex-col items-center justify-center gap-6 px-4 text-center">
<div
class="flex h-20 w-20 items-center justify-center rounded-full bg-muted"
>
<WifiOff class="h-10 w-10 text-muted-foreground" />
</div>
<div class="space-y-2">
<h1 class="text-2xl font-bold text-foreground">
{$t('offline.title')}
</h1>
<p class="max-w-md text-muted-foreground">
{$t('offline.description')}
</p>
</div>
<button
type="button"
onclick={retry}
class="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<RefreshCw class="h-4 w-4" />
{$t('offline.retry')}
</button>
</div>
+4 -1
View File
@@ -2,6 +2,7 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import ThemeCustomizer from '$lib/components/settings/ThemeCustomizer.svelte';
import BookmarkletGenerator from '$lib/components/settings/BookmarkletGenerator.svelte';
let { data }: { data: PageData } = $props();
</script>
@@ -10,8 +11,10 @@
<title>{$t('settings.title')} | {$t('app_name')}</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<div class="mx-auto max-w-2xl space-y-6 px-4 py-8">
<h1 class="mb-6 text-2xl font-bold text-foreground">{$t('settings.title')}</h1>
<ThemeCustomizer preferences={data.preferences} />
<BookmarkletGenerator />
</div>
+131
View File
@@ -0,0 +1,131 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
import { build, files, version } from '$service-worker';
const CACHE_NAME = `cache-${version}`;
const ASSETS = [...build, ...files];
const OFFLINE_URL = '/offline';
// Install: pre-cache all static assets and the offline fallback page
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(ASSETS);
// Cache offline fallback page
await cache.add(OFFLINE_URL);
await self.skipWaiting();
})()
);
});
// Activate: clean up old caches
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
const deletions = keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key));
await Promise.all(deletions);
await self.clients.claim();
})()
);
});
// Fetch: cache-first for static assets, network-first for API/pages
self.addEventListener('fetch', (event: FetchEvent) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') return;
// Skip cross-origin requests
if (url.origin !== self.location.origin) return;
// API calls: network-first with cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets (build artifacts + static files): cache-first
if (ASSETS.includes(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
// Navigation requests (HTML pages): network-first with offline fallback
if (request.mode === 'navigate') {
event.respondWith(navigationHandler(request));
return;
}
// Everything else: network-first
event.respondWith(networkFirst(request));
});
/**
* Cache-first strategy: serve from cache, fall back to network.
*/
async function cacheFirst(request: Request): Promise<Response> {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
/**
* Network-first strategy: try network, fall back to cache.
*/
async function networkFirst(request: Request): Promise<Response> {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
/**
* Navigation handler: network-first with offline fallback page.
*/
async function navigationHandler(request: Request): Promise<Response> {
try {
return await fetch(request);
} catch {
const cached = await caches.match(request);
if (cached) return cached;
const offlinePage = await caches.match(OFFLINE_URL);
if (offlinePage) return offlinePage;
return new Response('Offline', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/html' }
});
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#6366f1"/>
<rect x="64" y="64" width="160" height="160" rx="16" fill="white"/>
<rect x="288" y="64" width="160" height="160" rx="16" fill="white"/>
<rect x="64" y="288" width="160" height="160" rx="16" fill="white"/>
<rect x="288" y="288" width="160" height="160" rx="16" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 412 B

+25
View File
@@ -0,0 +1,25 @@
{
"name": "Web App Launcher",
"short_name": "Launcher",
"start_url": "/",
"display": "standalone",
"theme_color": "#6366f1",
"background_color": "#0a0a0a",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}