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:
+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