feat(mvp): phase 7 - UI polish & ambient backgrounds

Add layout system (sidebar, header, main layout), dark/light/system theme
with HSL customization, 3 ambient backgrounds (mesh gradient, particle field,
aurora), Cmd/Ctrl+K search dialog, page transitions, card hover effects,
status pulse animations, skeleton loaders, and responsive design. Polish
all existing pages with consistent theming.
This commit is contained in:
2026-03-24 21:37:16 +03:00
parent c5166ba3a9
commit 0bd30c5e17
41 changed files with 2106 additions and 391 deletions
+2
View File
@@ -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 `<html>`), `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).
+2 -2
View File
@@ -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
+50 -29
View File
@@ -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
<!-- Filled in by the implementation agent after completing this 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 `<html>`), `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).
+119 -10
View File
@@ -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%;
}
}
+14
View File
@@ -4,6 +4,20 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// Inline script to prevent FOUC — set theme class before first paint
(function () {
try {
var mode = localStorage.getItem('wal-theme-mode') || 'system';
if (mode === 'system') {
mode = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
document.documentElement.className = mode;
} catch (e) {}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+2 -2
View File
@@ -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}
>
<div class="mb-3 flex items-start justify-between">
@@ -67,7 +67,7 @@
<AppHealthBadge status={currentStatus} />
</div>
<h3 class="truncate text-sm font-semibold text-card-foreground group-hover:text-primary">
<h3 class="truncate text-sm font-semibold text-card-foreground transition-colors group-hover:text-primary">
{app.name}
</h3>
+5 -5
View File
@@ -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' };
}
});
</script>
<span class="inline-flex items-center gap-1.5 text-xs">
<span class="inline-block h-2 w-2 rounded-full {config.color}"></span>
<span class="inline-block h-2 w-2 rounded-full {config.color} {config.cssClass}"></span>
<span class="text-muted-foreground">{config.text}</span>
</span>
@@ -0,0 +1,18 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
import MeshGradient from './MeshGradient.svelte';
import ParticleField from './ParticleField.svelte';
import AuroraEffect from './AuroraEffect.svelte';
</script>
{#if theme.backgroundType !== 'none'}
<div class="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden="true">
{#if theme.backgroundType === 'mesh'}
<MeshGradient />
{:else if theme.backgroundType === 'particles'}
<ParticleField />
{:else if theme.backgroundType === 'aurora'}
<AuroraEffect />
{/if}
</div>
{/if}
@@ -0,0 +1,62 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
const hue = $derived(theme.primaryHue);
const sat = $derived(theme.primarySaturation);
const isDark = $derived(theme.isDark);
</script>
<div class="absolute inset-0 overflow-hidden">
<!-- First aurora band -->
<div
class="absolute -top-1/4 left-0 h-3/4 w-full opacity-[0.08]"
style="
background: linear-gradient(
180deg,
transparent 0%,
hsla({hue}, {sat}%, {isDark ? 60 : 50}%, 0.6) 30%,
hsla({hue + 40}, {sat}%, {isDark ? 50 : 40}%, 0.4) 60%,
transparent 100%
);
background-size: 400% 100%;
animation: aurora-shift 15s ease-in-out infinite;
filter: blur(40px);
transform: skewY(-5deg);
"
></div>
<!-- Second aurora band -->
<div
class="absolute -top-1/3 left-0 h-3/4 w-full opacity-[0.06]"
style="
background: linear-gradient(
180deg,
transparent 0%,
hsla({hue + 80}, {sat * 0.8}%, {isDark ? 55 : 45}%, 0.5) 35%,
hsla({hue + 120}, {sat * 0.6}%, {isDark ? 45 : 35}%, 0.3) 65%,
transparent 100%
);
background-size: 300% 100%;
animation: aurora-shift 20s ease-in-out infinite reverse;
filter: blur(50px);
transform: skewY(3deg);
"
></div>
<!-- Third aurora band -->
<div
class="absolute top-0 left-0 h-1/2 w-full opacity-[0.04]"
style="
background: linear-gradient(
180deg,
transparent 0%,
hsla({hue - 30}, {sat}%, {isDark ? 65 : 55}%, 0.4) 40%,
transparent 100%
);
background-size: 250% 100%;
animation: aurora-shift 12s ease-in-out infinite;
filter: blur(60px);
transform: skewY(-8deg);
"
></div>
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
interface Blob {
x: number;
y: number;
vx: number;
vy: number;
hueOffset: number;
size: number;
}
const blobCount = 4;
let blobs = $state<Blob[]>([]);
let animFrame: number;
function initBlobs(): Blob[] {
return Array.from({ length: blobCount }, (_, i) => ({
x: 20 + Math.random() * 60,
y: 20 + Math.random() * 60,
vx: (Math.random() - 0.5) * 0.02,
vy: (Math.random() - 0.5) * 0.02,
hueOffset: i * 40,
size: 35 + Math.random() * 20
}));
}
function animate() {
blobs = blobs.map((blob) => {
let { x, y, vx, vy } = blob;
x += vx;
y += vy;
if (x < 5 || x > 95) vx = -vx;
if (y < 5 || y > 95) vy = -vy;
return { ...blob, x, y, vx, vy };
});
animFrame = requestAnimationFrame(animate);
}
$effect(() => {
blobs = initBlobs();
animFrame = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(animFrame);
};
});
</script>
<div class="absolute inset-0">
<svg class="h-full w-full" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="mesh-blur">
<feGaussianBlur stdDeviation="60" />
</filter>
</defs>
{#each blobs as blob, i}
<circle
cx="{blob.x}%"
cy="{blob.y}%"
r="{blob.size}%"
fill="hsla({theme.primaryHue + blob.hueOffset}, {theme.primarySaturation}%, {theme.isDark ? 40 : 60}%, 0.12)"
filter="url(#mesh-blur)"
/>
{/each}
</svg>
</div>
@@ -0,0 +1,110 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
let canvas: HTMLCanvasElement;
let animFrame: number;
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
}
const PARTICLE_COUNT = 70;
const CONNECTION_DISTANCE = 120;
let particles: Particle[] = [];
function initParticles(w: number, h: number): Particle[] {
return Array.from({ length: PARTICLE_COUNT }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4,
radius: 1.5 + Math.random() * 1.5
}));
}
function drawFrame(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.clearRect(0, 0, w, h);
const hue = theme.primaryHue;
const sat = theme.primarySaturation;
const isDark = theme.isDark;
const lightness = isDark ? 70 : 40;
const baseAlpha = isDark ? 0.35 : 0.25;
// Update positions
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx = -p.vx;
if (p.y < 0 || p.y > h) p.vy = -p.vy;
}
// Draw connections
ctx.strokeStyle = `hsla(${hue}, ${sat}%, ${lightness}%, ${baseAlpha * 0.3})`;
ctx.lineWidth = 0.5;
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < CONNECTION_DISTANCE) {
const alpha = (1 - dist / CONNECTION_DISTANCE) * baseAlpha * 0.4;
ctx.strokeStyle = `hsla(${hue}, ${sat}%, ${lightness}%, ${alpha})`;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
}
}
}
// Draw particles
for (const p of particles) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, ${sat}%, ${lightness}%, ${baseAlpha})`;
ctx.fill();
}
animFrame = requestAnimationFrame(() => drawFrame(ctx, w, h));
}
$effect(() => {
if (!canvas) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
canvas.width = width;
canvas.height = height;
particles = initParticles(width, height);
}
});
resizeObserver.observe(canvas.parentElement!);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.parentElement!.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
particles = initParticles(canvas.width, canvas.height);
animFrame = requestAnimationFrame(() => drawFrame(ctx, canvas.width, canvas.height));
return () => {
cancelAnimationFrame(animFrame);
resizeObserver.disconnect();
};
});
</script>
<canvas bind:this={canvas} class="absolute inset-0 h-full w-full"></canvas>
+2 -2
View File
@@ -34,8 +34,8 @@
<div class="space-y-6">
{#if sections.length === 0}
<div class="rounded-lg border border-gray-700 bg-gray-800/50 p-12 text-center">
<p class="text-gray-400">This board has no sections yet.</p>
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
<p class="text-muted-foreground">This board has no sections yet.</p>
</div>
{:else}
{#each sections as section (section.id)}
+7 -7
View File
@@ -20,36 +20,36 @@
<a
href="/boards/{board.id}"
class="group block rounded-lg border border-gray-700 bg-gray-800/50 p-5 transition-colors hover:border-indigo-500/50 hover:bg-gray-800"
class="card-hover group block rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/50"
>
<div class="flex items-start gap-3">
{#if board.icon}
<span class="text-xl">{board.icon}</span>
{:else}
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-gray-700 text-sm text-gray-400">
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-sm text-muted-foreground">
B
</span>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate font-semibold text-white group-hover:text-indigo-300 transition-colors">
<h3 class="truncate font-semibold text-foreground transition-colors group-hover:text-primary">
{board.name}
</h3>
{#if board.isDefault}
<span class="shrink-0 rounded bg-indigo-600/20 px-1.5 py-0.5 text-xs text-indigo-400">
<span class="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-xs text-primary">
Default
</span>
{/if}
{#if board.isGuestAccessible}
<span class="shrink-0 rounded bg-green-600/20 px-1.5 py-0.5 text-xs text-green-400">
<span class="shrink-0 rounded bg-accent px-1.5 py-0.5 text-xs text-accent-foreground">
Guest
</span>
{/if}
</div>
{#if board.description}
<p class="mt-1 line-clamp-2 text-sm text-gray-400">{board.description}</p>
<p class="mt-1 line-clamp-2 text-sm text-muted-foreground">{board.description}</p>
{/if}
<p class="mt-2 text-xs text-gray-500">
<p class="mt-2 text-xs text-muted-foreground/70">
{sectionCount} section{sectionCount === 1 ? '' : 's'}
</p>
</div>
+4 -4
View File
@@ -16,9 +16,9 @@
<span class="text-2xl">{icon}</span>
{/if}
<div>
<h1 class="text-3xl font-bold text-white">{name}</h1>
<h1 class="text-3xl font-bold text-foreground">{name}</h1>
{#if description}
<p class="mt-1 text-sm text-gray-400">{description}</p>
<p class="mt-1 text-sm text-muted-foreground">{description}</p>
{/if}
</div>
</div>
@@ -26,14 +26,14 @@
<div class="flex items-center gap-2">
<a
href="/boards"
class="rounded-lg bg-gray-700 px-3 py-2 text-sm text-gray-200 hover:bg-gray-600 transition-colors"
class="rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
All Boards
</a>
{#if canEdit}
<a
href="/boards/{boardId}/edit"
class="rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Edit
</a>
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
import ThemeToggle from './ThemeToggle.svelte';
import SearchTrigger from '$lib/components/search/SearchTrigger.svelte';
import { ui } from '$lib/stores/ui.svelte.js';
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
interface Props {
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
}
let { user }: Props = $props();
let showUserMenu = $state(false);
let showBgMenu = $state(false);
const bgOptions: { value: BackgroundType; label: string }[] = [
{ value: 'mesh', label: 'Mesh Gradient' },
{ value: 'particles', label: 'Particles' },
{ value: 'aurora', label: 'Aurora' },
{ value: 'none', label: 'None' }
];
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.user-menu-container')) {
showUserMenu = false;
}
if (!target.closest('.bg-menu-container')) {
showBgMenu = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<header
class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-sm"
>
<!-- Mobile hamburger -->
{#if ui.isMobile}
<button
type="button"
onclick={() => ui.toggleSidebar()}
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Toggle sidebar"
>
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="4" y1="6" x2="20" y2="6" />
<line x1="4" y1="12" x2="20" y2="12" />
<line x1="4" y1="18" x2="20" y2="18" />
</svg>
</button>
{/if}
<!-- Search -->
<div class="flex-1">
<SearchTrigger />
</div>
<!-- Background selector -->
<div class="bg-menu-container relative">
<button
type="button"
onclick={() => (showBgMenu = !showBgMenu)}
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Background effect"
aria-label="Change background effect"
>
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
{#if showBgMenu}
<div
class="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
>
{#each bgOptions as opt}
<button
type="button"
onclick={() => {
theme.setBackground(opt.value);
showBgMenu = false;
}}
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors {theme.backgroundType === opt.value
? 'bg-accent text-accent-foreground'
: 'text-popover-foreground hover:bg-accent/50'}"
>
{#if theme.backgroundType === opt.value}
<svg
class="h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{:else}
<span class="h-3 w-3"></span>
{/if}
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
<!-- Theme toggle -->
<ThemeToggle />
<!-- User menu -->
{#if user}
<div class="user-menu-container relative">
<button
type="button"
onclick={() => (showUserMenu = !showUserMenu)}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground"
>
{user.displayName.charAt(0).toUpperCase()}
</span>
{#if !ui.isMobile}
<span class="max-w-[120px] truncate text-sm">{user.displayName}</span>
{/if}
</button>
{#if showUserMenu}
<div
class="absolute right-0 top-full mt-1 w-48 rounded-md border border-border bg-popover p-1 shadow-lg"
>
<div class="border-b border-border px-3 py-2">
<p class="text-sm font-medium text-popover-foreground">{user.displayName}</p>
<p class="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<form method="POST" action="/auth/logout">
<button
type="submit"
class="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
>
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Sign Out
</button>
</form>
</div>
{/if}
</div>
{:else}
<a
href="/login"
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Sign In
</a>
{/if}
</header>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Sidebar from './Sidebar.svelte';
import Header from './Header.svelte';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import SearchDialog from '$lib/components/search/SearchDialog.svelte';
import { ui } from '$lib/stores/ui.svelte.js';
interface BoardLink {
id: string;
name: string;
icon: string | null;
}
interface UserInfo {
displayName: string;
email: string;
role: string;
avatarUrl?: string | null;
}
interface Props {
user: UserInfo | null;
boards: BoardLink[];
children: Snippet;
}
let { user, boards, children }: Props = $props();
const isAdmin = $derived(user?.role === 'admin');
</script>
<!-- Ambient Background (fixed, behind everything) -->
<AmbientBackground />
<div class="relative z-10 flex h-screen overflow-hidden">
<!-- Mobile overlay -->
{#if ui.isMobile && !ui.sidebarHidden}
<button
type="button"
class="fixed inset-0 z-30 bg-black/50"
onclick={() => ui.closeMobileSidebar()}
aria-label="Close sidebar"
></button>
{/if}
<!-- Sidebar -->
{#if !ui.sidebarHidden || !ui.isMobile}
<div
class="shrink-0 {ui.isMobile ? 'fixed left-0 top-0 z-40 h-full' : 'relative'}"
>
<Sidebar {boards} {isAdmin} collapsed={ui.isMobile ? false : ui.sidebarCollapsed} />
</div>
{/if}
<!-- Main content area -->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<Header {user} />
<main class="flex-1 overflow-y-auto">
{@render children()}
</main>
</div>
</div>
<!-- Search Dialog (modal, z-50) -->
<SearchDialog />
+233
View File
@@ -0,0 +1,233 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte.js';
import { page } from '$app/stores';
interface BoardLink {
id: string;
name: string;
icon: string | null;
}
interface Props {
boards: BoardLink[];
isAdmin: boolean;
collapsed: boolean;
}
let { boards, isAdmin, collapsed }: Props = $props();
function isActive(path: string): boolean {
return $page.url.pathname.startsWith(path);
}
</script>
<aside
class="flex h-full flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200"
class:w-64={!collapsed}
class:w-16={collapsed}
>
<!-- Brand -->
<div class="flex h-14 items-center border-b border-sidebar-border px-4">
{#if !collapsed}
<a href="/" class="flex items-center gap-2 text-sidebar-foreground">
<svg
class="h-6 w-6 text-sidebar-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
<span class="text-sm font-semibold">App Launcher</span>
</a>
{:else}
<a href="/" class="mx-auto text-sidebar-primary" title="App Launcher">
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</a>
{/if}
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-2 py-3">
<!-- Main Links -->
<div class="mb-3">
{#if !collapsed}
<p class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50">
Navigation
</p>
{/if}
<a
href="/boards"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/boards')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
title={collapsed ? 'Boards' : undefined}
>
<svg
class="h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
{#if !collapsed}<span>Boards</span>{/if}
</a>
<a
href="/apps"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/apps')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
title={collapsed ? 'Apps' : undefined}
>
<svg
class="h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
</svg>
{#if !collapsed}<span>Apps</span>{/if}
</a>
</div>
<!-- Board List -->
{#if boards.length > 0}
<div class="mb-3">
{#if !collapsed}
<p
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
>
Boards
</p>
{/if}
{#each boards as board (board.id)}
<a
href="/boards/{board.id}"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors {isActive(`/boards/${board.id}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
title={collapsed ? board.name : undefined}
onclick={() => ui.closeMobileSidebar()}
>
{#if board.icon}
<span class="shrink-0 text-base">{board.icon}</span>
{:else}
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-sidebar-accent text-[10px] font-medium text-sidebar-foreground"
>
{board.name.charAt(0).toUpperCase()}
</span>
{/if}
{#if !collapsed}
<span class="truncate">{board.name}</span>
{/if}
</a>
{/each}
</div>
{/if}
<!-- Admin -->
{#if isAdmin}
<div class="mt-auto border-t border-sidebar-border pt-3">
{#if !collapsed}
<p
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
>
Admin
</p>
{/if}
<a
href="/admin/users"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/admin')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
title={collapsed ? 'Admin Panel' : undefined}
>
<svg
class="h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
{#if !collapsed}<span>Admin Panel</span>{/if}
</a>
</div>
{/if}
</nav>
<!-- Collapse Toggle (desktop only) -->
{#if !ui.isMobile}
<div class="border-t border-sidebar-border p-2">
<button
type="button"
onclick={() => ui.toggleSidebar()}
class="flex w-full items-center justify-center rounded-md p-2 text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<svg
class="h-4 w-4 transition-transform duration-200"
class:rotate-180={collapsed}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
{/if}
</aside>
@@ -0,0 +1,41 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
const modeIcons: Record<string, { path: string; label: string }> = {
light: {
path: 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z',
label: 'Light'
},
dark: {
path: 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z',
label: 'Dark'
},
system: {
path: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
label: 'System'
}
};
const currentIcon = $derived(modeIcons[theme.mode]);
</script>
<button
type="button"
onclick={() => theme.cycleMode()}
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
title="Theme: {currentIcon.label}"
aria-label="Toggle theme (current: {currentIcon.label})"
>
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d={currentIcon.path} />
</svg>
</button>
@@ -0,0 +1,111 @@
<script lang="ts">
import { search } from '$lib/stores/search.svelte.js';
import SearchResult from './SearchResult.svelte';
let inputEl: HTMLInputElement;
const appResults = $derived(search.results.filter((r) => r.type === 'app'));
const boardResults = $derived(search.results.filter((r) => r.type === 'board'));
$effect(() => {
if (search.open && inputEl) {
// Focus input when dialog opens
requestAnimationFrame(() => inputEl?.focus());
}
});
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
search.close();
}
}
</script>
{#if search.open}
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh] backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && search.close()}
>
<!-- Dialog -->
<div
class="w-full max-w-lg rounded-lg border border-border bg-popover shadow-2xl"
role="dialog"
aria-label="Search"
>
<!-- Input -->
<div class="flex items-center gap-2 border-b border-border px-4 py-3">
<svg
class="h-5 w-5 shrink-0 text-muted-foreground"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
bind:this={inputEl}
bind:value={search.query}
type="text"
placeholder="Search apps and boards..."
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
<kbd
class="hidden rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
>
ESC
</kbd>
</div>
<!-- Results -->
<div class="max-h-[50vh] overflow-y-auto p-2">
{#if search.loading}
<div class="flex items-center justify-center py-8">
<div
class="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-primary"
></div>
</div>
{:else if search.error}
<p class="py-6 text-center text-sm text-destructive">{search.error}</p>
{:else if search.query.length < 2}
<p class="py-6 text-center text-sm text-muted-foreground">
Type at least 2 characters to search
</p>
{:else if search.results.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">
No results for "{search.query}"
</p>
{:else}
{#if appResults.length > 0}
<div class="mb-2">
<p class="mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Apps
</p>
{#each appResults as result (result.id)}
<SearchResult {result} onselect={() => search.close()} />
{/each}
</div>
{/if}
{#if boardResults.length > 0}
<div>
<p class="mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Boards
</p>
{#each boardResults as result (result.id)}
<SearchResult {result} onselect={() => search.close()} />
{/each}
</div>
{/if}
{/if}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,79 @@
<script lang="ts">
import type { SearchResultItem } from '$lib/stores/search.svelte.js';
interface Props {
result: SearchResultItem;
onselect: () => void;
}
let { result, onselect }: Props = $props();
const href = $derived(result.type === 'app' ? result.url : `/boards/${result.id}`);
const isExternal = $derived(result.type === 'app');
</script>
<a
{href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
onclick={onselect}
class="flex items-center gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-accent"
>
<!-- Icon -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
>
{#if result.icon}
<span class="text-lg">{result.icon}</span>
{:else if result.type === 'app'}
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
</svg>
{:else}
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
{/if}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{result.name}</p>
{#if result.description}
<p class="truncate text-xs text-muted-foreground">{result.description}</p>
{/if}
</div>
<!-- Type badge -->
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase {result.type === 'app'
? 'bg-primary/10 text-primary'
: 'bg-accent text-accent-foreground'}"
>
{result.type}
</span>
</a>
@@ -0,0 +1,33 @@
<script lang="ts">
import { search } from '$lib/stores/search.svelte.js';
const isMac = $derived(
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('mac')
);
</script>
<button
type="button"
onclick={() => search.toggle()}
class="flex w-full max-w-sm items-center gap-2 rounded-md border border-input bg-background/50 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg
class="h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span class="flex-1 text-left">Search...</span>
<kbd
class="hidden rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
>
{isMac ? '⌘' : 'Ctrl'}K
</kbd>
</button>
+1 -1
View File
@@ -38,7 +38,7 @@
let expanded = $state(section.isExpandedByDefault);
</script>
<div class="rounded-lg border border-gray-700 bg-gray-800/30">
<div class="rounded-xl border border-border bg-card/30 shadow-sm">
<SectionHeader
title={section.title}
icon={section.icon}
@@ -12,10 +12,10 @@
<button
type="button"
onclick={onToggle}
class="flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-gray-700/30"
class="flex w-full items-center gap-2 rounded-t-xl px-4 py-3 text-left transition-colors hover:bg-accent/30"
>
<svg
class="h-4 w-4 shrink-0 text-gray-400 transition-transform duration-200"
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
class:rotate-90={expanded}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
@@ -32,5 +32,5 @@
<span class="text-base">{icon}</span>
{/if}
<span class="font-medium text-white">{title}</span>
<span class="font-medium text-foreground">{title}</span>
</button>
@@ -0,0 +1,22 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 3 }: Props = $props();
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#each items as i (i)}
<div class="rounded-lg border border-border bg-card p-5">
<div class="flex items-start gap-3">
<div class="skeleton h-8 w-8 rounded-md"></div>
<div class="min-w-0 flex-1">
<div class="skeleton mb-2 h-5 w-1/2 rounded"></div>
<div class="skeleton mb-1 h-3 w-full rounded"></div>
<div class="skeleton mt-2 h-3 w-20 rounded"></div>
</div>
</div>
</div>
{/each}
@@ -0,0 +1,21 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 1 }: Props = $props();
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#each items as i (i)}
<div class="rounded-lg border border-border bg-card p-4">
<div class="mb-3 flex items-start justify-between">
<div class="skeleton h-10 w-10 rounded-lg"></div>
<div class="skeleton h-5 w-14 rounded-full"></div>
</div>
<div class="skeleton mb-2 h-4 w-3/4 rounded"></div>
<div class="skeleton h-3 w-full rounded"></div>
<div class="skeleton mt-1 h-3 w-1/2 rounded"></div>
</div>
{/each}
@@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
count?: number;
widgetsPerSection?: number;
}
let { count = 2, widgetsPerSection = 4 }: Props = $props();
const sections = $derived(Array.from({ length: count }, (_, i) => i));
const widgets = $derived(Array.from({ length: widgetsPerSection }, (_, i) => i));
</script>
{#each sections as s (s)}
<div class="rounded-lg border border-border bg-card/50">
<!-- Section header skeleton -->
<div class="flex items-center gap-2 px-4 py-3">
<div class="skeleton h-4 w-4 rounded"></div>
<div class="skeleton h-4 w-32 rounded"></div>
</div>
<!-- Widget grid skeleton -->
<div class="grid grid-cols-2 gap-3 px-4 pb-4 sm:grid-cols-3 lg:grid-cols-4">
{#each widgets as w (w)}
<div class="flex flex-col items-center gap-2 rounded-lg border border-border bg-card p-4">
<div class="skeleton h-12 w-12 rounded-lg"></div>
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-4 w-12 rounded-full"></div>
</div>
{/each}
</div>
</div>
{/each}
+4 -4
View File
@@ -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"
>
<!-- Icon -->
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-gray-700 transition-colors group-hover:bg-gray-600">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-2xl">{app.icon}</span>
{:else if iconSrc}
@@ -52,14 +52,14 @@
class="h-8 w-8 object-contain"
/>
{:else}
<span class="text-lg font-bold text-gray-400">
<span class="text-lg font-bold text-muted-foreground">
{app.name.charAt(0).toUpperCase()}
</span>
{/if}
</div>
<!-- Name -->
<span class="text-sm font-medium text-white group-hover:text-indigo-300 transition-colors truncate w-full">
<span class="w-full truncate text-sm font-medium text-foreground transition-colors group-hover:text-primary">
{app.name}
</span>
+3 -3
View File
@@ -27,7 +27,7 @@
</script>
{#if widgets.length === 0}
<p class="text-sm text-gray-500">No widgets in this section.</p>
<p class="text-sm text-muted-foreground">No widgets in this section.</p>
{:else}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{#each widgets as widget (widget.id)}
@@ -35,8 +35,8 @@
{#if widget.type === 'app' && widget.app}
<AppWidget app={widget.app} />
{:else}
<div class="flex h-full items-center justify-center rounded-lg border border-gray-700 bg-gray-800/50 p-4">
<span class="text-xs text-gray-500">{widget.type} widget</span>
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<span class="text-xs text-muted-foreground">{widget.type} widget</span>
</div>
{/if}
</WidgetContainer>
+122
View File
@@ -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<SearchResultItem[]>([]);
loading = $state(false);
error = $state<string | null>(null);
#debounceTimer: ReturnType<typeof setTimeout> | 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();
+120
View File
@@ -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<T>(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<ThemeMode>('system');
primaryHue = $state(220);
primarySaturation = $state(70);
backgroundType = $state<BackgroundType>('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<ThemeMode>(THEME_STORAGE_KEY, 'system');
this.primaryHue = getStoredNumber(PRIMARY_HUE_KEY, 220);
this.primarySaturation = getStoredNumber(PRIMARY_SAT_KEY, 70);
this.backgroundType = getStoredValue<BackgroundType>(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();
+76
View File
@@ -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();
+34 -1
View File
@@ -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
};
};
+27 -3
View File
@@ -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);
</script>
<div class="dark min-h-screen">
{@render children()}
</div>
{#if showLayout}
<MainLayout
user={data.user ?? null}
boards={data.sidebarBoards ?? []}
>
{#key pageKey}
<div in:fade={{ duration: 150, delay: 75 }} out:fade={{ duration: 75 }}>
{@render children()}
</div>
{/key}
</MainLayout>
{:else}
{#key pageKey}
<div in:fade={{ duration: 150, delay: 75 }} out:fade={{ duration: 75 }}>
{@render children()}
</div>
{/key}
{/if}
+16 -10
View File
@@ -8,21 +8,27 @@
<title>Web App Launcher</title>
</svelte:head>
<main class="flex min-h-screen items-center justify-center bg-background text-foreground">
<div class="flex min-h-[60vh] items-center justify-center p-6">
<div class="text-center">
<h1 class="text-4xl font-bold">Web App Launcher</h1>
<h1 class="text-4xl font-bold text-foreground">Web App Launcher</h1>
{#if data.user}
<p class="mt-4 text-muted-foreground">
Welcome, {data.user.displayName}. No default board is configured yet.
</p>
<form method="POST" action="/auth/logout" class="mt-6">
<button
type="submit"
class="rounded-md bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground hover:bg-secondary/80"
<div class="mt-6 flex items-center justify-center gap-3">
<a
href="/boards"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Sign Out
</button>
</form>
View Boards
</a>
<a
href="/apps"
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent"
>
Browse Apps
</a>
</div>
{/if}
</div>
</main>
</div>
+16 -12
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types.js';
import { page } from '$app/stores';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
@@ -9,20 +10,24 @@
{ href: '/admin/groups', label: 'Groups' },
{ href: '/admin/settings', label: 'Settings' }
] as const;
function isActive(href: string): boolean {
return $page.url.pathname === href;
}
</script>
<div class="min-h-screen bg-background text-foreground">
<nav class="border-b border-border bg-card">
<div class="mx-auto flex max-w-6xl items-center gap-6 px-6 py-3">
<a href="/" class="text-sm text-muted-foreground hover:text-foreground">
&larr; Back to Dashboard
</a>
<span class="text-sm font-semibold text-card-foreground">Admin Panel</span>
<div class="flex gap-4">
<div class="p-6">
<div class="mx-auto max-w-6xl">
<!-- Admin header -->
<div class="mb-6 flex flex-wrap items-center gap-4 rounded-xl border border-border bg-card p-4 shadow-sm">
<span class="text-sm font-semibold text-foreground">Admin Panel</span>
<div class="flex gap-1">
{#each navItems as item}
<a
href={item.href}
class="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {isActive(item.href)
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'}"
>
{item.label}
</a>
@@ -32,8 +37,7 @@
{data.user.displayName} (admin)
</div>
</div>
</nav>
<main class="mx-auto max-w-6xl p-6">
{@render children()}
</main>
</div>
</div>
+28 -8
View File
@@ -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 @@
<title>Apps — Web App Launcher</title>
</svelte:head>
<main class="min-h-screen bg-background p-6 text-foreground">
<div class="p-6">
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">App Registry</h1>
<div>
<h1 class="text-2xl font-bold text-foreground">App Registry</h1>
<p class="mt-1 text-sm text-muted-foreground">
{data.apps.length} app{data.apps.length === 1 ? '' : 's'} registered
</p>
</div>
<button
type="button"
onclick={() => (showForm = !showForm)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
>
{showForm ? 'Cancel' : 'Add App'}
</button>
</div>
{#if showForm}
<div class="mb-6 rounded-lg border border-border bg-card p-6">
<div class="mb-6 rounded-xl border border-border bg-card p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">New App</h2>
<AppForm form={data.form} action="?/create" />
</div>
@@ -36,14 +42,14 @@
<div class="mb-4 flex flex-wrap gap-2">
<a
href="/apps"
class="rounded-full border border-border px-3 py-1 text-sm text-muted-foreground hover:bg-accent"
class="rounded-full border border-border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
All
</a>
{#each data.categories as category}
<a
href="/apps?category={encodeURIComponent(category)}"
class="rounded-full border border-border px-3 py-1 text-sm text-muted-foreground hover:bg-accent"
class="rounded-full border border-border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{category}
</a>
@@ -52,7 +58,21 @@
{/if}
{#if data.apps.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-muted-foreground">
<div class="flex flex-col items-center justify-center rounded-xl border border-border bg-card/50 py-16 text-muted-foreground">
<svg
class="mb-3 h-12 w-12 text-muted-foreground/40"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-lg">No apps registered yet.</p>
<p class="mt-1 text-sm">Click "Add App" to register your first application.</p>
</div>
@@ -64,4 +84,4 @@
</div>
{/if}
</div>
</main>
</div>
+47 -31
View File
@@ -6,40 +6,56 @@
</script>
<svelte:head>
<title>Boards</title>
<title>Boards — Web App Launcher</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-4 py-8">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Boards</h1>
<p class="mt-1 text-sm text-gray-400">
{data.boards.length} board{data.boards.length === 1 ? '' : 's'} available
</p>
</div>
<div class="p-6">
<div class="mx-auto max-w-6xl">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-foreground">Boards</h1>
<p class="mt-1 text-sm text-muted-foreground">
{data.boards.length} board{data.boards.length === 1 ? '' : 's'} available
</p>
</div>
{#if !data.isGuest && data.user?.role === 'admin'}
<a
href="/boards/new"
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
>
New Board
</a>
{/if}
</div>
{#if data.boards.length === 0}
<div class="rounded-lg border border-gray-700 bg-gray-800/50 p-12 text-center">
<p class="text-gray-400">No boards available.</p>
{#if data.isGuest}
<p class="mt-2 text-sm text-gray-500">Sign in to see more boards.</p>
{#if !data.isGuest && data.user?.role === 'admin'}
<a
href="/boards/new"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
New Board
</a>
{/if}
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.boards as board (board.id)}
<BoardCard {board} />
{/each}
</div>
{/if}
{#if data.boards.length === 0}
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
<svg
class="mx-auto mb-3 h-12 w-12 text-muted-foreground/40"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
<p class="text-muted-foreground">No boards available.</p>
{#if data.isGuest}
<p class="mt-2 text-sm text-muted-foreground/70">Sign in to see more boards.</p>
{/if}
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.boards as board (board.id)}
<BoardCard {board} />
{/each}
</div>
{/if}
</div>
</div>
+12 -10
View File
@@ -7,17 +7,19 @@
</script>
<svelte:head>
<title>{data.board.name}</title>
<title>{data.board.name} — Web App Launcher</title>
</svelte:head>
<div class="mx-auto max-w-7xl px-4 py-6">
<BoardHeader
name={data.board.name}
description={data.board.description}
icon={data.board.icon}
boardId={data.board.id}
canEdit={data.canEdit}
/>
<div class="p-6">
<div class="mx-auto max-w-7xl">
<BoardHeader
name={data.board.name}
description={data.board.description}
icon={data.board.icon}
boardId={data.board.id}
canEdit={data.canEdit}
/>
<Board sections={data.board.sections} />
<Board sections={data.board.sections} />
</div>
</div>
+229 -227
View File
@@ -12,252 +12,254 @@
<title>Edit: {data.board.name}</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-4 py-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">Edit Board</h1>
<a
href="/boards/{data.board.id}"
class="rounded-lg bg-gray-700 px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 transition-colors"
>
Back to Board
</a>
</div>
<!-- Board Properties -->
<section class="mb-8 rounded-lg border border-gray-700 bg-gray-800/50 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">Board Properties</h2>
<form method="POST" action="?/updateBoard" use:enhance>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="board-name" class="mb-1 block text-sm font-medium text-gray-300">Name</label>
<input
id="board-name"
name="name"
type="text"
value={data.board.name}
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none"
required
/>
</div>
<div>
<label for="board-icon" class="mb-1 block text-sm font-medium text-gray-300">Icon</label>
<input
id="board-icon"
name="icon"
type="text"
value={data.board.icon ?? ''}
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none"
placeholder="e.g. layout-dashboard"
/>
</div>
<div class="sm:col-span-2">
<label for="board-desc" class="mb-1 block text-sm font-medium text-gray-300">Description</label>
<textarea
id="board-desc"
name="description"
rows="2"
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none"
>{data.board.description ?? ''}</textarea>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-gray-300">
<input
type="checkbox"
name="isDefault"
checked={data.board.isDefault}
class="rounded border-gray-600 bg-gray-700"
/>
Default Board
</label>
<label class="flex items-center gap-2 text-sm text-gray-300">
<input
type="checkbox"
name="isGuestAccessible"
checked={data.board.isGuestAccessible}
class="rounded border-gray-600 bg-gray-700"
/>
Guest Accessible
</label>
</div>
</div>
<div class="mt-4">
<button
type="submit"
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
>
Save Board
</button>
</div>
</form>
</section>
<!-- Sections -->
<section class="mb-8">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">Sections</h2>
<button
type="button"
onclick={() => (showAddSection = !showAddSection)}
class="rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
<div class="p-6">
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-foreground">Edit Board</h1>
<a
href="/boards/{data.board.id}"
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
{showAddSection ? 'Cancel' : 'Add Section'}
</button>
Back to Board
</a>
</div>
{#if showAddSection}
<div class="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-4">
<form
method="POST"
action="?/addSection"
use:enhance={() => {
return async ({ update }) => {
await update();
showAddSection = false;
};
}}
<!-- Board Properties -->
<section class="mb-8 rounded-xl border border-border bg-card p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Board Properties</h2>
<form method="POST" action="?/updateBoard" use:enhance>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="board-name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
<input
id="board-name"
name="name"
type="text"
value={data.board.name}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
required
/>
</div>
<div>
<label for="board-icon" class="mb-1 block text-sm font-medium text-foreground">Icon</label>
<input
id="board-icon"
name="icon"
type="text"
value={data.board.icon ?? ''}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="e.g. layout-dashboard"
/>
</div>
<div class="sm:col-span-2">
<label for="board-desc" class="mb-1 block text-sm font-medium text-foreground">Description</label>
<textarea
id="board-desc"
name="description"
rows="2"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
>{data.board.description ?? ''}</textarea>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
name="isDefault"
checked={data.board.isDefault}
class="h-4 w-4 rounded border-input accent-primary"
/>
Default Board
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
name="isGuestAccessible"
checked={data.board.isGuestAccessible}
class="h-4 w-4 rounded border-input accent-primary"
/>
Guest Accessible
</label>
</div>
</div>
<div class="mt-4">
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Save Board
</button>
</div>
</form>
</section>
<!-- Sections -->
<section class="mb-8">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-foreground">Sections</h2>
<button
type="button"
onclick={() => (showAddSection = !showAddSection)}
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="section-title" class="mb-1 block text-sm font-medium text-gray-300">Title</label>
<input
id="section-title"
name="title"
type="text"
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none"
required
/>
</div>
<div>
<label for="section-icon" class="mb-1 block text-sm font-medium text-gray-300">Icon</label>
<input
id="section-icon"
name="icon"
type="text"
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:border-indigo-500 focus:outline-none"
placeholder="Optional"
/>
</div>
</div>
<div class="mt-3">
<button
type="submit"
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-500 transition-colors"
>
Create Section
</button>
</div>
</form>
{showAddSection ? 'Cancel' : 'Add Section'}
</button>
</div>
{/if}
{#if data.board.sections.length === 0}
<div class="rounded-lg border border-gray-700 bg-gray-800/50 p-8 text-center">
<p class="text-gray-400">No sections yet. Add one to get started.</p>
</div>
{:else}
<div class="space-y-4">
{#each data.board.sections as section (section.id)}
<div class="rounded-lg border border-gray-700 bg-gray-800/50 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium text-white">{section.title}</span>
<span class="text-xs text-gray-400">Order: {section.order}</span>
{#if section.icon}
<span class="text-xs text-gray-500">({section.icon})</span>
{/if}
{#if showAddSection}
<div class="mb-4 rounded-xl border border-border bg-card p-4 shadow-sm">
<form
method="POST"
action="?/addSection"
use:enhance={() => {
return async ({ update }) => {
await update();
showAddSection = false;
};
}}
>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="section-title" class="mb-1 block text-sm font-medium text-foreground">Title</label>
<input
id="section-title"
name="title"
type="text"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
required
/>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => (addWidgetSectionId = addWidgetSectionId === section.id ? null : section.id)}
class="rounded bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-500 transition-colors"
>
Add Widget
</button>
<form method="POST" action="?/deleteSection" use:enhance>
<input type="hidden" name="sectionId" value={section.id} />
<div>
<label for="section-icon" class="mb-1 block text-sm font-medium text-foreground">Icon</label>
<input
id="section-icon"
name="icon"
type="text"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="Optional"
/>
</div>
</div>
<div class="mt-3">
<button
type="submit"
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Create Section
</button>
</div>
</form>
</div>
{/if}
{#if data.board.sections.length === 0}
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
<p class="text-muted-foreground">No sections yet. Add one to get started.</p>
</div>
{:else}
<div class="space-y-4">
{#each data.board.sections as section (section.id)}
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground">{section.title}</span>
<span class="text-xs text-muted-foreground">Order: {section.order}</span>
{#if section.icon}
<span class="text-xs text-muted-foreground">({section.icon})</span>
{/if}
</div>
<div class="flex items-center gap-2">
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-500 transition-colors"
type="button"
onclick={() => (addWidgetSectionId = addWidgetSectionId === section.id ? null : section.id)}
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Delete
Add Widget
</button>
</form>
</div>
</div>
{#if addWidgetSectionId === section.id}
<div class="mb-3 rounded border border-gray-600 bg-gray-700/50 p-3">
<form
method="POST"
action="?/addWidget"
use:enhance={() => {
return async ({ update }) => {
await update();
addWidgetSectionId = null;
};
}}
>
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="type" value="app" />
<div>
<label for="widget-app-{section.id}" class="mb-1 block text-sm font-medium text-gray-300">Select App</label>
<select
id="widget-app-{section.id}"
name="appId"
class="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-white focus:border-indigo-500 focus:outline-none"
required
>
<option value="">Choose an app...</option>
{#each data.apps as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</div>
<div class="mt-2">
<form method="POST" action="?/deleteSection" use:enhance>
<input type="hidden" name="sectionId" value={section.id} />
<button
type="submit"
class="rounded bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-500 transition-colors"
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Add
Delete
</button>
</div>
</form>
</form>
</div>
</div>
{/if}
<!-- Widgets list -->
{#if section.widgets.length === 0}
<p class="text-sm text-gray-500">No widgets in this section.</p>
{:else}
<div class="space-y-2">
{#each section.widgets as widget (widget.id)}
<div class="flex items-center justify-between rounded border border-gray-600 bg-gray-700/30 px-3 py-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-indigo-400 uppercase">{widget.type}</span>
{#if widget.app}
<span class="text-sm text-white">{widget.app.name}</span>
<span class="text-xs text-gray-400">({widget.app.url})</span>
{:else}
<span class="text-sm text-gray-400">Widget #{widget.order}</span>
{/if}
{#if addWidgetSectionId === section.id}
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
<form
method="POST"
action="?/addWidget"
use:enhance={() => {
return async ({ update }) => {
await update();
addWidgetSectionId = null;
};
}}
>
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="type" value="app" />
<div>
<label for="widget-app-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Select App</label>
<select
id="widget-app-{section.id}"
name="appId"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
required
>
<option value="">Choose an app...</option>
{#each data.apps as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</div>
<form method="POST" action="?/deleteWidget" use:enhance>
<input type="hidden" name="widgetId" value={widget.id} />
<div class="mt-2">
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-500 transition-colors"
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Remove
Add
</button>
</form>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</section>
</div>
</form>
</div>
{/if}
<!-- Widgets list -->
{#if section.widgets.length === 0}
<p class="text-sm text-muted-foreground">No widgets in this section.</p>
{:else}
<div class="space-y-2">
{#each section.widgets as widget (widget.id)}
<div class="flex items-center justify-between rounded-lg border border-border bg-background/50 px-3 py-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium uppercase text-primary">{widget.type}</span>
{#if widget.app}
<span class="text-sm text-foreground">{widget.app.name}</span>
<span class="text-xs text-muted-foreground">({widget.app.url})</span>
{:else}
<span class="text-sm text-muted-foreground">Widget #{widget.order}</span>
{/if}
</div>
<form method="POST" action="?/deleteWidget" use:enhance>
<input type="hidden" name="widgetId" value={widget.id} />
<button
type="submit"
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Remove
</button>
</form>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</section>
</div>
</div>
+34 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { superForm } from 'sveltekit-superforms';
import type { PageData } from './$types.js';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
let { data }: { data: PageData } = $props();
@@ -11,9 +12,31 @@
<title>Login — Web App Launcher</title>
</svelte:head>
<main class="flex min-h-screen items-center justify-center bg-background text-foreground">
<div class="w-full max-w-md rounded-lg border border-border bg-card p-8 shadow-lg">
<h1 class="mb-6 text-center text-2xl font-bold text-card-foreground">Sign In</h1>
<AmbientBackground />
<main class="relative z-10 flex min-h-screen items-center justify-center bg-background/80 p-4 text-foreground">
<div class="w-full max-w-md rounded-xl border border-border bg-card/90 p-8 shadow-xl backdrop-blur-sm">
<div class="mb-8 text-center">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<svg
class="h-6 w-6 text-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</div>
<h1 class="text-2xl font-bold text-card-foreground">Welcome back</h1>
<p class="mt-1 text-sm text-muted-foreground">Sign in to your account</p>
</div>
<form method="POST" use:enhance class="space-y-4">
<div>
@@ -26,7 +49,7 @@
type="email"
autocomplete="email"
bind:value={$form.email}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="you@example.com"
/>
{#if $errors.email}
@@ -44,7 +67,7 @@
type="password"
autocomplete="current-password"
bind:value={$form.password}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="Enter your password"
/>
{#if $errors.password}
@@ -55,17 +78,20 @@
<button
type="submit"
disabled={$submitting}
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
class="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{#if $submitting}
Signing in...
<span class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></span>
Signing in...
</span>
{:else}
Sign In
{/if}
</button>
</form>
<p class="mt-4 text-center text-sm text-muted-foreground">
<p class="mt-6 text-center text-sm text-muted-foreground">
Don't have an account?
<a href="/register" class="font-medium text-primary hover:underline">Register</a>
</p>
+35 -9
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { superForm } from 'sveltekit-superforms';
import type { PageData } from './$types.js';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
let { data }: { data: PageData } = $props();
@@ -11,9 +12,31 @@
<title>Register — Web App Launcher</title>
</svelte:head>
<main class="flex min-h-screen items-center justify-center bg-background text-foreground">
<div class="w-full max-w-md rounded-lg border border-border bg-card p-8 shadow-lg">
<h1 class="mb-6 text-center text-2xl font-bold text-card-foreground">Create Account</h1>
<AmbientBackground />
<main class="relative z-10 flex min-h-screen items-center justify-center bg-background/80 p-4 text-foreground">
<div class="w-full max-w-md rounded-xl border border-border bg-card/90 p-8 shadow-xl backdrop-blur-sm">
<div class="mb-8 text-center">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<svg
class="h-6 w-6 text-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4-4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</svg>
</div>
<h1 class="text-2xl font-bold text-card-foreground">Create Account</h1>
<p class="mt-1 text-sm text-muted-foreground">Get started with App Launcher</p>
</div>
<form method="POST" use:enhance class="space-y-4">
<div>
@@ -26,7 +49,7 @@
type="text"
autocomplete="name"
bind:value={$form.displayName}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="Your name"
/>
{#if $errors.displayName}
@@ -44,7 +67,7 @@
type="email"
autocomplete="email"
bind:value={$form.email}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="you@example.com"
/>
{#if $errors.email}
@@ -62,7 +85,7 @@
type="password"
autocomplete="new-password"
bind:value={$form.password}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder="At least 6 characters"
/>
{#if $errors.password}
@@ -73,17 +96,20 @@
<button
type="submit"
disabled={$submitting}
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
class="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{#if $submitting}
Creating account...
<span class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></span>
Creating account...
</span>
{:else}
Create Account
{/if}
</button>
</form>
<p class="mt-4 text-center text-sm text-muted-foreground">
<p class="mt-6 text-center text-sm text-muted-foreground">
Already have an account?
<a href="/login" class="font-medium text-primary hover:underline">Sign in</a>
</p>