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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user