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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
← 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user