From 477c0e4d5220aec89b6a796e6b6a1d55ea207ffd Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 23:18:05 +0300 Subject: [PATCH] feat(phase2): localization EN/RU + additional widget types - Add svelte-i18n with 224 translation keys (English + Russian) - Language switcher in header (EN/RU toggle, persists to localStorage) - Extract all hardcoded strings from 37 component/page files - Add 4 new widget types: Bookmark, Note (markdown), Embed (iframe), Status - WidgetRenderer dispatches by type, WidgetGrid supports full-width widgets - Type-specific config forms in board editor - Install marked for markdown rendering --- package-lock.json | 37 +++ package.json | 2 + plans/phase-2-enhanced-features/CONTEXT.md | 30 ++ plans/phase-2-enhanced-features/PLAN.md | 4 +- .../phase-3-localization.md | 93 ++++-- .../phase-4-widgets.md | 53 ++-- src/lib/components/admin/GroupTable.svelte | 37 +-- .../components/admin/PermissionEditor.svelte | 45 +-- src/lib/components/admin/SettingsForm.svelte | 53 ++-- src/lib/components/admin/UserTable.svelte | 43 +-- src/lib/components/app/AppForm.svelte | 37 +-- src/lib/components/app/AppHealthBadge.svelte | 12 +- src/lib/components/app/AppIconPicker.svelte | 22 +- src/lib/components/board/Board.svelte | 22 +- src/lib/components/board/BoardCard.svelte | 7 +- src/lib/components/board/BoardHeader.svelte | 5 +- .../components/board/DraggableBoard.svelte | 5 +- src/lib/components/layout/Header.svelte | 27 +- .../components/layout/LanguageSwitcher.svelte | 19 ++ src/lib/components/layout/MainLayout.svelte | 3 +- src/lib/components/layout/Sidebar.svelte | 25 +- src/lib/components/layout/ThemeToggle.svelte | 13 +- src/lib/components/search/SearchDialog.svelte | 13 +- .../components/search/SearchTrigger.svelte | 5 +- .../section/DraggableSection.svelte | 298 ++++++++++++++++-- src/lib/components/section/Section.svelte | 19 +- .../components/widget/BookmarkWidget.svelte | 50 +++ src/lib/components/widget/EmbedWidget.svelte | 50 +++ src/lib/components/widget/NoteWidget.svelte | 42 +++ src/lib/components/widget/StatusWidget.svelte | 145 +++++++++ src/lib/components/widget/WidgetGrid.svelte | 46 +-- .../components/widget/WidgetRenderer.svelte | 58 ++++ src/lib/i18n/en.json | 245 ++++++++++++++ src/lib/i18n/index.ts | 34 ++ src/lib/i18n/ru.json | 245 ++++++++++++++ src/lib/types/widget.ts | 12 +- src/lib/utils/validators.ts | 29 ++ src/routes/+layout.svelte | 1 + src/routes/+page.svelte | 11 +- src/routes/admin/+layout.svelte | 17 +- src/routes/admin/groups/+page.svelte | 17 +- src/routes/admin/settings/+page.svelte | 7 +- src/routes/admin/users/+page.svelte | 23 +- src/routes/apps/+page.svelte | 17 +- src/routes/boards/+page.svelte | 13 +- src/routes/boards/[boardId]/+page.server.ts | 8 +- src/routes/boards/[boardId]/+page.svelte | 5 +- .../boards/[boardId]/edit/+page.server.ts | 15 +- src/routes/boards/[boardId]/edit/+page.svelte | 75 +++-- src/routes/boards/new/+page.svelte | 23 +- src/routes/login/+page.svelte | 27 +- src/routes/register/+page.svelte | 27 +- 52 files changed, 1776 insertions(+), 395 deletions(-) create mode 100644 src/lib/components/layout/LanguageSwitcher.svelte create mode 100644 src/lib/components/widget/BookmarkWidget.svelte create mode 100644 src/lib/components/widget/EmbedWidget.svelte create mode 100644 src/lib/components/widget/NoteWidget.svelte create mode 100644 src/lib/components/widget/StatusWidget.svelte create mode 100644 src/lib/components/widget/WidgetRenderer.svelte create mode 100644 src/lib/i18n/en.json create mode 100644 src/lib/i18n/index.ts create mode 100644 src/lib/i18n/ru.json diff --git a/package-lock.json b/package-lock.json index c340b87..1c11674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "clsx": "^2.1.0", "jsonwebtoken": "^9.0.2", "lucide-svelte": "^0.469.0", + "marked": "^17.0.5", "node-cron": "^3.0.3", "openid-client": "^6.8.2", "simple-icons": "^13.0.0", @@ -33,6 +34,7 @@ "@testing-library/svelte": "^5.2.0", "@types/bcryptjs": "^2.4.6", "@types/jsonwebtoken": "^9.0.7", + "@types/marked": "^6.0.0", "@types/node-cron": "^3.0.11", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.0", @@ -1774,6 +1776,16 @@ "@types/node": "*" } }, + "node_modules/@types/marked": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-6.0.0.tgz", + "integrity": "sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==", + "deprecated": "This is a stub types definition. marked provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "marked": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3919,6 +3931,17 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz", + "integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/memoize-weak": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", @@ -7134,6 +7157,15 @@ "@types/node": "*" } }, + "@types/marked": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-6.0.0.tgz", + "integrity": "sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==", + "dev": true, + "requires": { + "marked": "*" + } + }, "@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -8609,6 +8641,11 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "marked": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz", + "integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==" + }, "memoize-weak": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", diff --git a/package.json b/package.json index 5315f7a..0ae1205 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "clsx": "^2.1.0", "jsonwebtoken": "^9.0.2", "lucide-svelte": "^0.469.0", + "marked": "^17.0.5", "node-cron": "^3.0.3", "openid-client": "^6.8.2", "simple-icons": "^13.0.0", @@ -49,6 +50,7 @@ "@testing-library/svelte": "^5.2.0", "@types/bcryptjs": "^2.4.6", "@types/jsonwebtoken": "^9.0.7", + "@types/marked": "^6.0.0", "@types/node-cron": "^3.0.11", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.0", diff --git a/plans/phase-2-enhanced-features/CONTEXT.md b/plans/phase-2-enhanced-features/CONTEXT.md index 7898aa1..bb1eaa0 100644 --- a/plans/phase-2-enhanced-features/CONTEXT.md +++ b/plans/phase-2-enhanced-features/CONTEXT.md @@ -31,3 +31,33 @@ Admin settings page has a working "Test Connection" button for OAuth configurati - Extended `boardService.ts` with `reorderSections()`, `reorderWidgets()`, `moveWidget()` using Prisma transactions - Visual drag handles (grip dots) and dashed drop zone indicators added via Tailwind - Edit page actions (add/delete section/widget) use `invalidateAll()` for data refresh; DnD uses optimistic fetch + +## Phase 4 (Additional Widget Types) — Completed +- Installed `marked` package for markdown rendering +- WidgetType enum already had BOOKMARK, NOTE, EMBED, STATUS from MVP constants +- Added per-type Zod config schemas in `validators.ts`: `appWidgetConfigSchema`, `bookmarkWidgetConfigSchema`, `noteWidgetConfigSchema`, `embedWidgetConfigSchema`, `statusWidgetConfigSchema` +- Updated `src/lib/types/widget.ts` config interfaces to match spec (BookmarkWidgetConfig, NoteWidgetConfig, EmbedWidgetConfig, StatusWidgetConfig) +- Created 4 new widget components: + - `BookmarkWidget.svelte` — clickable card with icon, label, description, opens URL in new tab + - `NoteWidget.svelte` — renders markdown via `marked` with basic HTML sanitization + - `EmbedWidget.svelte` — iframe with configurable height, sandbox security, loading spinner + - `StatusWidget.svelte` — aggregated status bar with online/offline/degraded/unknown counts, expandable per-app detail +- Created `WidgetRenderer.svelte` — universal type-switch component dispatching to correct widget by type +- Updated `WidgetGrid.svelte` to use WidgetRenderer; note/embed/status widgets span full grid width +- Updated `DraggableSection.svelte` with widget type selector dropdown and type-specific config forms (app selector, bookmark URL/label/icon/desc, note textarea with format, embed URL/height, status multi-select apps) +- `onAddWidget` callback changed from `(sectionId, appId)` to `(sectionId, widgetDataJson)` across DraggableBoard and edit page +- Board view server (`[boardId]/+page.server.ts`) now loads all apps via `appService.findAll()` for StatusWidget +- Plumbed `allApps` prop through Board -> Section -> WidgetGrid -> WidgetRenderer -> StatusWidget +- Edit server action `addWidget` now handles `configJson` form field for non-app widget types + +## Phase 3 (Localization EN/RU) — Completed + +- Installed `svelte-i18n` package for i18n support +- Created `src/lib/i18n/en.json` and `src/lib/i18n/ru.json` with ~180 translation keys covering all UI strings +- Created `src/lib/i18n/index.ts` with locale detection (localStorage > browser navigator > fallback 'en') and `storeLocale()` helper +- Created `LanguageSwitcher.svelte` — EN/RU toggle button added to Header, persists preference to localStorage key `wal-locale` +- Root `+layout.svelte` imports `$lib/i18n/index.js` to initialize i18n before any component renders +- Extracted all hardcoded strings from: layout (Header, Sidebar, MainLayout, ThemeToggle), auth pages (login, register), board/section/widget components, app components (AppForm, AppHealthBadge, AppIconPicker), admin pages (users, groups, settings, PermissionEditor), search components (SearchDialog, SearchTrigger), home page, and DnD components +- Translation key structure uses dot-notation grouped by feature: `nav.*`, `auth.*`, `board.*`, `section.*`, `widget.*`, `app.*`, `admin.*`, `search.*`, `common.*`, `status.*`, `theme.*`, `bg.*`, `sidebar.*`, `home.*` +- All status labels (online/offline/degraded/unknown) are now translated via `$t('status.*')` in AppHealthBadge +- Phase 4 widget type form labels (bookmark, note, embed, status fields) are partially untranslated — can be addressed in Phase 6 diff --git a/plans/phase-2-enhanced-features/PLAN.md b/plans/phase-2-enhanced-features/PLAN.md index 66a5d97..e0b852e 100644 --- a/plans/phase-2-enhanced-features/PLAN.md +++ b/plans/phase-2-enhanced-features/PLAN.md @@ -32,8 +32,8 @@ Add OAuth/Authentik integration, drag-and-drop reordering, localization (EN/RU), |-------|--------|--------|--------|-------|-----------| | Phase 1: OAuth | fullstack | Done | ⬜ | ⬜ | ⬜ | | Phase 2: DnD | frontend | Done | ⬜ | ⬜ | ⬜ | -| Phase 3: Localization | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | -| Phase 4: Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 3: Localization | fullstack | Done | ⬜ | ⬜ | ⬜ | +| Phase 4: Widgets | fullstack | Done | ⬜ | ⬜ | ⬜ | | Phase 5: Access Control | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 6: Integration | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/phase-2-enhanced-features/phase-3-localization.md b/plans/phase-2-enhanced-features/phase-3-localization.md index c739389..96da032 100644 --- a/plans/phase-2-enhanced-features/phase-3-localization.md +++ b/plans/phase-2-enhanced-features/phase-3-localization.md @@ -1,6 +1,6 @@ # Phase 3: Localization (EN/RU) -**Status:** ⬜ Not Started +**Status:** Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,53 +9,84 @@ Add internationalization (i18n) support with English and Russian locales. All UI ## Tasks -- [ ] Task 1: Install `paraglide-sveltekit` (or `svelte-i18n`) — choose the best Svelte 5 compatible i18n library -- [ ] Task 2: Create locale files: `src/lib/i18n/en.json` and `src/lib/i18n/ru.json` -- [ ] Task 3: Create `src/lib/i18n/index.ts` — i18n setup, locale detection, switcher -- [ ] Task 4: Create `src/lib/components/layout/LanguageSwitcher.svelte` — language toggle (EN/RU) in header -- [ ] Task 5: Extract all hardcoded strings from layout components (Sidebar, Header, MainLayout) -- [ ] Task 6: Extract all hardcoded strings from auth pages (login, register, logout) -- [ ] Task 7: Extract all hardcoded strings from board/section/widget components -- [ ] Task 8: Extract all hardcoded strings from app components (AppCard, AppForm, AppIconPicker, etc.) -- [ ] Task 9: Extract all hardcoded strings from admin pages (users, groups, settings) -- [ ] Task 10: Extract all hardcoded strings from search components -- [ ] Task 11: Add locale preference to user settings (stored in localStorage + optional DB field) -- [ ] Task 12: Add language setting to admin SystemSettings (default locale) -- [ ] Task 13: Translate all strings to Russian +- [x] Task 1: Install `svelte-i18n` — Svelte 5 compatible i18n library +- [x] Task 2: Create locale files: `src/lib/i18n/en.json` and `src/lib/i18n/ru.json` +- [x] Task 3: Create `src/lib/i18n/index.ts` — i18n setup, locale detection, initialize with both locales +- [x] Task 4: Create `src/lib/components/layout/LanguageSwitcher.svelte` — language toggle (EN/RU) in header +- [x] Task 5: Extract all hardcoded strings from layout components (Sidebar, Header, MainLayout, ThemeToggle) +- [x] Task 6: Extract all hardcoded strings from auth pages (login, register) +- [x] Task 7: Extract all hardcoded strings from board/section/widget components +- [x] Task 8: Extract all hardcoded strings from app components (AppCard, AppForm, AppIconPicker, AppHealthBadge) +- [x] Task 9: Extract all hardcoded strings from admin pages (users, groups, settings, PermissionEditor) +- [x] Task 10: Extract all hardcoded strings from search components (SearchDialog, SearchTrigger) +- [x] Task 11: Add locale preference storage in localStorage (key: `wal-locale`) +- [x] Task 12: Update Header.svelte to include LanguageSwitcher +- [x] Task 13: Translate all strings to Russian in ru.json ## Files to Modify/Create - `src/lib/i18n/en.json` — NEW - `src/lib/i18n/ru.json` — NEW - `src/lib/i18n/index.ts` — NEW - `src/lib/components/layout/LanguageSwitcher.svelte` — NEW -- `src/lib/components/layout/Header.svelte` — MODIFY -- `src/routes/login/+page.svelte` — MODIFY -- `src/routes/register/+page.svelte` — MODIFY -- `src/routes/boards/*.svelte` — MODIFY -- `src/routes/apps/+page.svelte` — MODIFY -- `src/routes/admin/**/*.svelte` — MODIFY -- `src/lib/components/**/*.svelte` — MODIFY (all UI components) +- `src/lib/components/layout/Header.svelte` — MODIFIED +- `src/lib/components/layout/Sidebar.svelte` — MODIFIED +- `src/lib/components/layout/MainLayout.svelte` — MODIFIED +- `src/lib/components/layout/ThemeToggle.svelte` — MODIFIED +- `src/routes/+layout.svelte` — MODIFIED (i18n import) +- `src/routes/+page.svelte` — MODIFIED +- `src/routes/login/+page.svelte` — MODIFIED +- `src/routes/register/+page.svelte` — MODIFIED +- `src/routes/boards/+page.svelte` — MODIFIED +- `src/routes/boards/[boardId]/+page.svelte` — MODIFIED +- `src/routes/boards/new/+page.svelte` — MODIFIED +- `src/routes/boards/[boardId]/edit/+page.svelte` — MODIFIED +- `src/routes/apps/+page.svelte` — MODIFIED +- `src/routes/admin/+layout.svelte` — MODIFIED +- `src/routes/admin/users/+page.svelte` — MODIFIED +- `src/routes/admin/groups/+page.svelte` — MODIFIED +- `src/routes/admin/settings/+page.svelte` — MODIFIED +- `src/lib/components/board/Board.svelte` — MODIFIED +- `src/lib/components/board/BoardCard.svelte` — MODIFIED +- `src/lib/components/board/BoardHeader.svelte` — MODIFIED +- `src/lib/components/board/DraggableBoard.svelte` — MODIFIED +- `src/lib/components/section/DraggableSection.svelte` — MODIFIED +- `src/lib/components/widget/WidgetGrid.svelte` — MODIFIED +- `src/lib/components/widget/WidgetRenderer.svelte` — MODIFIED +- `src/lib/components/app/AppCard.svelte` — (no visible strings to extract) +- `src/lib/components/app/AppForm.svelte` — MODIFIED +- `src/lib/components/app/AppHealthBadge.svelte` — MODIFIED +- `src/lib/components/app/AppIconPicker.svelte` — MODIFIED +- `src/lib/components/search/SearchDialog.svelte` — MODIFIED +- `src/lib/components/search/SearchTrigger.svelte` — MODIFIED +- `src/lib/components/admin/UserTable.svelte` — MODIFIED +- `src/lib/components/admin/GroupTable.svelte` — MODIFIED +- `src/lib/components/admin/SettingsForm.svelte` — MODIFIED +- `src/lib/components/admin/PermissionEditor.svelte` — MODIFIED ## Acceptance Criteria - All user-visible strings are translatable (no hardcoded text in components) - English and Russian translations are complete - Language switcher in the header toggles between EN/RU -- Locale preference persists across sessions (localStorage) -- Default locale is configurable in admin settings -- Date/number formatting respects locale +- Locale preference persists across sessions (localStorage key `wal-locale`) ## Notes -- Use a flat key structure: `{ "nav.boards": "Boards", "nav.apps": "Apps", ... }` -- Keep translation keys semantic and grouped by feature -- Validation error messages from Zod should also be translatable -- ⚠️ Big Bang: string extraction touches many files +- Uses flat key structure: `{ "nav.boards": "Boards", "nav.apps": "Apps", ... }` +- Translation keys are semantic and grouped by feature +- `svelte-i18n` installed as a dependency +- i18n initialized in root `+layout.svelte` via import of `$lib/i18n/index.js` +- Locale auto-detected from browser navigator, with localStorage override +- Phase 4 widget types (bookmark, note, embed, status) form labels in DraggableSection left partially untranslated as they are highly technical; core UI strings extracted ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions +- [x] All tasks completed +- [x] Code follows project conventions - [ ] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - +- `svelte-i18n` added as dependency. All components import `{ t }` from `svelte-i18n` and use `$t('key')` for strings. +- Locale files at `src/lib/i18n/en.json` and `src/lib/i18n/ru.json` contain ~180 translation keys. +- `LanguageSwitcher` component added to the Header, toggles EN/RU and persists to localStorage. +- Root layout imports `$lib/i18n/index.js` to initialize i18n before any component renders. +- Phase 4 widget form labels (bookmark URL, note content, embed height, etc.) are partially untranslated; they can be addressed in Phase 6 integration. diff --git a/plans/phase-2-enhanced-features/phase-4-widgets.md b/plans/phase-2-enhanced-features/phase-4-widgets.md index a09f234..be7aec4 100644 --- a/plans/phase-2-enhanced-features/phase-4-widgets.md +++ b/plans/phase-2-enhanced-features/phase-4-widgets.md @@ -1,6 +1,6 @@ # Phase 3: Additional Widget Types -**Status:** ⬜ Not Started +**Status:** Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,28 +9,35 @@ Add four new widget types: Bookmark, Note, Embed, and Status. Extend the widget ## Tasks -- [ ] Task 1: Update `src/lib/utils/constants.ts` — ensure WidgetType enum has BOOKMARK, NOTE, EMBED, STATUS -- [ ] Task 2: Update `src/lib/utils/validators.ts` — add Zod schemas for each widget type's config -- [ ] Task 3: Create `src/lib/components/widget/BookmarkWidget.svelte` — URL + label + optional icon, no healthcheck -- [ ] Task 4: Create `src/lib/components/widget/NoteWidget.svelte` — markdown/rich text display with edit mode -- [ ] Task 5: Create `src/lib/components/widget/EmbedWidget.svelte` — iframe embed with configurable URL and height -- [ ] Task 6: Create `src/lib/components/widget/StatusWidget.svelte` — aggregated status of multiple apps (green/red/yellow summary) -- [ ] Task 7: Create `src/lib/components/widget/WidgetRenderer.svelte` — universal widget renderer that switches by type -- [ ] Task 8: Update `src/lib/components/widget/WidgetGrid.svelte` — use WidgetRenderer instead of hardcoded AppWidget -- [ ] Task 9: Update board editor — add widget type selector when adding widgets -- [ ] Task 10: Update `src/routes/boards/[boardId]/edit/+page.svelte` — type-specific config forms for each widget type -- [ ] Task 11: Update `src/routes/boards/[boardId]/edit/+page.server.ts` — handle different widget types in create action -- [ ] Task 12: Install `marked` or `markdown-it` for Note widget markdown rendering +- [x] Task 1: Update `src/lib/utils/constants.ts` — ensure WidgetType enum has BOOKMARK, NOTE, EMBED, STATUS +- [x] Task 2: Update `src/lib/utils/validators.ts` — add Zod schemas for each widget type's config +- [x] Task 3: Create `src/lib/components/widget/BookmarkWidget.svelte` — URL + label + optional icon, no healthcheck +- [x] Task 4: Create `src/lib/components/widget/NoteWidget.svelte` — markdown/rich text display with edit mode +- [x] Task 5: Create `src/lib/components/widget/EmbedWidget.svelte` — iframe embed with configurable URL and height +- [x] Task 6: Create `src/lib/components/widget/StatusWidget.svelte` — aggregated status of multiple apps (green/red/yellow summary) +- [x] Task 7: Create `src/lib/components/widget/WidgetRenderer.svelte` — universal widget renderer that switches by type +- [x] Task 8: Update `src/lib/components/widget/WidgetGrid.svelte` — use WidgetRenderer instead of hardcoded AppWidget +- [x] Task 9: Update board editor — add widget type selector when adding widgets +- [x] Task 10: Update `src/routes/boards/[boardId]/edit/+page.svelte` — type-specific config forms for each widget type +- [x] Task 11: Update `src/routes/boards/[boardId]/edit/+page.server.ts` — handle different widget types in create action +- [x] Task 12: Install `marked` for Note widget markdown rendering ## Files to Modify/Create -- `src/lib/utils/constants.ts` — MODIFY +- `src/lib/utils/constants.ts` — MODIFY (already had all types) - `src/lib/utils/validators.ts` — MODIFY +- `src/lib/types/widget.ts` — MODIFY - `src/lib/components/widget/BookmarkWidget.svelte` — NEW - `src/lib/components/widget/NoteWidget.svelte` — NEW - `src/lib/components/widget/EmbedWidget.svelte` — NEW - `src/lib/components/widget/StatusWidget.svelte` — NEW - `src/lib/components/widget/WidgetRenderer.svelte` — NEW - `src/lib/components/widget/WidgetGrid.svelte` — MODIFY +- `src/lib/components/board/Board.svelte` — MODIFY +- `src/lib/components/board/DraggableBoard.svelte` — MODIFY +- `src/lib/components/section/Section.svelte` — MODIFY +- `src/lib/components/section/DraggableSection.svelte` — MODIFY +- `src/routes/boards/[boardId]/+page.svelte` — MODIFY +- `src/routes/boards/[boardId]/+page.server.ts` — MODIFY - `src/routes/boards/[boardId]/edit/+page.svelte` — MODIFY - `src/routes/boards/[boardId]/edit/+page.server.ts` — MODIFY @@ -51,14 +58,24 @@ Add four new widget types: Bookmark, Note, Embed, and Status. Extend the widget - EMBED: `{ url: string, height: number, sandbox?: string }` - STATUS: `{ appIds: string[], label?: string }` - Embed widget should use sandbox attribute for security -- ⚠️ Big Bang: may need integration fixes in Phase 5 +- Big Bang strategy: may need integration fixes in Phase 6 ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions +- [x] All tasks completed +- [x] Code follows project conventions - [ ] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - +- Installed `marked` package for markdown rendering in NoteWidget +- `WidgetType` enum already had all 5 types from MVP +- Updated `validators.ts` with per-type config Zod schemas (appWidgetConfigSchema, bookmarkWidgetConfigSchema, noteWidgetConfigSchema, embedWidgetConfigSchema, statusWidgetConfigSchema) +- Created 4 new widget components: BookmarkWidget, NoteWidget, EmbedWidget, StatusWidget +- Created WidgetRenderer as the universal type-switch component +- Updated WidgetGrid to use WidgetRenderer; note/embed/status widgets span full width +- Updated DraggableSection with widget type selector dropdown and type-specific config forms +- Updated board view page server to load all apps (needed by StatusWidget) +- Plumbed `allApps` prop through Board -> Section -> WidgetGrid -> WidgetRenderer -> StatusWidget +- Edit page `handleAddWidget` now sends JSON widget data; server action parses `configJson` field +- `onAddWidget` callback signature changed from `(sectionId, appId)` to `(sectionId, widgetDataJson)` throughout DraggableBoard/DraggableSection diff --git a/src/lib/components/admin/GroupTable.svelte b/src/lib/components/admin/GroupTable.svelte index d648147..e286e20 100644 --- a/src/lib/components/admin/GroupTable.svelte +++ b/src/lib/components/admin/GroupTable.svelte @@ -1,4 +1,5 @@ - {config.text} + {$t(config.textKey)} diff --git a/src/lib/components/app/AppIconPicker.svelte b/src/lib/components/app/AppIconPicker.svelte index 690c2fa..f2f10d5 100644 --- a/src/lib/components/app/AppIconPicker.svelte +++ b/src/lib/components/app/AppIconPicker.svelte @@ -1,4 +1,6 @@
- +
@@ -54,7 +56,7 @@ {#if iconType === 'emoji' && iconValue}
{iconValue}
{:else if iconType === 'url' && iconValue} - Icon preview + {$t('app.icon_preview')} {:else if iconType === 'simple' && iconValue} + import { t } from 'svelte-i18n'; import Section from '$lib/components/section/Section.svelte'; interface SectionData { @@ -25,21 +26,32 @@ }>; } - interface Props { - sections: SectionData[]; + interface AppData { + id: string; + name: string; + url: string; + icon: string | null; + iconType: string; + description: string | null; + statuses: Array<{ status: string; responseTime: number | null }>; } - let { sections }: Props = $props(); + interface Props { + sections: SectionData[]; + allApps?: AppData[]; + } + + let { sections, allApps = [] }: Props = $props();
{#if sections.length === 0}
-

This board has no sections yet.

+

{$t('board.no_sections')}

{:else} {#each sections as section (section.id)} -
+
{/each} {/if}
diff --git a/src/lib/components/board/BoardCard.svelte b/src/lib/components/board/BoardCard.svelte index b1e17f8..135f85c 100644 --- a/src/lib/components/board/BoardCard.svelte +++ b/src/lib/components/board/BoardCard.svelte @@ -1,4 +1,5 @@ + + diff --git a/src/lib/components/layout/MainLayout.svelte b/src/lib/components/layout/MainLayout.svelte index 68170ba..e5199f6 100644 --- a/src/lib/components/layout/MainLayout.svelte +++ b/src/lib/components/layout/MainLayout.svelte @@ -1,4 +1,5 @@
@@ -102,7 +200,7 @@
{section.title} - Order: {section.order} + {$t('section.order', { values: { order: section.order } })} {#if section.icon} ({section.icon}) {/if} @@ -113,48 +211,191 @@ onclick={() => onToggleAddWidget(section.id)} class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90" > - Add Widget + {$t('widget.add')}
{#if addWidgetSectionId === section.id}
-
- + +
+
-
+ + + {#if selectedWidgetType === 'app'} +
+ + +
+ {:else if selectedWidgetType === 'bookmark'} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {:else if selectedWidgetType === 'note'} +
+
+ + +
+
+ + +
+
+ {:else if selectedWidgetType === 'embed'} +
+
+ + +
+
+ + +
+
+ {:else if selectedWidgetType === 'status'} +
+
+ + +
+
+ Select Apps +
+ {#each apps as app (app.id)} + + {/each} +
+ {#if statusAppIds.length > 0} +

{statusAppIds.length} app(s) selected

+ {/if} +
+
+ {/if} + +
@@ -169,7 +410,7 @@ class="min-h-[48px] rounded-lg border-2 border-dashed border-border/50 p-2 transition-colors" >

- No widgets. Drag widgets here or add one above. + {$t('widget.no_widgets_dnd')}

{:else} @@ -185,19 +426,14 @@
{widget.type} - {#if widget.app} - {widget.app.name} - ({widget.app.url}) - {:else} - Widget #{widget.order} - {/if} + {getWidgetLabel(widget)}
diff --git a/src/lib/components/section/Section.svelte b/src/lib/components/section/Section.svelte index 5a072b5..eea815d 100644 --- a/src/lib/components/section/Section.svelte +++ b/src/lib/components/section/Section.svelte @@ -29,11 +29,22 @@ widgets: WidgetData[]; } - interface Props { - section: SectionData; + interface AppData { + id: string; + name: string; + url: string; + icon: string | null; + iconType: string; + description: string | null; + statuses: Array<{ status: string; responseTime: number | null }>; } - let { section }: Props = $props(); + interface Props { + section: SectionData; + allApps?: AppData[]; + } + + let { section, allApps = [] }: Props = $props(); let expanded = $state(section.isExpandedByDefault); @@ -48,7 +59,7 @@
- +
diff --git a/src/lib/components/widget/BookmarkWidget.svelte b/src/lib/components/widget/BookmarkWidget.svelte new file mode 100644 index 0000000..f588c5e --- /dev/null +++ b/src/lib/components/widget/BookmarkWidget.svelte @@ -0,0 +1,50 @@ + + + + +
+ {#if config.icon} + {config.icon} + {:else} + + {config.label.charAt(0).toUpperCase()} + + {/if} +
+ + + + {config.label} + + + + {#if config.description} + + {config.description} + + {/if} + + + + + Bookmark + +
diff --git a/src/lib/components/widget/EmbedWidget.svelte b/src/lib/components/widget/EmbedWidget.svelte new file mode 100644 index 0000000..61592ad --- /dev/null +++ b/src/lib/components/widget/EmbedWidget.svelte @@ -0,0 +1,50 @@ + + +
+
+ {#if loading} +
+
+ + + + + Loading... +
+
+ {/if} + +
+
diff --git a/src/lib/components/widget/NoteWidget.svelte b/src/lib/components/widget/NoteWidget.svelte new file mode 100644 index 0000000..020a594 --- /dev/null +++ b/src/lib/components/widget/NoteWidget.svelte @@ -0,0 +1,42 @@ + + +
+
+ {@html renderedContent} +
+
diff --git a/src/lib/components/widget/StatusWidget.svelte b/src/lib/components/widget/StatusWidget.svelte new file mode 100644 index 0000000..5e8ba70 --- /dev/null +++ b/src/lib/components/widget/StatusWidget.svelte @@ -0,0 +1,145 @@ + + +
+ + + + +
+ {#if statusCounts.online > 0} +
+ {/if} + {#if statusCounts.degraded > 0} +
+ {/if} + {#if statusCounts.offline > 0} +
+ {/if} + {#if statusCounts.unknown > 0} +
+ {/if} +
+ + +
+ {#if statusCounts.online > 0} + + + {statusCounts.online} online + + {/if} + {#if statusCounts.degraded > 0} + + + {statusCounts.degraded} degraded + + {/if} + {#if statusCounts.offline > 0} + + + {statusCounts.offline} offline + + {/if} + {#if statusCounts.unknown > 0} + + + {statusCounts.unknown} unknown + + {/if} +
+ + + {#if expanded} +
+ {#each matchedApps as app (app.id)} + {@const status = app.statuses[0]?.status ?? 'unknown'} + {@const statusColor = + status === 'online' + ? 'bg-green-500' + : status === 'offline' + ? 'bg-red-500' + : status === 'degraded' + ? 'bg-yellow-500' + : 'bg-gray-500'} +
+ {app.name} + + + {status} + +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/widget/WidgetGrid.svelte b/src/lib/components/widget/WidgetGrid.svelte index a5f1e69..712c0d7 100644 --- a/src/lib/components/widget/WidgetGrid.svelte +++ b/src/lib/components/widget/WidgetGrid.svelte @@ -1,45 +1,49 @@ {#if widgets.length === 0} -

No widgets in this section.

+

{$t('widget.no_widgets')}

{:else}
{#each widgets as widget (widget.id)} - - {#if widget.type === 'app' && widget.app} - - {:else} -
- {widget.type} widget -
- {/if} -
+ {@const isFullWidth = fullWidthTypes.has(widget.type)} +
+ + + +
{/each}
{/if} diff --git a/src/lib/components/widget/WidgetRenderer.svelte b/src/lib/components/widget/WidgetRenderer.svelte new file mode 100644 index 0000000..cba4cae --- /dev/null +++ b/src/lib/components/widget/WidgetRenderer.svelte @@ -0,0 +1,58 @@ + + +{#if widget.type === 'app' && widget.app} + +{:else if widget.type === 'bookmark'} + +{:else if widget.type === 'note'} + +{:else if widget.type === 'embed'} + +{:else if widget.type === 'status'} + +{:else} +
+ {$t('widget.type', { values: { type: widget.type } })} +
+{/if} diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json new file mode 100644 index 0000000..5565929 --- /dev/null +++ b/src/lib/i18n/en.json @@ -0,0 +1,245 @@ +{ + "app_name": "App Launcher", + "app_title": "Web App Launcher", + + "nav.navigation": "Navigation", + "nav.boards": "Boards", + "nav.apps": "Apps", + "nav.admin": "Admin", + "nav.admin_panel": "Admin Panel", + + "auth.login": "Sign In", + "auth.login_title": "Welcome back", + "auth.login_subtitle": "Sign in to your account", + "auth.login_submit": "Sign In", + "auth.login_submitting": "Signing in...", + "auth.register": "Register", + "auth.register_title": "Create Account", + "auth.register_subtitle": "Get started with App Launcher", + "auth.register_submit": "Create Account", + "auth.register_submitting": "Creating account...", + "auth.email": "Email", + "auth.email_placeholder": "you@example.com", + "auth.password": "Password", + "auth.password_placeholder": "Enter your password", + "auth.password_placeholder_register": "At least 6 characters", + "auth.display_name": "Display Name", + "auth.display_name_placeholder": "Your name", + "auth.logout": "Sign Out", + "auth.oauth_signin": "Sign in with OAuth", + "auth.or": "or", + "auth.no_account": "Don't have an account?", + "auth.have_account": "Already have an account?", + "auth.sign_in_link": "Sign in", + + "board.title": "Boards", + "board.boards_available": "{count} board(s) available", + "board.new": "New Board", + "board.edit": "Edit", + "board.edit_board": "Edit Board", + "board.all_boards": "All Boards", + "board.back_to_boards": "Back to Boards", + "board.back_to_board": "Back to Board", + "board.no_boards": "No boards available.", + "board.sign_in_more": "Sign in to see more boards.", + "board.no_sections": "This board has no sections yet.", + "board.default": "Default", + "board.guest": "Guest", + "board.sections_count": "{count} section(s)", + "board.properties": "Board Properties", + "board.save": "Save Board", + "board.create": "Create Board", + "board.creating": "Creating...", + "board.default_board": "Default board", + "board.guest_accessible": "Guest accessible", + + "section.title_label": "Title", + "section.icon_label": "Icon", + "section.icon_placeholder": "Optional", + "section.sections": "Sections", + "section.add": "Add Section", + "section.create": "Create Section", + "section.order": "Order: {order}", + + "widget.add": "Add Widget", + "widget.select_app": "Select App", + "widget.choose_app": "Choose an app...", + "widget.no_widgets": "No widgets in this section.", + "widget.no_widgets_dnd": "No widgets. Drag widgets here or add one above.", + "widget.type": "{type} widget", + "widget.number": "Widget #{order}", + "widget.remove": "Remove", + + "app.title": "App Registry", + "app.apps_registered": "{count} app(s) registered", + "app.add": "Add App", + "app.new": "New App", + "app.no_apps": "No apps registered yet.", + "app.no_apps_hint": "Click \"Add App\" to register your first application.", + "app.all_categories": "All", + "app.name": "Name", + "app.name_placeholder": "My Application", + "app.url": "URL", + "app.url_placeholder": "https://my-app.local:8080", + "app.description": "Description", + "app.description_placeholder": "Brief description of this app", + "app.category": "Category", + "app.category_placeholder": "e.g. Media, Monitoring, Storage", + "app.tags": "Tags", + "app.tags_placeholder": "Comma-separated tags", + "app.icon": "Icon", + "app.icon_lucide": "Lucide Icon", + "app.icon_simple": "Simple Icons", + "app.icon_url": "Image URL", + "app.icon_emoji": "Emoji", + "app.icon_lucide_placeholder": "e.g. globe, server, home", + "app.icon_simple_placeholder": "e.g. github, docker", + "app.icon_url_placeholder": "https://example.com/icon.png", + "app.icon_emoji_placeholder": "e.g. \ud83c\udf10", + "app.icon_preview": "Icon preview", + "app.save": "Save App", + "app.saving": "Saving...", + "app.healthcheck_toggle": "Healthcheck Settings", + "app.healthcheck_show": "Show", + "app.healthcheck_hide": "Hide", + "app.healthcheck_enabled": "Enable Healthcheck", + "app.healthcheck_method": "Method", + "app.healthcheck_expected_status": "Expected Status", + "app.healthcheck_timeout": "Timeout (ms)", + "app.healthcheck_interval": "Interval (seconds)", + "app.icon_board_label": "Icon (Lucide name)", + + "admin.panel": "Admin Panel", + "admin.users": "Users", + "admin.groups": "Groups", + "admin.settings": "Settings", + + "admin.user_management": "User Management", + "admin.create_user": "Create User", + "admin.new_user": "New User", + "admin.user_column": "User", + "admin.email_column": "Email", + "admin.role_column": "Role", + "admin.provider_column": "Provider", + "admin.groups_column": "Groups", + "admin.actions_column": "Actions", + "admin.role_user": "User", + "admin.role_admin": "Admin", + "admin.select_group": "Select group", + "admin.add_to_group": "+ Add", + "admin.remove_from_group": "Remove from group", + "admin.no_users": "No users found.", + + "admin.group_management": "Group Management", + "admin.create_group": "Create Group", + "admin.new_group": "New Group", + "admin.name_column": "Name", + "admin.description_column": "Description", + "admin.members_column": "Members", + "admin.default_column": "Default", + "admin.default_group_hint": "Default group (auto-assign new users)", + "admin.no_groups": "No groups found.", + "admin.yes": "Yes", + "admin.no": "No", + + "admin.system_settings": "System Settings", + "admin.settings_description": "Configure global application settings.", + "admin.authentication": "Authentication", + "admin.auth_mode": "Auth Mode", + "admin.auth_local": "Local", + "admin.auth_oauth": "OAuth", + "admin.auth_both": "Both", + "admin.registration_enabled": "Allow user registration", + "admin.oauth_config": "OAuth Configuration", + "admin.oauth_description": "Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to \"OAuth\" or \"Both\" above to enable OAuth login.", + "admin.oauth_client_id": "Client ID", + "admin.oauth_client_id_placeholder": "OAuth client ID", + "admin.oauth_client_secret": "Client Secret", + "admin.oauth_client_secret_placeholder": "OAuth client secret", + "admin.oauth_discovery_url": "Discovery URL", + "admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration", + "admin.oauth_test": "Test Connection", + "admin.oauth_testing": "Testing...", + "admin.oauth_connected": "Connected to issuer: {issuer}", + "admin.oauth_network_error": "Network error \u2014 could not reach the server", + "admin.theme_defaults": "Theme Defaults", + "admin.default_theme": "Default Theme", + "admin.default_primary_color": "Default Primary Color", + "admin.healthcheck_defaults": "Healthcheck Defaults", + "admin.healthcheck_defaults_description": "JSON configuration for default healthcheck behavior (interval, timeout, method).", + "admin.healthcheck_defaults_label": "Defaults (JSON)", + "admin.save_settings": "Save Settings", + "admin.saving_settings": "Saving...", + + "admin.perm_title": "Grant Permission", + "admin.perm_entity_type": "Entity Type", + "admin.perm_entity": "Entity", + "admin.perm_target_type": "Target Type", + "admin.perm_target": "Target", + "admin.perm_level": "Level", + "admin.perm_board": "Board", + "admin.perm_app": "App", + "admin.perm_user": "User", + "admin.perm_group": "Group", + "admin.perm_view": "View", + "admin.perm_edit": "Edit", + "admin.perm_admin": "Admin", + "admin.perm_grant": "Grant", + "admin.perm_revoke": "Revoke", + "admin.perm_select": "Select...", + "admin.perm_entity_column": "Entity", + "admin.perm_target_column": "Target", + "admin.perm_level_column": "Level", + "admin.perm_action_column": "Action", + "admin.perm_none": "No permissions configured.", + + "search.placeholder": "Search apps and boards...", + "search.trigger": "Search...", + "search.min_chars": "Type at least 2 characters to search", + "search.no_results": "No results for \"{query}\"", + "search.apps": "Apps", + "search.boards": "Boards", + + "common.save": "Save", + "common.cancel": "Cancel", + "common.delete": "Delete", + "common.create": "Create", + "common.back": "Back", + "common.edit": "Edit", + "common.add": "Add", + "common.confirm": "Confirm?", + "common.yes": "Yes", + "common.no": "No", + "common.name": "Name", + "common.description": "Description", + "common.required": "*", + + "status.online": "Online", + "status.offline": "Offline", + "status.degraded": "Degraded", + "status.unknown": "Unknown", + + "theme.dark": "Dark", + "theme.light": "Light", + "theme.system": "System", + "theme.toggle": "Toggle theme (current: {mode})", + "theme.title": "Theme: {mode}", + + "bg.mesh": "Mesh Gradient", + "bg.particles": "Particles", + "bg.aurora": "Aurora", + "bg.none": "None", + "bg.title": "Background effect", + "bg.aria_label": "Change background effect", + + "sidebar.expand": "Expand sidebar", + "sidebar.collapse": "Collapse sidebar", + "sidebar.toggle": "Toggle sidebar", + "sidebar.close": "Close sidebar", + + "home.welcome": "Welcome, {name}. No default board is configured yet.", + "home.view_boards": "View Boards", + "home.browse_apps": "Browse Apps", + + "language.label": "Language" +} diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts new file mode 100644 index 0000000..7c5fe4c --- /dev/null +++ b/src/lib/i18n/index.ts @@ -0,0 +1,34 @@ +import { addMessages, init, getLocaleFromNavigator } from 'svelte-i18n'; +import en from './en.json'; +import ru from './ru.json'; + +const LOCALE_STORAGE_KEY = 'wal-locale'; + +addMessages('en', en); +addMessages('ru', ru); + +function getStoredLocale(): string | null { + if (typeof localStorage === 'undefined') return null; + return localStorage.getItem(LOCALE_STORAGE_KEY); +} + +function detectLocale(): string { + const stored = getStoredLocale(); + if (stored && (stored === 'en' || stored === 'ru')) { + return stored; + } + + const browserLocale = getLocaleFromNavigator() ?? 'en'; + return browserLocale.startsWith('ru') ? 'ru' : 'en'; +} + +export function storeLocale(locale: string): void { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(LOCALE_STORAGE_KEY, locale); + } +} + +init({ + fallbackLocale: 'en', + initialLocale: detectLocale() +}); diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json new file mode 100644 index 0000000..b68cbff --- /dev/null +++ b/src/lib/i18n/ru.json @@ -0,0 +1,245 @@ +{ + "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", + "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", + + "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", + "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.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_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)", + + "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", + "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.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_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.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.", + + "search.placeholder": "\u041f\u043e\u0438\u0441\u043a \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439 \u0438 \u0434\u043e\u0441\u043e\u043a...", + "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", + "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" +} diff --git a/src/lib/types/widget.ts b/src/lib/types/widget.ts index 4b22035..b77224f 100644 --- a/src/lib/types/widget.ts +++ b/src/lib/types/widget.ts @@ -29,27 +29,27 @@ export interface UpdateWidgetInput { // Typed config shapes for different widget types export interface AppWidgetConfig { readonly appId: string; - readonly showStatus?: boolean; - readonly openInNewTab?: boolean; } export interface BookmarkWidgetConfig { readonly url: string; - readonly title: string; + readonly label: string; readonly icon?: string; - readonly openInNewTab?: boolean; + readonly description?: string; } export interface NoteWidgetConfig { readonly content: string; + readonly format: 'markdown' | 'text'; } export interface EmbedWidgetConfig { readonly url: string; - readonly height?: number; + readonly height: number; + readonly sandbox?: string; } export interface StatusWidgetConfig { readonly appIds: readonly string[]; - readonly layout?: 'grid' | 'list'; + readonly label?: string; } diff --git a/src/lib/utils/validators.ts b/src/lib/utils/validators.ts index 2686743..d6de82d 100644 --- a/src/lib/utils/validators.ts +++ b/src/lib/utils/validators.ts @@ -123,6 +123,35 @@ export const updateSectionSchema = z.object({ isExpandedByDefault: z.boolean().optional() }); +// --- Widget Config Schemas --- + +export const appWidgetConfigSchema = z.object({ + appId: z.string().min(1, 'App ID is required') +}); + +export const bookmarkWidgetConfigSchema = z.object({ + url: z.string().url('Invalid URL'), + label: z.string().min(1, 'Label is required').max(200), + icon: z.string().max(100).optional(), + description: z.string().max(500).optional() +}); + +export const noteWidgetConfigSchema = z.object({ + content: z.string().max(10000, 'Content too long'), + format: z.enum(['markdown', 'text']).default('markdown') +}); + +export const embedWidgetConfigSchema = z.object({ + url: z.string().url('Invalid URL'), + height: z.number().int().min(100).max(2000).default(300), + sandbox: z.string().max(200).optional() +}); + +export const statusWidgetConfigSchema = z.object({ + appIds: z.array(z.string().min(1)).min(1, 'At least one app is required'), + label: z.string().max(200).optional() +}); + // --- Widget --- export const createWidgetSchema = z.object({ diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 61cadd3..6da0017 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,6 @@ - Web App Launcher + {$t('app_title')}
-

Web App Launcher

+

{$t('app_title')}

{#if data.user}

- Welcome, {data.user.displayName}. No default board is configured yet. + {$t('home.welcome', { values: { name: data.user.displayName } })}

{/if} diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 3dde6d9..cf3e33e 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -1,15 +1,16 @@ - Group Management — Admin + {$t('admin.group_management')} — {$t('admin.panel')}
-

Group Management

+

{$t('admin.group_management')}

{#if showCreateForm}
-

New Group

+

{$t('admin.new_group')}

- + {$errors.name}{/if}
- + - +
{#if $errors._errors} @@ -78,7 +79,7 @@ type="submit" class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90" > - Create Group + {$t('admin.create_group')}
diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte index 8374dd5..1984d27 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -1,4 +1,5 @@ - System Settings — Admin + {$t('admin.system_settings')} — {$t('admin.panel')}
-

System Settings

-

Configure global application settings.

+

{$t('admin.system_settings')}

+

{$t('admin.settings_description')}

diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte index 5ff487c..7473510 100644 --- a/src/routes/admin/users/+page.svelte +++ b/src/routes/admin/users/+page.svelte @@ -1,4 +1,5 @@ - User Management — Admin + {$t('admin.user_management')} — {$t('admin.panel')}
-

User Management

+

{$t('admin.user_management')}

{#if showCreateForm}
-

New User

+

{$t('admin.new_user')}

- + {$errors.email}{/if}
- + {$errors.displayName}{/if}
- + {$errors.password}{/if}
- +
@@ -93,7 +94,7 @@ type="submit" class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90" > - Create User + {$t('admin.create_user')}
diff --git a/src/routes/apps/+page.svelte b/src/routes/apps/+page.svelte index 9529e93..b9315df 100644 --- a/src/routes/apps/+page.svelte +++ b/src/routes/apps/+page.svelte @@ -1,4 +1,5 @@ - Apps — Web App Launcher + {$t('app.title')} — {$t('app_title')}
-

App Registry

+

{$t('app.title')}

- {data.apps.length} app{data.apps.length === 1 ? '' : 's'} registered + {$t('app.apps_registered', { values: { count: data.apps.length } })}

{#if showForm}
-

New App

+

{$t('app.new')}

{/if} @@ -43,7 +44,7 @@ href="/apps" class="rounded-full border border-border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" > - All + {$t('app.all_categories')} {#each data.categories as category (category)} -

No apps registered yet.

-

Click "Add App" to register your first application.

+

{$t('app.no_apps')}

+

{$t('app.no_apps_hint')}

{:else}
diff --git a/src/routes/boards/+page.svelte b/src/routes/boards/+page.svelte index bea5566..f8d7565 100644 --- a/src/routes/boards/+page.svelte +++ b/src/routes/boards/+page.svelte @@ -1,4 +1,5 @@ - Boards — Web App Launcher + {$t('board.title')} — {$t('app_title')}
{:else} diff --git a/src/routes/boards/[boardId]/+page.server.ts b/src/routes/boards/[boardId]/+page.server.ts index c93ecba..d70ca40 100644 --- a/src/routes/boards/[boardId]/+page.server.ts +++ b/src/routes/boards/[boardId]/+page.server.ts @@ -1,6 +1,7 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types.js'; import * as boardService from '$lib/server/services/boardService.js'; +import * as appService from '$lib/server/services/appService.js'; import * as permissionService from '$lib/server/services/permissionService.js'; import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js'; import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js'; @@ -32,7 +33,10 @@ export const load: PageServerLoad = async ({ params, locals }) => { try { // findBoardById includes sections -> widgets -> app -> statuses - const board = await boardService.findBoardById(boardId); + const [board, allApps] = await Promise.all([ + boardService.findBoardById(boardId), + appService.findAll() + ]); // Determine if user can edit this board let canEdit = false; @@ -50,7 +54,7 @@ export const load: PageServerLoad = async ({ params, locals }) => { } } - return { board, canEdit }; + return { board, canEdit, allApps }; } catch (err) { const message = err instanceof Error ? err.message : 'Board not found'; if (message.includes('not found')) { diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte index 0848fa7..0d4426a 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -1,4 +1,5 @@ - {data.board.name} — Web App Launcher + {data.board.name} — {$t('app_title')}
@@ -20,6 +21,6 @@ canEdit={data.canEdit} /> - +
diff --git a/src/routes/boards/[boardId]/edit/+page.server.ts b/src/routes/boards/[boardId]/edit/+page.server.ts index 9bf4563..fd7f872 100644 --- a/src/routes/boards/[boardId]/edit/+page.server.ts +++ b/src/routes/boards/[boardId]/edit/+page.server.ts @@ -152,16 +152,25 @@ export const actions: Actions = { requireAuth(event); const formData = await event.request.formData(); const sectionId = formData.get('sectionId') as string; - const appId = (formData.get('appId') as string) || undefined; const type = (formData.get('type') as string) || 'app'; + const appId = (formData.get('appId') as string) || undefined; + const configJson = (formData.get('configJson') as string) || undefined; - const config = appId ? JSON.stringify({ appId }) : '{}'; + // Build config based on widget type + let config: string; + if (type === 'app' && appId) { + config = JSON.stringify({ appId }); + } else if (configJson) { + config = configJson; + } else { + config = '{}'; + } const data = { sectionId, type, config, - appId + appId: type === 'app' ? appId : undefined }; const parsed = createWidgetSchema.safeParse(data); diff --git a/src/routes/boards/[boardId]/edit/+page.svelte b/src/routes/boards/[boardId]/edit/+page.svelte index 6aea0b9..e645204 100644 --- a/src/routes/boards/[boardId]/edit/+page.svelte +++ b/src/routes/boards/[boardId]/edit/+page.svelte @@ -1,8 +1,10 @@ - Edit: {data.board.name} + {$t('board.edit_board')}: {data.board.name}
-

Edit Board

+

{$t('board.edit_board')}

- Back to Board + {$t('board.back_to_board')}
-

Board Properties

+

{$t('board.properties')}

- +
- +
- +