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