diff --git a/plans/mvp-web-app-launcher/CONTEXT.md b/plans/mvp-web-app-launcher/CONTEXT.md index fc8b115..946c20d 100644 --- a/plans/mvp-web-app-launcher/CONTEXT.md +++ b/plans/mvp-web-app-launcher/CONTEXT.md @@ -2,6 +2,8 @@ ## Current State +Phase 7 (UI Polish & Ambient Backgrounds) is complete. All 24 tasks implemented. Three Svelte 5 rune-based stores created: `theme.svelte.ts` (dark/light/system mode cycling, HSL primary color with `--primary-h`/`--primary-s`/`--primary-l` CSS variables set via JS, background type selection, all persisted to localStorage, auto-applies `dark`/`light` class to ``), `ui.svelte.ts` (sidebar collapsed/hidden state with responsive breakpoint detection at 768px), `search.svelte.ts` (Cmd/Ctrl+K hotkey binding, debounced fetch to `/api/search`, results grouped by type). Layout system: `MainLayout.svelte` composes sidebar + header + ambient background + search dialog + page content; `Sidebar.svelte` is collapsible (full on desktop, icons-only when collapsed, hidden on mobile with hamburger overlay); `Header.svelte` has sticky top bar with search trigger, background effect dropdown, theme toggle, and user avatar menu with logout; login/register pages bypass the layout and render their own `AmbientBackground`. Three ambient background effects: `MeshGradient` (4 SVG circles with requestAnimationFrame drift + Gaussian blur at 12% opacity), `ParticleField` (70 canvas particles with connection lines at configurable distance), `AuroraEffect` (3 CSS gradient bands with `aurora-shift` keyframe animation at varying speeds/directions). Search: `SearchDialog` modal with grouped results (apps open in new tab, boards navigate internally), `SearchTrigger` shows shortcut hint. CSS enhancements in `app.css`: HSL-based `--primary` using JS-settable variables, `status-pulse` keyframe on `.status-online`, `.card-hover` class (scale 1.02 + elevated shadow), `.skeleton` shimmer animation, `aurora-shift` keyframe, smooth `background-color`/`color` transition on body, custom scrollbar styling. `app.html` includes inline FOUC-prevention script reading localStorage before first paint. Page transitions via `{#key $page.url.pathname}` + Svelte `fade`. All pages converted from hardcoded gray/indigo colors to semantic CSS variable-based theming. Skeleton components created: `CardSkeleton`, `BoardSkeleton`, `SectionSkeleton`. `+layout.server.ts` extended to fetch sidebar board list filtered by user role/guest status. + Phase 4 (App Registry & Healthcheck) is complete. All app CRUD API routes are implemented at `/api/apps` (GET/POST) and `/api/apps/[id]` (GET/PATCH/DELETE) with Zod validation and auth middleware. Status history is served from `/api/apps/[id]/status`. The healthcheck service performs HTTP HEAD/GET requests with AbortController timeouts, mapping responses to online/offline/degraded/unknown. The scheduler uses node-cron (default: every 60 seconds) with an initial delayed check on startup. Icon resolution supports lucide, simple-icons (CDN), direct URL, and emoji types. The app registry UI at `/apps` renders cards in a responsive grid with category filtering and an inline Superforms create form. Custom icon uploads are handled at `/api/uploads` with type (SVG/PNG/JPG/WebP) and size (<1MB) validation, saving to `static/uploads/`. A Docker healthcheck endpoint at `/api/health` returns 200 with no auth. All Svelte components use runes mode ($state, $derived, $props). Phase 3 (Authentication System) is complete. The full local authentication flow is implemented: login, registration, logout, and JWT token refresh. `hooks.server.ts` validates access tokens on every request, injects `event.locals.user`/`session`, and silently rotates expired tokens via refresh tokens. Protected routes redirect to `/login`; guest-accessible board routes are exempt. Login and registration pages use Superforms + Zod with inline validation errors. Registration respects the `SystemSettings.registrationEnabled` toggle. Reusable middleware helpers (`requireAuth`, `requireAdmin`, `requireRole`) are available for downstream phases. The root layout injects user session into all page data. The root page redirects to the default board or login. `jwt.ts` and `password.ts` are thin re-exports from `authService` (no duplication). Build does not pass yet (Big Bang strategy — expected). diff --git a/plans/mvp-web-app-launcher/PLAN.md b/plans/mvp-web-app-launcher/PLAN.md index 663f1f8..29640cf 100644 --- a/plans/mvp-web-app-launcher/PLAN.md +++ b/plans/mvp-web-app-launcher/PLAN.md @@ -33,7 +33,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi - [x] Phase 4: App Registry & Healthcheck [fullstack] → [subplan](./phase-4-app-healthcheck.md) - [ ] Phase 5: Board, Section & Widget System [fullstack] → [subplan](./phase-5-board-widgets.md) - [x] Phase 6: Admin Panel [fullstack] → [subplan](./phase-6-admin-panel.md) -- [ ] Phase 7: UI Polish & Ambient Backgrounds [frontend] → [subplan](./phase-7-ui-polish.md) +- [x] Phase 7: UI Polish & Ambient Backgrounds [frontend] → [subplan](./phase-7-ui-polish.md) - [ ] Phase 8: Integration, Testing & Deployment [fullstack] → [subplan](./phase-8-integration-deploy.md) ## Phase Progress Log @@ -46,7 +46,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi | Phase 4: App & Healthcheck | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 5: Board & Widgets | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 6: Admin Panel | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ | -| Phase 7: UI Polish | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 7: UI Polish | frontend | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 8: Integration & Deploy | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | ## Final Review diff --git a/plans/mvp-web-app-launcher/phase-7-ui-polish.md b/plans/mvp-web-app-launcher/phase-7-ui-polish.md index 6b6ee53..37d0a16 100644 --- a/plans/mvp-web-app-launcher/phase-7-ui-polish.md +++ b/plans/mvp-web-app-launcher/phase-7-ui-polish.md @@ -1,6 +1,6 @@ # Phase 7: UI Polish & Ambient Backgrounds -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** frontend @@ -9,30 +9,30 @@ Polish the entire UI: implement the root layout with sidebar and header, dark/li ## Tasks -- [ ] Task 1: Create `src/lib/components/layout/MainLayout.svelte` — root layout wrapper -- [ ] Task 2: Create `src/lib/components/layout/Sidebar.svelte` — collapsible sidebar with board list -- [ ] Task 3: Create `src/lib/components/layout/Header.svelte` — top bar with search trigger, user menu, theme toggle -- [ ] Task 4: Create `src/lib/components/layout/ThemeToggle.svelte` — dark/light/system toggle -- [ ] Task 5: Create `src/lib/stores/theme.svelte.ts` — Svelte 5 rune-based theme store (HSL primary color, mode) -- [ ] Task 6: Create `src/lib/stores/ui.svelte.ts` — sidebar state, layout preferences -- [ ] Task 7: Create `src/lib/stores/search.svelte.ts` — search dialog state -- [ ] Task 8: Update `src/app.css` — complete theme system with CSS custom properties, HSL-based colors, dark/light variants -- [ ] Task 9: Create `src/lib/components/background/AmbientBackground.svelte` — background switcher component -- [ ] Task 10: Create `src/lib/components/background/MeshGradient.svelte` — animated mesh gradient using tweened/spring -- [ ] Task 11: Create `src/lib/components/background/ParticleField.svelte` — canvas-based particle animation -- [ ] Task 12: Create `src/lib/components/background/AuroraEffect.svelte` — aurora borealis CSS animation -- [ ] Task 13: Create `src/lib/components/search/SearchDialog.svelte` — Cmd/Ctrl+K search dialog -- [ ] Task 14: Create `src/lib/components/search/SearchResult.svelte` — search result item -- [ ] Task 15: Create `src/lib/components/search/SearchTrigger.svelte` — search bar trigger in header -- [ ] Task 16: Add page transitions to `+layout.svelte` — fade/fly transitions between routes -- [ ] Task 17: Add section expand/collapse animations (Svelte slide transition) -- [ ] Task 18: Add card hover effects — subtle scale + shadow lift via CSS + spring -- [ ] Task 19: Add status indicator pulse animation (CSS @keyframes) -- [ ] Task 20: Add skeleton loading states for boards, apps, sections -- [ ] Task 21: Ensure fully responsive design — desktop, tablet, mobile breakpoints -- [ ] Task 22: Update `src/routes/+layout.svelte` — integrate MainLayout, AmbientBackground, theme system -- [ ] Task 23: Polish login and register pages with consistent styling -- [ ] Task 24: Polish all existing pages (apps, boards, admin) with consistent component styling +- [x] Task 1: Create `src/lib/components/layout/MainLayout.svelte` — root layout wrapper +- [x] Task 2: Create `src/lib/components/layout/Sidebar.svelte` — collapsible sidebar with board list +- [x] Task 3: Create `src/lib/components/layout/Header.svelte` — top bar with search trigger, user menu, theme toggle +- [x] Task 4: Create `src/lib/components/layout/ThemeToggle.svelte` — dark/light/system toggle +- [x] Task 5: Create `src/lib/stores/theme.svelte.ts` — Svelte 5 rune-based theme store (HSL primary color, mode) +- [x] Task 6: Create `src/lib/stores/ui.svelte.ts` — sidebar state, layout preferences +- [x] Task 7: Create `src/lib/stores/search.svelte.ts` — search dialog state +- [x] Task 8: Update `src/app.css` — complete theme system with CSS custom properties, HSL-based colors, dark/light variants +- [x] Task 9: Create `src/lib/components/background/AmbientBackground.svelte` — background switcher component +- [x] Task 10: Create `src/lib/components/background/MeshGradient.svelte` — animated mesh gradient using tweened/spring +- [x] Task 11: Create `src/lib/components/background/ParticleField.svelte` — canvas-based particle animation +- [x] Task 12: Create `src/lib/components/background/AuroraEffect.svelte` — aurora borealis CSS animation +- [x] Task 13: Create `src/lib/components/search/SearchDialog.svelte` — Cmd/Ctrl+K search dialog +- [x] Task 14: Create `src/lib/components/search/SearchResult.svelte` — search result item +- [x] Task 15: Create `src/lib/components/search/SearchTrigger.svelte` — search bar trigger in header +- [x] Task 16: Add page transitions to `+layout.svelte` — fade/fly transitions between routes +- [x] Task 17: Add section expand/collapse animations (Svelte slide transition) +- [x] Task 18: Add card hover effects — subtle scale + shadow lift via CSS + spring +- [x] Task 19: Add status indicator pulse animation (CSS @keyframes) +- [x] Task 20: Add skeleton loading states for boards, apps, sections +- [x] Task 21: Ensure fully responsive design — desktop, tablet, mobile breakpoints +- [x] Task 22: Update `src/routes/+layout.svelte` — integrate MainLayout, AmbientBackground, theme system +- [x] Task 23: Polish login and register pages with consistent styling +- [x] Task 24: Polish all existing pages (apps, boards, admin) with consistent component styling ## Files to Modify/Create - `src/lib/components/layout/MainLayout.svelte` @@ -76,11 +76,32 @@ Polish the entire UI: implement the root layout with sidebar and header, dark/li - Use Tailwind utility classes as primary styling approach ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions -- [ ] No unintended side effects +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - + +Phase 7 (UI Polish & Ambient Backgrounds) is complete. All 24 tasks implemented: + +**Stores (3 files):** Three Svelte 5 rune-based stores created — `theme.svelte.ts` (dark/light/system mode, HSL primary color, background type, localStorage persistence, auto-applies classes to ``), `ui.svelte.ts` (sidebar collapsed/hidden state, responsive breakpoint detection, localStorage persistence), `search.svelte.ts` (Cmd/Ctrl+K hotkey, debounced fetch to `/api/search`, grouped results by type). + +**Layout (4 components):** `MainLayout.svelte` wraps the entire app with sidebar + header + content + ambient background + search dialog. `Sidebar.svelte` is collapsible (icons-only on tablet, hidden on mobile with hamburger toggle), shows navigation links and board list with active-state highlighting, admin link for admin users. `Header.svelte` provides sticky top bar with mobile hamburger, search trigger, background selector dropdown, theme toggle, and user avatar menu with logout. `ThemeToggle.svelte` cycles through light/dark/system modes. + +**Backgrounds (4 components):** `AmbientBackground.svelte` switches between three effects. `MeshGradient.svelte` renders 4 SVG blobs with requestAnimationFrame-driven drift, blurred, at low opacity, colored by HSL primary. `ParticleField.svelte` draws 70 particles on a canvas with connection lines between nearby particles. `AuroraEffect.svelte` uses CSS gradient animation on three skewed bands with the aurora-shift keyframe. + +**Search (3 components):** `SearchDialog.svelte` is a modal overlay with text input, debounced search, results grouped by apps/boards, loading spinner, empty state. `SearchResult.svelte` displays individual results with type badge. `SearchTrigger.svelte` shows a search button in the header with Cmd/Ctrl+K shortcut hint. + +**CSS/Theme:** `app.css` updated with HSL-based `--primary` using `--primary-h`/`--primary-s`/`--primary-l` variables (JS-settable), status-pulse keyframe for online dots, card-hover utility class (scale + shadow), skeleton shimmer animation, aurora-shift keyframe, scrollbar styling, smooth body background transition. `app.html` includes inline FOUC-prevention script that reads localStorage before first paint. + +**Animations:** Page transitions via `{#key}` + Svelte `fade` in `+layout.svelte`. Section collapse uses existing Svelte `slide` transition. Card hover via `.card-hover` CSS class on AppCard, BoardCard, AppWidget. Status pulse via `.status-online` CSS class on AppHealthBadge. + +**Skeletons:** Three skeleton components — `CardSkeleton`, `BoardSkeleton`, `SectionSkeleton` — using the `.skeleton` shimmer CSS class. + +**Page Polish:** All pages updated to use semantic theme variables (no hardcoded gray/indigo colors). Login and register pages enhanced with logo icon, backdrop blur, smoother input styling. Board pages, edit page, and admin layout all converted from hardcoded dark colors to CSS variable-based theming. Admin layout uses pill-style active nav tabs. + +**Responsive:** Sidebar hidden on mobile (<768px) with hamburger toggle; collapsed to icons on tablet; expanded on desktop. Widget grids use responsive grid-cols. Login/register are centered and full-width on mobile. + +**Layout server:** `+layout.server.ts` now fetches sidebar board list (admin: all boards, regular users: all boards, guests: guest-accessible only). diff --git a/src/app.css b/src/app.css index e18d825..c9c6098 100644 --- a/src/app.css +++ b/src/app.css @@ -4,6 +4,11 @@ @custom-variant dark (&:is(.dark *)); :root { + /* HSL-based primary color (overridden by theme store via JS) */ + --primary-h: 220; + --primary-s: 70%; + --primary-l: 50%; + --background: hsl(0 0% 100%); --foreground: hsl(240 10% 3.9%); --muted: hsl(240 4.8% 95.9%); @@ -14,7 +19,7 @@ --card-foreground: hsl(240 10% 3.9%); --border: hsl(240 5.9% 90%); --input: hsl(240 5.9% 90%); - --primary: hsl(240 5.9% 10%); + --primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l)); --primary-foreground: hsl(0 0% 98%); --secondary: hsl(240 4.8% 95.9%); --secondary-foreground: hsl(240 5.9% 10%); @@ -22,30 +27,32 @@ --accent-foreground: hsl(240 5.9% 10%); --destructive: hsl(0 72.2% 50.6%); --destructive-foreground: hsl(0 0% 98%); - --ring: hsl(240 10% 3.9%); + --ring: hsl(var(--primary-h) var(--primary-s) var(--primary-l)); --radius: 0.5rem; --sidebar: hsl(0 0% 98%); --sidebar-foreground: hsl(240 5.3% 26.1%); - --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l)); --sidebar-primary-foreground: hsl(0 0% 98%); --sidebar-accent: hsl(240 4.8% 95.9%); --sidebar-accent-foreground: hsl(240 5.9% 10%); --sidebar-border: hsl(220 13% 91%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); + --sidebar-ring: hsl(var(--primary-h) calc(var(--primary-s) * 1.2) 60%); } .dark { + --primary-l: 60%; + --background: hsl(240 10% 3.9%); --foreground: hsl(0 0% 98%); --muted: hsl(240 3.7% 15.9%); --muted-foreground: hsl(240 5% 64.9%); --popover: hsl(240 10% 3.9%); --popover-foreground: hsl(0 0% 98%); - --card: hsl(240 10% 3.9%); + --card: hsl(240 6% 7%); --card-foreground: hsl(0 0% 98%); --border: hsl(240 3.7% 15.9%); --input: hsl(240 3.7% 15.9%); - --primary: hsl(0 0% 98%); + --primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l)); --primary-foreground: hsl(240 5.9% 10%); --secondary: hsl(240 3.7% 15.9%); --secondary-foreground: hsl(0 0% 98%); @@ -53,15 +60,15 @@ --accent-foreground: hsl(0 0% 98%); --destructive: hsl(0 62.8% 30.6%); --destructive-foreground: hsl(0 0% 98%); - --ring: hsl(240 4.9% 83.9%); - --sidebar: hsl(240 5.9% 10%); + --ring: hsl(var(--primary-h) var(--primary-s) var(--primary-l)); + --sidebar: hsl(240 5.9% 6%); --sidebar-foreground: hsl(240 4.8% 95.9%); - --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l)); --sidebar-primary-foreground: hsl(0 0% 100%); --sidebar-accent: hsl(240 3.7% 15.9%); --sidebar-accent-foreground: hsl(240 4.8% 95.9%); --sidebar-border: hsl(240 3.7% 15.9%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); + --sidebar-ring: hsl(var(--primary-h) calc(var(--primary-s) * 1.2) 60%); } @theme inline { @@ -105,5 +112,107 @@ } body { @apply bg-background text-foreground; + transition: background-color 0.3s ease, color 0.3s ease; + } +} + +/* ===== Status Indicator Pulse ===== */ +@keyframes status-pulse { + 0%, + 100% { + opacity: 1; + box-shadow: 0 0 0 0 currentColor; + } + 50% { + opacity: 0.8; + box-shadow: 0 0 0 4px transparent; + } +} + +.status-online { + animation: status-pulse 2s ease-in-out infinite; + color: hsl(142 71% 45%); +} + +/* ===== Card Hover Effects ===== */ +.card-hover { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card-hover:hover { + transform: scale(1.02); + box-shadow: + 0 10px 25px -5px rgba(0, 0, 0, 0.15), + 0 4px 10px -5px rgba(0, 0, 0, 0.1); +} + +.dark .card-hover:hover { + box-shadow: + 0 10px 25px -5px rgba(0, 0, 0, 0.4), + 0 4px 10px -5px rgba(0, 0, 0, 0.3); +} + +/* ===== Skeleton Loading ===== */ +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.skeleton { + background: linear-gradient( + 90deg, + var(--muted) 25%, + hsl(240 4.8% 85%) 50%, + var(--muted) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + border-radius: var(--radius); +} + +.dark .skeleton { + background: linear-gradient( + 90deg, + var(--muted) 25%, + hsl(240 3.7% 22%) 50%, + var(--muted) 75% + ); + background-size: 200% 100%; +} + +/* ===== Scrollbar Styling ===== */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--muted-foreground); + border-radius: 4px; + opacity: 0.5; +} + +::-webkit-scrollbar-thumb:hover { + opacity: 0.8; +} + +/* ===== Aurora Keyframes ===== */ +@keyframes aurora-shift { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; } } diff --git a/src/app.html b/src/app.html index a2e03b2..dca9062 100644 --- a/src/app.html +++ b/src/app.html @@ -4,6 +4,20 @@ + %sveltekit.head% diff --git a/src/lib/components/app/AppCard.svelte b/src/lib/components/app/AppCard.svelte index 64faf3a..ff01800 100644 --- a/src/lib/components/app/AppCard.svelte +++ b/src/lib/components/app/AppCard.svelte @@ -43,7 +43,7 @@ href={app.url} target="_blank" rel="noopener noreferrer" - class="group flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/50 hover:bg-accent/50" + class="card-hover group flex flex-col rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/50" title={app.description ?? app.name} >
@@ -67,7 +67,7 @@
-

+

{app.name}

diff --git a/src/lib/components/app/AppHealthBadge.svelte b/src/lib/components/app/AppHealthBadge.svelte index e0ae37f..a384bab 100644 --- a/src/lib/components/app/AppHealthBadge.svelte +++ b/src/lib/components/app/AppHealthBadge.svelte @@ -8,18 +8,18 @@ const config = $derived.by(() => { switch (status) { case 'online': - return { color: 'bg-green-500', text: 'Online' }; + return { color: 'bg-green-500', cssClass: 'status-online', text: 'Online' }; case 'offline': - return { color: 'bg-red-500', text: 'Offline' }; + return { color: 'bg-red-500', cssClass: '', text: 'Offline' }; case 'degraded': - return { color: 'bg-yellow-500', text: 'Degraded' }; + return { color: 'bg-yellow-500', cssClass: '', text: 'Degraded' }; default: - return { color: 'bg-gray-500', text: 'Unknown' }; + return { color: 'bg-gray-500', cssClass: '', text: 'Unknown' }; } }); - + {config.text} diff --git a/src/lib/components/background/AmbientBackground.svelte b/src/lib/components/background/AmbientBackground.svelte new file mode 100644 index 0000000..e1faa48 --- /dev/null +++ b/src/lib/components/background/AmbientBackground.svelte @@ -0,0 +1,18 @@ + + +{#if theme.backgroundType !== 'none'} + +{/if} diff --git a/src/lib/components/background/AuroraEffect.svelte b/src/lib/components/background/AuroraEffect.svelte new file mode 100644 index 0000000..7d40508 --- /dev/null +++ b/src/lib/components/background/AuroraEffect.svelte @@ -0,0 +1,62 @@ + + +
+ +
+ + +
+ + +
+
diff --git a/src/lib/components/background/MeshGradient.svelte b/src/lib/components/background/MeshGradient.svelte new file mode 100644 index 0000000..63d970a --- /dev/null +++ b/src/lib/components/background/MeshGradient.svelte @@ -0,0 +1,71 @@ + + +
+ + + + + + + + {#each blobs as blob, i} + + {/each} + +
diff --git a/src/lib/components/background/ParticleField.svelte b/src/lib/components/background/ParticleField.svelte new file mode 100644 index 0000000..7b5f271 --- /dev/null +++ b/src/lib/components/background/ParticleField.svelte @@ -0,0 +1,110 @@ + + + diff --git a/src/lib/components/board/Board.svelte b/src/lib/components/board/Board.svelte index 691021b..8b843f0 100644 --- a/src/lib/components/board/Board.svelte +++ b/src/lib/components/board/Board.svelte @@ -34,8 +34,8 @@
{#if sections.length === 0} -
-

This board has no sections yet.

+
+

This board has no sections yet.

{:else} {#each sections as section (section.id)} diff --git a/src/lib/components/board/BoardCard.svelte b/src/lib/components/board/BoardCard.svelte index 305a5a0..87999c4 100644 --- a/src/lib/components/board/BoardCard.svelte +++ b/src/lib/components/board/BoardCard.svelte @@ -20,36 +20,36 @@
{#if board.icon} {board.icon} {:else} - + B {/if}
-

+

{board.name}

{#if board.isDefault} - + Default {/if} {#if board.isGuestAccessible} - + Guest {/if}
{#if board.description} -

{board.description}

+

{board.description}

{/if} -

+

{sectionCount} section{sectionCount === 1 ? '' : 's'}

diff --git a/src/lib/components/board/BoardHeader.svelte b/src/lib/components/board/BoardHeader.svelte index 262dbb9..0aa98a5 100644 --- a/src/lib/components/board/BoardHeader.svelte +++ b/src/lib/components/board/BoardHeader.svelte @@ -16,9 +16,9 @@ {icon} {/if}
-

{name}

+

{name}

{#if description} -

{description}

+

{description}

{/if}
@@ -26,14 +26,14 @@
All Boards {#if canEdit} Edit diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte new file mode 100644 index 0000000..98603f3 --- /dev/null +++ b/src/lib/components/layout/Header.svelte @@ -0,0 +1,192 @@ + + + + +
+ + {#if ui.isMobile} + + {/if} + + +
+ +
+ + +
+ + + {#if showBgMenu} +
+ {#each bgOptions as opt} + + {/each} +
+ {/if} +
+ + + + + + {#if user} +
+ + + {#if showUserMenu} +
+
+

{user.displayName}

+

{user.email}

+
+ +
+ +
+
+ {/if} +
+ {:else} + + Sign In + + {/if} +
diff --git a/src/lib/components/layout/MainLayout.svelte b/src/lib/components/layout/MainLayout.svelte new file mode 100644 index 0000000..68170ba --- /dev/null +++ b/src/lib/components/layout/MainLayout.svelte @@ -0,0 +1,67 @@ + + + + + +
+ + {#if ui.isMobile && !ui.sidebarHidden} + + {/if} + + + {#if !ui.sidebarHidden || !ui.isMobile} +
+ +
+ {/if} + + +
+
+ +
+ {@render children()} +
+
+
+ + + diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte new file mode 100644 index 0000000..7065f73 --- /dev/null +++ b/src/lib/components/layout/Sidebar.svelte @@ -0,0 +1,233 @@ + + + diff --git a/src/lib/components/layout/ThemeToggle.svelte b/src/lib/components/layout/ThemeToggle.svelte new file mode 100644 index 0000000..f8d2cc8 --- /dev/null +++ b/src/lib/components/layout/ThemeToggle.svelte @@ -0,0 +1,41 @@ + + + diff --git a/src/lib/components/search/SearchDialog.svelte b/src/lib/components/search/SearchDialog.svelte new file mode 100644 index 0000000..f399a35 --- /dev/null +++ b/src/lib/components/search/SearchDialog.svelte @@ -0,0 +1,111 @@ + + +{#if search.open} + + +
e.key === 'Escape' && search.close()} + > + + +
+{/if} diff --git a/src/lib/components/search/SearchResult.svelte b/src/lib/components/search/SearchResult.svelte new file mode 100644 index 0000000..6b32c05 --- /dev/null +++ b/src/lib/components/search/SearchResult.svelte @@ -0,0 +1,79 @@ + + + + +
+ {#if result.icon} + {result.icon} + {:else if result.type === 'app'} + + + + + + {:else} + + + + + + {/if} +
+ + +
+

{result.name}

+ {#if result.description} +

{result.description}

+ {/if} +
+ + + + {result.type} + +
diff --git a/src/lib/components/search/SearchTrigger.svelte b/src/lib/components/search/SearchTrigger.svelte new file mode 100644 index 0000000..bf7a9e6 --- /dev/null +++ b/src/lib/components/search/SearchTrigger.svelte @@ -0,0 +1,33 @@ + + + diff --git a/src/lib/components/section/Section.svelte b/src/lib/components/section/Section.svelte index 84ba5a0..5a072b5 100644 --- a/src/lib/components/section/Section.svelte +++ b/src/lib/components/section/Section.svelte @@ -38,7 +38,7 @@ let expanded = $state(section.isExpandedByDefault); -
+
{icon} {/if} - {title} + {title} diff --git a/src/lib/components/skeleton/BoardSkeleton.svelte b/src/lib/components/skeleton/BoardSkeleton.svelte new file mode 100644 index 0000000..ee6fb10 --- /dev/null +++ b/src/lib/components/skeleton/BoardSkeleton.svelte @@ -0,0 +1,22 @@ + + +{#each items as i (i)} +
+
+
+
+
+
+
+
+
+
+{/each} diff --git a/src/lib/components/skeleton/CardSkeleton.svelte b/src/lib/components/skeleton/CardSkeleton.svelte new file mode 100644 index 0000000..423b837 --- /dev/null +++ b/src/lib/components/skeleton/CardSkeleton.svelte @@ -0,0 +1,21 @@ + + +{#each items as i (i)} +
+
+
+
+
+
+
+
+
+{/each} diff --git a/src/lib/components/skeleton/SectionSkeleton.svelte b/src/lib/components/skeleton/SectionSkeleton.svelte new file mode 100644 index 0000000..6fcdefa --- /dev/null +++ b/src/lib/components/skeleton/SectionSkeleton.svelte @@ -0,0 +1,32 @@ + + +{#each sections as s (s)} +
+ +
+
+
+
+ + +
+ {#each widgets as w (w)} +
+
+
+
+
+ {/each} +
+
+{/each} diff --git a/src/lib/components/widget/AppWidget.svelte b/src/lib/components/widget/AppWidget.svelte index 9659d3a..3d5b26f 100644 --- a/src/lib/components/widget/AppWidget.svelte +++ b/src/lib/components/widget/AppWidget.svelte @@ -39,10 +39,10 @@ href={app.url} target="_blank" rel="noopener noreferrer" - class="group flex flex-col items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/50 p-4 text-center transition-colors hover:border-indigo-500/50 hover:bg-gray-800" + class="card-hover group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50" > -
+
{#if app.iconType === 'emoji' && app.icon} {app.icon} {:else if iconSrc} @@ -52,14 +52,14 @@ class="h-8 w-8 object-contain" /> {:else} - + {app.name.charAt(0).toUpperCase()} {/if}
- + {app.name} diff --git a/src/lib/components/widget/WidgetGrid.svelte b/src/lib/components/widget/WidgetGrid.svelte index d36fb1f..a5f1e69 100644 --- a/src/lib/components/widget/WidgetGrid.svelte +++ b/src/lib/components/widget/WidgetGrid.svelte @@ -27,7 +27,7 @@ {#if widgets.length === 0} -

No widgets in this section.

+

No widgets in this section.

{:else}
{#each widgets as widget (widget.id)} @@ -35,8 +35,8 @@ {#if widget.type === 'app' && widget.app} {:else} -
- {widget.type} widget +
+ {widget.type} widget
{/if} diff --git a/src/lib/stores/search.svelte.ts b/src/lib/stores/search.svelte.ts new file mode 100644 index 0000000..e80d410 --- /dev/null +++ b/src/lib/stores/search.svelte.ts @@ -0,0 +1,122 @@ +export interface SearchResultItem { + type: 'app' | 'board'; + id: string; + name: string; + description: string | null; + url: string; + icon: string | null; +} + +class SearchStore { + open = $state(false); + query = $state(''); + results = $state([]); + loading = $state(false); + error = $state(null); + + #debounceTimer: ReturnType | null = null; + + constructor() { + if (typeof window !== 'undefined') { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + this.toggle(); + } + if (e.key === 'Escape' && this.open) { + e.preventDefault(); + this.close(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + } + + $effect(() => { + const q = this.query; + if (q.length < 2) { + this.results = []; + this.error = null; + return; + } + this.#debouncedSearch(q); + }); + } + + #debouncedSearch(q: string) { + if (this.#debounceTimer) { + clearTimeout(this.#debounceTimer); + } + this.#debounceTimer = setTimeout(() => { + this.#performSearch(q); + }, 300); + } + + async #performSearch(q: string) { + this.loading = true; + this.error = null; + + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`); + if (!res.ok) { + this.error = 'Search failed'; + this.results = []; + return; + } + + const data = await res.json(); + const items: SearchResultItem[] = []; + + if (data.apps) { + for (const app of data.apps) { + items.push({ + type: 'app', + id: app.id, + name: app.name, + description: app.description ?? null, + url: app.url, + icon: app.icon ?? null + }); + } + } + + if (data.boards) { + for (const board of data.boards) { + items.push({ + type: 'board', + id: board.id, + name: board.name, + description: board.description ?? null, + url: `/boards/${board.id}`, + icon: board.icon ?? null + }); + } + } + + this.results = items; + } catch { + this.error = 'Search failed'; + this.results = []; + } finally { + this.loading = false; + } + } + + toggle() { + this.open = !this.open; + if (!this.open) { + this.query = ''; + this.results = []; + this.error = null; + } + } + + close() { + this.open = false; + this.query = ''; + this.results = []; + this.error = null; + } +} + +export const search = new SearchStore(); diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts new file mode 100644 index 0000000..caa7fec --- /dev/null +++ b/src/lib/stores/theme.svelte.ts @@ -0,0 +1,120 @@ +const THEME_STORAGE_KEY = 'wal-theme-mode'; +const PRIMARY_HUE_KEY = 'wal-primary-hue'; +const PRIMARY_SAT_KEY = 'wal-primary-sat'; +const BG_TYPE_KEY = 'wal-bg-type'; + +export type ThemeMode = 'dark' | 'light' | 'system'; +export type BackgroundType = 'mesh' | 'particles' | 'aurora' | 'none'; + +function getStoredValue(key: string, fallback: T): T { + if (typeof window === 'undefined') return fallback; + try { + const stored = localStorage.getItem(key); + if (stored === null) return fallback; + return stored as unknown as T; + } catch { + return fallback; + } +} + +function getStoredNumber(key: string, fallback: number): number { + if (typeof window === 'undefined') return fallback; + try { + const stored = localStorage.getItem(key); + if (stored === null) return fallback; + const parsed = Number(stored); + return Number.isNaN(parsed) ? fallback : parsed; + } catch { + return fallback; + } +} + +class ThemeStore { + mode = $state('system'); + primaryHue = $state(220); + primarySaturation = $state(70); + backgroundType = $state('mesh'); + + resolvedMode = $derived<'dark' | 'light'>( + this.mode === 'system' ? this.#systemPreference : this.mode + ); + + isDark = $derived(this.resolvedMode === 'dark'); + + #systemPreference: 'dark' | 'light' = 'dark'; + + constructor() { + if (typeof window !== 'undefined') { + this.mode = getStoredValue(THEME_STORAGE_KEY, 'system'); + this.primaryHue = getStoredNumber(PRIMARY_HUE_KEY, 220); + this.primarySaturation = getStoredNumber(PRIMARY_SAT_KEY, 70); + this.backgroundType = getStoredValue(BG_TYPE_KEY, 'mesh'); + + const mql = window.matchMedia('(prefers-color-scheme: dark)'); + this.#systemPreference = mql.matches ? 'dark' : 'light'; + mql.addEventListener('change', (e) => { + this.#systemPreference = e.matches ? 'dark' : 'light'; + }); + } + + $effect(() => { + if (typeof window === 'undefined') return; + localStorage.setItem(THEME_STORAGE_KEY, this.mode); + }); + + $effect(() => { + if (typeof window === 'undefined') return; + localStorage.setItem(PRIMARY_HUE_KEY, String(this.primaryHue)); + }); + + $effect(() => { + if (typeof window === 'undefined') return; + localStorage.setItem(PRIMARY_SAT_KEY, String(this.primarySaturation)); + }); + + $effect(() => { + if (typeof window === 'undefined') return; + localStorage.setItem(BG_TYPE_KEY, this.backgroundType); + }); + + $effect(() => { + if (typeof document === 'undefined') return; + const html = document.documentElement; + if (this.resolvedMode === 'dark') { + html.classList.add('dark'); + html.classList.remove('light'); + } else { + html.classList.remove('dark'); + html.classList.add('light'); + } + }); + + $effect(() => { + if (typeof document === 'undefined') return; + const html = document.documentElement; + html.style.setProperty('--primary-h', String(this.primaryHue)); + html.style.setProperty('--primary-s', `${this.primarySaturation}%`); + }); + } + + cycleMode() { + const modes: ThemeMode[] = ['light', 'dark', 'system']; + const idx = modes.indexOf(this.mode); + this.mode = modes[(idx + 1) % modes.length]; + } + + setMode(mode: ThemeMode) { + this.mode = mode; + } + + setBackground(bg: BackgroundType) { + this.backgroundType = bg; + } + + setPrimaryColor(hue: number, saturation: number) { + this.primaryHue = Math.max(0, Math.min(360, hue)); + this.primarySaturation = Math.max(0, Math.min(100, saturation)); + } +} + +export const theme = new ThemeStore(); diff --git a/src/lib/stores/ui.svelte.ts b/src/lib/stores/ui.svelte.ts new file mode 100644 index 0000000..77178d1 --- /dev/null +++ b/src/lib/stores/ui.svelte.ts @@ -0,0 +1,76 @@ +const SIDEBAR_COLLAPSED_KEY = 'wal-sidebar-collapsed'; +const SIDEBAR_HIDDEN_KEY = 'wal-sidebar-hidden'; + +function getStoredBool(key: string, fallback: boolean): boolean { + if (typeof window === 'undefined') return fallback; + try { + const stored = localStorage.getItem(key); + if (stored === null) return fallback; + return stored === 'true'; + } catch { + return fallback; + } +} + +class UiStore { + sidebarCollapsed = $state(false); + sidebarHidden = $state(false); + isMobile = $state(false); + + sidebarVisible = $derived(!this.sidebarHidden); + + constructor() { + if (typeof window !== 'undefined') { + this.sidebarCollapsed = getStoredBool(SIDEBAR_COLLAPSED_KEY, false); + this.sidebarHidden = getStoredBool(SIDEBAR_HIDDEN_KEY, false); + + this.isMobile = window.innerWidth < 768; + + const handleResize = () => { + const wasMobile = this.isMobile; + this.isMobile = window.innerWidth < 768; + + if (this.isMobile && !wasMobile) { + this.sidebarHidden = true; + } + if (!this.isMobile && wasMobile) { + this.sidebarHidden = false; + } + }; + + window.addEventListener('resize', handleResize); + } + + $effect(() => { + if (typeof window === 'undefined') return; + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(this.sidebarCollapsed)); + }); + + $effect(() => { + if (typeof window === 'undefined') return; + localStorage.setItem(SIDEBAR_HIDDEN_KEY, String(this.sidebarHidden)); + }); + } + + toggleSidebar() { + if (this.isMobile) { + this.sidebarHidden = !this.sidebarHidden; + } else { + this.sidebarCollapsed = !this.sidebarCollapsed; + } + } + + closeMobileSidebar() { + if (this.isMobile) { + this.sidebarHidden = true; + } + } + + openMobileSidebar() { + if (this.isMobile) { + this.sidebarHidden = false; + } + } +} + +export const ui = new UiStore(); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 5d5a2ce..17920b5 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,7 +1,40 @@ import type { LayoutServerLoad } from './$types.js'; +import { prisma } from '$lib/server/prisma.js'; export const load: LayoutServerLoad = async ({ locals }) => { + // Fetch sidebar boards for the layout + let boards: Array<{ id: string; name: string; icon: string | null }> = []; + + try { + if (locals.user) { + // Authenticated user: fetch boards they can access + if (locals.user.role === 'admin') { + boards = await prisma.board.findMany({ + select: { id: true, name: true, icon: true }, + orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] + }); + } else { + // Regular users: fetch all boards (permission filtering done at page level) + boards = await prisma.board.findMany({ + select: { id: true, name: true, icon: true }, + orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] + }); + } + } else { + // Guest: only guest-accessible boards + boards = await prisma.board.findMany({ + where: { isGuestAccessible: true }, + select: { id: true, name: true, icon: true }, + orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] + }); + } + } catch { + // Fail gracefully — sidebar will just be empty + boards = []; + } + return { - user: locals.user + user: locals.user, + sidebarBoards: boards }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 8b8c3cd..044b59d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,10 +2,34 @@ import '../app.css'; import type { Snippet } from 'svelte'; import type { LayoutData } from './$types.js'; + import MainLayout from '$lib/components/layout/MainLayout.svelte'; + import { page } from '$app/stores'; + import { fade } from 'svelte/transition'; let { data, children }: { data: LayoutData; children: Snippet } = $props(); + + // Pages that should NOT have the main layout (login, register) + const noLayoutPaths = ['/login', '/register']; + const showLayout = $derived(!noLayoutPaths.includes($page.url.pathname)); + + const pageKey = $derived($page.url.pathname); -
- {@render children()} -
+{#if showLayout} + + {#key pageKey} +
+ {@render children()} +
+ {/key} +
+{:else} + {#key pageKey} +
+ {@render children()} +
+ {/key} +{/if} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1274518..8661fe4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -8,21 +8,27 @@ Web App Launcher -
+
-

Web App Launcher

+

Web App Launcher

{#if data.user}

Welcome, {data.user.displayName}. No default board is configured yet.

-
- - + View Boards +
+ + Browse Apps + +
{/if}
-
+
diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 06444e6..a5c844a 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -1,6 +1,7 @@ -
- -
+ {@render children()} -
+
diff --git a/src/routes/apps/+page.svelte b/src/routes/apps/+page.svelte index 9c15fce..68857bc 100644 --- a/src/routes/apps/+page.svelte +++ b/src/routes/apps/+page.svelte @@ -2,6 +2,7 @@ import type { PageData } from './$types.js'; import AppCard from '$lib/components/app/AppCard.svelte'; import AppForm from '$lib/components/app/AppForm.svelte'; + import CardSkeleton from '$lib/components/skeleton/CardSkeleton.svelte'; let { data }: { data: PageData } = $props(); @@ -12,21 +13,26 @@ Apps — Web App Launcher -
+
-

App Registry

+
+

App Registry

+

+ {data.apps.length} app{data.apps.length === 1 ? '' : 's'} registered +

+
{#if showForm} -
+

New App

@@ -36,14 +42,14 @@
All {#each data.categories as category} {category} @@ -52,7 +58,21 @@ {/if} {#if data.apps.length === 0} -
+
+ + + + +

No apps registered yet.

Click "Add App" to register your first application.

@@ -64,4 +84,4 @@
{/if}
-
+
diff --git a/src/routes/boards/+page.svelte b/src/routes/boards/+page.svelte index d2c732a..bea5566 100644 --- a/src/routes/boards/+page.svelte +++ b/src/routes/boards/+page.svelte @@ -6,40 +6,56 @@ - Boards + Boards — Web App Launcher -
-
-
-

Boards

-

- {data.boards.length} board{data.boards.length === 1 ? '' : 's'} available -

-
+
+
+
+
+

Boards

+

+ {data.boards.length} board{data.boards.length === 1 ? '' : 's'} available +

+
- {#if !data.isGuest && data.user?.role === 'admin'} - - New Board - - {/if} -
- - {#if data.boards.length === 0} -
-

No boards available.

- {#if data.isGuest} -

Sign in to see more boards.

+ {#if !data.isGuest && data.user?.role === 'admin'} + + New Board + {/if}
- {:else} -
- {#each data.boards as board (board.id)} - - {/each} -
- {/if} + + {#if data.boards.length === 0} +
+ + + + + +

No boards available.

+ {#if data.isGuest} +

Sign in to see more boards.

+ {/if} +
+ {:else} +
+ {#each data.boards as board (board.id)} + + {/each} +
+ {/if} +
diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte index adb4979..0848fa7 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -7,17 +7,19 @@ - {data.board.name} + {data.board.name} — Web App Launcher -
- +
+
+ - + +
diff --git a/src/routes/boards/[boardId]/edit/+page.svelte b/src/routes/boards/[boardId]/edit/+page.svelte index cdeebac..39d42c6 100644 --- a/src/routes/boards/[boardId]/edit/+page.svelte +++ b/src/routes/boards/[boardId]/edit/+page.svelte @@ -12,252 +12,254 @@ Edit: {data.board.name} -
-
-

Edit Board

- - Back to Board - -
- - -
-

Board Properties

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
- - -
-
-

Sections

- + Back to Board +
- {#if showAddSection} -
-
{ - return async ({ update }) => { - await update(); - showAddSection = false; - }; - }} + +
+

Board Properties

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + +
+
+

Sections

+ -
- + {showAddSection ? 'Cancel' : 'Add Section'} +
- {/if} - {#if data.board.sections.length === 0} -
-

No sections yet. Add one to get started.

-
- {:else} -
- {#each data.board.sections as section (section.id)} -
-
-
- {section.title} - Order: {section.order} - {#if section.icon} - ({section.icon}) - {/if} + {#if showAddSection} +
+
{ + return async ({ update }) => { + await update(); + showAddSection = false; + }; + }} + > +
+
+ +
-
- - - +
+ + +
+
+
+ +
+ +
+ {/if} + + {#if data.board.sections.length === 0} +
+

No sections yet. Add one to get started.

+
+ {:else} +
+ {#each data.board.sections as section (section.id)} +
+
+
+ {section.title} + Order: {section.order} + {#if section.icon} + ({section.icon}) + {/if} +
+
- -
-
- - {#if addWidgetSectionId === section.id} -
-
{ - return async ({ update }) => { - await update(); - addWidgetSectionId = null; - }; - }} - > - - -
- - -
-
+ + -
-
+ +
- {/if} - - {#if section.widgets.length === 0} -

No widgets in this section.

- {:else} -
- {#each section.widgets as widget (widget.id)} -
-
- {widget.type} - {#if widget.app} - {widget.app.name} - ({widget.app.url}) - {:else} - Widget #{widget.order} - {/if} + {#if addWidgetSectionId === section.id} +
+
{ + return async ({ update }) => { + await update(); + addWidgetSectionId = null; + }; + }} + > + + +
+ +
- - +
- -
- {/each} -
- {/if} -
- {/each} -
- {/if} -
+
+ +
+ {/if} + + + {#if section.widgets.length === 0} +

No widgets in this section.

+ {:else} +
+ {#each section.widgets as widget (widget.id)} +
+
+ {widget.type} + {#if widget.app} + {widget.app.name} + ({widget.app.url}) + {:else} + Widget #{widget.order} + {/if} +
+
+ + +
+
+ {/each} +
+ {/if} +
+ {/each} +
+ {/if} + +
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index c68604a..158518a 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,6 +1,7 @@