First commit

This commit is contained in:
AlacrisDevs
2026-02-04 23:01:44 +02:00
commit cfec43f7ef
78 changed files with 9509 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
const { session, user } = await locals.safeGetSession();
return { session, user };
};

14
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,14 @@
<script lang="ts">
import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
import { createClient } from "$lib/supabase";
import { setContext } from "svelte";
let { children, data } = $props();
const supabase = createClient();
setContext("supabase", supabase);
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}

View File

@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
redirect(303, '/login');
}
const { data: memberships } = await locals.supabase
.from('org_members')
.select(`
role,
organization:organizations(*)
`)
.eq('user_id', user.id);
const organizations = (memberships ?? []).map((m) => ({
...m.organization,
role: m.role
}));
return {
organizations
};
};

190
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,190 @@
<script lang="ts">
import { getContext } from "svelte";
import { Button, Card, Modal, Input } from "$lib/components/ui";
import { createOrganization, generateSlug } from "$lib/api/organizations";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface OrgWithRole {
id: string;
name: string;
slug: string;
role: string;
}
interface Props {
data: {
organizations: OrgWithRole[];
user: any;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let organizations = $state(data.organizations);
let showCreateModal = $state(false);
let newOrgName = $state("");
let creating = $state(false);
async function handleCreateOrg() {
if (!newOrgName.trim() || creating) return;
creating = true;
try {
const slug = generateSlug(newOrgName);
const newOrg = await createOrganization(supabase, newOrgName, slug);
organizations = [...organizations, { ...newOrg, role: "owner" }];
showCreateModal = false;
newOrgName = "";
} catch (error) {
console.error("Failed to create organization:", error);
} finally {
creating = false;
}
}
</script>
<div class="min-h-screen bg-dark">
<header class="border-b border-light/10 bg-surface">
<div
class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between"
>
<h1 class="text-xl font-bold text-light">Root Org</h1>
<div class="flex items-center gap-4">
<a href="/style" class="text-sm text-light/60 hover:text-light"
>Style Guide</a
>
<form method="POST" action="/auth/logout">
<Button variant="ghost" size="sm" type="submit"
>Sign Out</Button
>
</form>
</div>
</div>
</header>
<main class="max-w-6xl mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-8">
<div>
<h2 class="text-2xl font-bold text-light">
Your Organizations
</h2>
<p class="text-light/50 mt-1">
Select an organization to get started
</p>
</div>
<Button onclick={() => (showCreateModal = true)}>
<svg
class="w-4 h-4 mr-2"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Organization
</Button>
</div>
{#if organizations.length === 0}
<Card>
<div class="p-12 text-center">
<svg
class="w-16 h-16 mx-auto mb-4 text-light/30"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<h3 class="text-lg font-medium text-light mb-2">
No organizations yet
</h3>
<p class="text-light/50 mb-6">
Create your first organization to start collaborating
</p>
<Button onclick={() => (showCreateModal = true)}
>Create Organization</Button
>
</div>
</Card>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each organizations as org}
<a href="/{org.slug}" class="block group">
<Card
class="h-full hover:ring-1 hover:ring-primary/50 transition-all"
>
<div class="p-6">
<div
class="flex items-start justify-between mb-4"
>
<div
class="w-12 h-12 bg-primary/20 rounded-xl flex items-center justify-center text-primary font-bold text-lg"
>
{org.name.charAt(0).toUpperCase()}
</div>
<span
class="text-xs px-2 py-1 bg-light/10 rounded text-light/60 capitalize"
>
{org.role}
</span>
</div>
<h3
class="text-lg font-semibold text-light group-hover:text-primary transition-colors"
>
{org.name}
</h3>
<p class="text-sm text-light/40 mt-1">
/{org.slug}
</p>
</div>
</Card>
</a>
{/each}
</div>
{/if}
</main>
</div>
<Modal
isOpen={showCreateModal}
onClose={() => (showCreateModal = false)}
title="Create Organization"
>
<div class="space-y-4">
<Input
label="Organization Name"
bind:value={newOrgName}
placeholder="e.g. Acme Inc"
/>
{#if newOrgName}
<p class="text-sm text-light/50">
URL: <span class="text-light/70"
>/{generateSlug(newOrgName)}</span
>
</p>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showCreateModal = false)}
>Cancel</Button
>
<Button
onclick={handleCreateOrg}
disabled={!newOrgName.trim() || creating}
>
{creating ? "Creating..." : "Create"}
</Button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,36 @@
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ params, locals }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
error(401, 'Unauthorized');
}
const { data: org, error: orgError } = await locals.supabase
.from('organizations')
.select('*')
.eq('slug', params.orgSlug)
.single();
if (orgError || !org) {
error(404, 'Organization not found');
}
const { data: membership } = await locals.supabase
.from('org_members')
.select('role')
.eq('org_id', org.id)
.eq('user_id', user.id)
.single();
if (!membership) {
error(403, 'You are not a member of this organization');
}
return {
org,
role: membership.role
};
};

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import { page } from "$app/stores";
import type { Snippet } from "svelte";
interface Props {
data: {
org: { id: string; name: string; slug: string };
role: string;
};
children: Snippet;
}
let { data, children }: Props = $props();
const navItems = [
{ href: `/${data.org.slug}`, label: "Overview", icon: "home" },
{
href: `/${data.org.slug}/documents`,
label: "Documents",
icon: "file",
},
{ href: `/${data.org.slug}/kanban`, label: "Kanban", icon: "kanban" },
{
href: `/${data.org.slug}/calendar`,
label: "Calendar",
icon: "calendar",
},
{
href: `/${data.org.slug}/settings`,
label: "Settings",
icon: "settings",
},
];
function isActive(href: string): boolean {
return $page.url.pathname === href;
}
</script>
<div class="flex h-screen bg-dark">
<aside class="w-64 bg-surface border-r border-light/10 flex flex-col">
<div class="p-4 border-b border-light/10">
<h1 class="text-lg font-semibold text-light truncate">
{data.org.name}
</h1>
<p class="text-xs text-light/50 capitalize">{data.role}</p>
</div>
<nav class="flex-1 p-2 space-y-1">
{#each navItems as item}
<a
href={item.href}
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors {isActive(
item.href,
)
? 'bg-primary text-white'
: 'text-light/70 hover:bg-light/5 hover:text-light'}"
>
{#if item.icon === "home"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
/>
<polyline points="9,22 9,12 15,12 15,22" />
</svg>
{:else if item.icon === "file"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14,2 14,8 20,8" />
</svg>
{:else if item.icon === "kanban"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
<line x1="15" y1="3" x2="15" y2="21" />
</svg>
{:else if item.icon === "calendar"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{:else if item.icon === "settings"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="3" />
<path
d="M12 1v2m0 18v2M4.2 4.2l1.4 1.4m12.8 12.8l1.4 1.4M1 12h2m18 0h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"
/>
</svg>
{/if}
{item.label}
</a>
{/each}
</nav>
<div class="p-4 border-t border-light/10">
<a
href="/"
class="flex items-center gap-2 text-sm text-light/50 hover:text-light transition-colors"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m15 18-6-6 6-6" />
</svg>
All Organizations
</a>
</div>
</aside>
<main class="flex-1 overflow-auto">
{@render children()}
</main>
</div>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { Card } from '$lib/components/ui';
interface Props {
data: {
org: { id: string; name: string; slug: string };
role: string;
};
}
let { data }: Props = $props();
const quickLinks = [
{ href: `/${data.org.slug}/documents`, label: 'Documents', description: 'Collaborative docs and files', icon: 'file' },
{ href: `/${data.org.slug}/kanban`, label: 'Kanban', description: 'Track tasks and projects', icon: 'kanban' },
{ href: `/${data.org.slug}/calendar`, label: 'Calendar', description: 'Schedule events and meetings', icon: 'calendar' }
];
</script>
<div class="p-8">
<header class="mb-8">
<h1 class="text-3xl font-bold text-light">{data.org.name}</h1>
<p class="text-light/50 mt-1">Organization Overview</p>
</header>
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{#each quickLinks as link}
<a href={link.href} class="block group">
<Card class="h-full hover:ring-1 hover:ring-primary/50 transition-all">
<div class="p-6">
<div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
{#if link.icon === 'file'}
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
</svg>
{:else if link.icon === 'kanban'}
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
<line x1="15" y1="3" x2="15" y2="21" />
</svg>
{:else if link.icon === 'calendar'}
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{/if}
</div>
<h3 class="text-lg font-semibold text-light mb-1">{link.label}</h3>
<p class="text-sm text-light/50">{link.description}</p>
</div>
</Card>
</a>
{/each}
</section>
<section>
<h2 class="text-xl font-semibold text-light mb-4">Recent Activity</h2>
<Card>
<div class="p-6 text-center text-light/50">
<p>No recent activity to show</p>
</div>
</Card>
</section>
</div>

View File

@@ -0,0 +1,23 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { supabase } = locals;
// Fetch events for current month ± 1 month
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const endDate = new Date(now.getFullYear(), now.getMonth() + 2, 0);
const { data: events } = await supabase
.from('calendar_events')
.select('*')
.eq('org_id', org.id)
.gte('start_time', startDate.toISOString())
.lte('end_time', endDate.toISOString())
.order('start_time');
return {
events: events ?? []
};
};

View File

@@ -0,0 +1,239 @@
<script lang="ts">
import { getContext } from "svelte";
import { Button, Modal, Input, Textarea } from "$lib/components/ui";
import { Calendar } from "$lib/components/calendar";
import { createEvent } from "$lib/api/calendar";
import type { CalendarEvent } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Props {
data: {
org: { id: string; name: string; slug: string };
events: CalendarEvent[];
user: { id: string } | null;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let events = $state(data.events);
let showCreateModal = $state(false);
let showEventModal = $state(false);
let selectedEvent = $state<CalendarEvent | null>(null);
let selectedDate = $state<Date | null>(null);
let newEvent = $state({
title: "",
description: "",
date: "",
startTime: "09:00",
endTime: "10:00",
allDay: false,
color: "#6366f1",
});
const colorOptions = [
{ value: "#6366f1", label: "Indigo" },
{ value: "#ec4899", label: "Pink" },
{ value: "#10b981", label: "Green" },
{ value: "#f59e0b", label: "Amber" },
{ value: "#ef4444", label: "Red" },
{ value: "#8b5cf6", label: "Purple" },
];
function handleDateClick(date: Date) {
selectedDate = date;
newEvent.date = date.toISOString().split("T")[0];
showCreateModal = true;
}
function handleEventClick(event: CalendarEvent) {
selectedEvent = event;
showEventModal = true;
}
async function handleCreateEvent() {
if (!newEvent.title.trim() || !newEvent.date || !data.user) return;
const startTime = newEvent.allDay
? `${newEvent.date}T00:00:00`
: `${newEvent.date}T${newEvent.startTime}:00`;
const endTime = newEvent.allDay
? `${newEvent.date}T23:59:59`
: `${newEvent.date}T${newEvent.endTime}:00`;
const created = await createEvent(
supabase,
data.org.id,
{
title: newEvent.title,
description: newEvent.description || undefined,
start_time: startTime,
end_time: endTime,
all_day: newEvent.allDay,
color: newEvent.color,
},
data.user.id,
);
events = [...events, created];
resetForm();
}
function resetForm() {
showCreateModal = false;
newEvent = {
title: "",
description: "",
date: "",
startTime: "09:00",
endTime: "10:00",
allDay: false,
color: "#6366f1",
};
selectedDate = null;
}
function formatEventTime(event: CalendarEvent): string {
if (event.all_day) return "All day";
const start = new Date(event.start_time);
const end = new Date(event.end_time);
return `${start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
</script>
<div class="p-6 h-full overflow-auto">
<header class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-light">Calendar</h1>
<Button onclick={() => (showCreateModal = true)}>
<svg
class="w-4 h-4 mr-2"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Event
</Button>
</header>
<Calendar
{events}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
</div>
<Modal isOpen={showCreateModal} onClose={resetForm} title="Create Event">
<div class="space-y-4">
<Input
label="Title"
bind:value={newEvent.title}
placeholder="Event title"
/>
<Textarea
label="Description"
bind:value={newEvent.description}
placeholder="Optional description"
rows={2}
/>
<Input label="Date" type="date" bind:value={newEvent.date} />
<label class="flex items-center gap-2 text-sm text-light">
<input
type="checkbox"
bind:checked={newEvent.allDay}
class="rounded"
/>
All day event
</label>
{#if !newEvent.allDay}
<div class="grid grid-cols-2 gap-4">
<Input
label="Start Time"
type="time"
bind:value={newEvent.startTime}
/>
<Input
label="End Time"
type="time"
bind:value={newEvent.endTime}
/>
</div>
{/if}
<div>
<label class="block text-sm font-medium text-light mb-2"
>Color</label
>
<div class="flex gap-2">
{#each colorOptions as color}
<button
type="button"
class="w-8 h-8 rounded-full transition-transform"
class:ring-2={newEvent.color === color.value}
class:ring-white={newEvent.color === color.value}
class:scale-110={newEvent.color === color.value}
style="background-color: {color.value}"
onclick={() => (newEvent.color = color.value)}
aria-label={color.label}
></button>
{/each}
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={resetForm}>Cancel</Button>
<Button
onclick={handleCreateEvent}
disabled={!newEvent.title.trim() || !newEvent.date}
>Create</Button
>
</div>
</div>
</Modal>
<Modal
isOpen={showEventModal}
onClose={() => (showEventModal = false)}
title={selectedEvent?.title ?? "Event"}
>
{#if selectedEvent}
<div class="space-y-3">
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
style="background-color: {selectedEvent.color ?? '#6366f1'}"
></div>
<span class="text-light/70"
>{formatEventTime(selectedEvent)}</span
>
</div>
{#if selectedEvent.description}
<p class="text-light/80">{selectedEvent.description}</p>
{/if}
<p class="text-xs text-light/40">
{new Date(selectedEvent.start_time).toLocaleDateString(
undefined,
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
},
)}
</p>
</div>
{/if}
</Modal>

View File

@@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { supabase } = locals;
const { data: documents } = await supabase
.from('documents')
.select('*')
.eq('org_id', org.id)
.order('name');
return {
documents: documents ?? []
};
};

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import { getContext } from "svelte";
import { Button, Modal, Input } from "$lib/components/ui";
import { FileTree, Editor } from "$lib/components/documents";
import { buildDocumentTree } from "$lib/api/documents";
import type { Document } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Props {
data: {
org: { id: string; name: string; slug: string };
documents: Document[];
user: { id: string } | null;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let documents = $state(data.documents);
let selectedDoc = $state<Document | null>(null);
let showCreateModal = $state(false);
let newDocName = $state("");
let newDocType = $state<"folder" | "document">("document");
let parentFolderId = $state<string | null>(null);
const documentTree = $derived(buildDocumentTree(documents));
function handleSelect(doc: Document) {
if (doc.type === "document") {
selectedDoc = doc;
}
}
function handleAdd(folderId: string | null) {
parentFolderId = folderId;
showCreateModal = true;
}
async function handleMove(docId: string, newParentId: string | null) {
const { error } = await supabase
.from("documents")
.update({
parent_id: newParentId,
updated_at: new Date().toISOString(),
})
.eq("id", docId);
if (!error) {
documents = documents.map((d) =>
d.id === docId ? { ...d, parent_id: newParentId } : d,
);
}
}
async function handleCreate() {
if (!newDocName.trim() || !data.user) return;
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: data.org.id,
name: newDocName,
type: newDocType,
parent_id: parentFolderId,
created_by: data.user.id,
content:
newDocType === "document"
? { type: "doc", content: [] }
: null,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc];
if (newDocType === "document") {
selectedDoc = newDoc;
}
}
showCreateModal = false;
newDocName = "";
newDocType = "document";
parentFolderId = null;
}
async function handleSave(content: unknown) {
if (!selectedDoc) return;
await supabase
.from("documents")
.update({ content, updated_at: new Date().toISOString() })
.eq("id", selectedDoc.id);
documents = documents.map((d) =>
d.id === selectedDoc!.id ? { ...d, content } : d,
);
}
</script>
<div class="flex h-full">
<aside class="w-72 border-r border-light/10 flex flex-col">
<div
class="p-4 border-b border-light/10 flex items-center justify-between"
>
<h2 class="font-semibold text-light">Documents</h2>
<Button size="sm" onclick={() => (showCreateModal = true)}>
<svg
class="w-4 h-4 mr-1"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New
</Button>
</div>
<div class="flex-1 overflow-y-auto p-2">
{#if documentTree.length === 0}
<div class="text-center text-light/40 py-8 text-sm">
<p>No documents yet</p>
<p class="mt-1">Create your first document</p>
</div>
{:else}
<FileTree
items={documentTree}
selectedId={selectedDoc?.id ?? null}
onSelect={handleSelect}
onAdd={handleAdd}
onMove={handleMove}
/>
{/if}
</div>
</aside>
<main class="flex-1 overflow-hidden">
{#if selectedDoc}
<Editor document={selectedDoc} onSave={handleSave} />
{:else}
<div class="h-full flex items-center justify-center text-light/40">
<div class="text-center">
<svg
class="w-16 h-16 mx-auto mb-4 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14,2 14,8 20,8" />
</svg>
<p>Select a document to edit</p>
</div>
</div>
{/if}
</main>
</div>
<Modal
isOpen={showCreateModal}
onClose={() => (showCreateModal = false)}
title="Create New"
>
<div class="space-y-4">
<div class="flex gap-2">
<button
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
'document'
? 'border-primary bg-primary/10'
: 'border-light/20'}"
onclick={() => (newDocType = "document")}
>
Document
</button>
<button
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
'folder'
? 'border-primary bg-primary/10'
: 'border-light/20'}"
onclick={() => (newDocType = "folder")}
>
Folder
</button>
</div>
<Input
label="Name"
bind:value={newDocName}
placeholder={newDocType === "folder"
? "Folder name"
: "Document name"}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showCreateModal = false)}
>Cancel</Button
>
<Button onclick={handleCreate} disabled={!newDocName.trim()}
>Create</Button
>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { supabase } = locals;
const { data: boards } = await supabase
.from('kanban_boards')
.select('*')
.eq('org_id', org.id)
.order('created_at');
return {
boards: boards ?? []
};
};

View File

@@ -0,0 +1,256 @@
<script lang="ts">
import { getContext } from "svelte";
import { Button, Card, Modal, Input } from "$lib/components/ui";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
fetchBoardWithColumns,
createBoard,
createCard,
moveCard,
} from "$lib/api/kanban";
import type {
KanbanBoard as KanbanBoardType,
KanbanCard,
} from "$lib/supabase/types";
import type { BoardWithColumns } from "$lib/api/kanban";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Props {
data: {
org: { id: string; name: string; slug: string };
boards: KanbanBoardType[];
user: { id: string } | null;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let boards = $state(data.boards);
let selectedBoard = $state<BoardWithColumns | null>(null);
let showCreateBoardModal = $state(false);
let showCreateCardModal = $state(false);
let showCardDetailModal = $state(false);
let selectedCard = $state<KanbanCard | null>(null);
let newBoardName = $state("");
let newCardTitle = $state("");
let targetColumnId = $state<string | null>(null);
async function loadBoard(boardId: string) {
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
}
async function handleCreateBoard() {
if (!newBoardName.trim()) return;
const newBoard = await createBoard(supabase, data.org.id, newBoardName);
boards = [...boards, newBoard];
await loadBoard(newBoard.id);
showCreateBoardModal = false;
newBoardName = "";
}
async function handleAddCard(columnId: string) {
targetColumnId = columnId;
showCreateCardModal = true;
}
async function handleCreateCard() {
if (
!newCardTitle.trim() ||
!targetColumnId ||
!selectedBoard ||
!data.user
)
return;
const column = selectedBoard.columns.find(
(c) => c.id === targetColumnId,
);
const position = column?.cards.length ?? 0;
await createCard(
supabase,
targetColumnId,
newCardTitle,
position,
data.user.id,
);
await loadBoard(selectedBoard.id);
showCreateCardModal = false;
newCardTitle = "";
targetColumnId = null;
}
async function handleCardMove(
cardId: string,
toColumnId: string,
toPosition: number,
) {
if (!selectedBoard) return;
await moveCard(supabase, cardId, toColumnId, toPosition);
await loadBoard(selectedBoard.id);
}
function handleCardClick(card: KanbanCard) {
selectedCard = card;
showCardDetailModal = true;
}
function handleCardUpdate(updatedCard: KanbanCard) {
if (!selectedBoard) return;
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) => ({
...col,
cards: col.cards.map((c) =>
c.id === updatedCard.id ? updatedCard : c,
),
})),
};
selectedCard = updatedCard;
}
async function handleCardDelete(cardId: string) {
if (!selectedBoard) return;
await loadBoard(selectedBoard.id);
}
</script>
<div class="flex h-full">
<aside class="w-64 border-r border-light/10 flex flex-col">
<div
class="p-4 border-b border-light/10 flex items-center justify-between"
>
<h2 class="font-semibold text-light">Boards</h2>
<Button size="sm" onclick={() => (showCreateBoardModal = true)}>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</Button>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-1">
{#if boards.length === 0}
<div class="text-center text-light/40 py-8 text-sm">
<p>No boards yet</p>
</div>
{:else}
{#each boards as board}
<button
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {selectedBoard?.id ===
board.id
? 'bg-primary text-white'
: 'text-light/70 hover:bg-light/5'}"
onclick={() => loadBoard(board.id)}
>
{board.name}
</button>
{/each}
{/if}
</div>
</aside>
<main class="flex-1 overflow-hidden p-6">
{#if selectedBoard}
<header class="mb-6">
<h1 class="text-2xl font-bold text-light">
{selectedBoard.name}
</h1>
</header>
<KanbanBoard
columns={selectedBoard.columns}
onCardClick={handleCardClick}
onCardMove={handleCardMove}
onAddCard={handleAddCard}
/>
{:else}
<div class="h-full flex items-center justify-center text-light/40">
<div class="text-center">
<svg
class="w-16 h-16 mx-auto mb-4 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
<line x1="15" y1="3" x2="15" y2="21" />
</svg>
<p>Select a board or create a new one</p>
</div>
</div>
{/if}
</main>
</div>
<Modal
isOpen={showCreateBoardModal}
onClose={() => (showCreateBoardModal = false)}
title="Create Board"
>
<div class="space-y-4">
<Input
label="Board Name"
bind:value={newBoardName}
placeholder="e.g. Sprint 1"
/>
<div class="flex justify-end gap-2">
<Button
variant="ghost"
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
>
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
>Create</Button
>
</div>
</div>
</Modal>
<Modal
isOpen={showCreateCardModal}
onClose={() => (showCreateCardModal = false)}
title="Add Card"
>
<div class="space-y-4">
<Input
label="Title"
bind:value={newCardTitle}
placeholder="Card title"
/>
<div class="flex justify-end gap-2">
<Button
variant="ghost"
onclick={() => (showCreateCardModal = false)}>Cancel</Button
>
<Button onclick={handleCreateCard} disabled={!newCardTitle.trim()}
>Add</Button
>
</div>
</div>
</Modal>
<CardDetailModal
card={selectedCard}
isOpen={showCardDetailModal}
onClose={() => {
showCardDetailModal = false;
selectedCard = null;
}}
onUpdate={handleCardUpdate}
onDelete={handleCardDelete}
/>

View File

@@ -0,0 +1,43 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { exchangeCodeForTokens } from '$lib/api/google-calendar';
export const GET: RequestHandler = async ({ url, locals }) => {
const code = url.searchParams.get('code');
const stateParam = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error || !code || !stateParam) {
redirect(303, '/?error=google_auth_failed');
}
let state: { orgSlug: string; userId: string };
try {
state = JSON.parse(decodeURIComponent(stateParam));
} catch {
redirect(303, '/?error=invalid_state');
}
const redirectUri = `${url.origin}/api/google-calendar/callback`;
try {
const tokens = await exchangeCodeForTokens(code, redirectUri);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
// Store tokens in database
await locals.supabase
.from('google_calendar_connections')
.upsert({
user_id: state.userId,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_expires_at: expiresAt.toISOString(),
updated_at: new Date().toISOString()
}, { onConflict: 'user_id' });
redirect(303, `/${state.orgSlug}/calendar?connected=true`);
} catch (err) {
console.error('Google Calendar OAuth error:', err);
redirect(303, `/${state.orgSlug}/calendar?error=token_exchange_failed`);
}
};

View File

@@ -0,0 +1,22 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGoogleAuthUrl } from '$lib/api/google-calendar';
export const GET: RequestHandler = async ({ url, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
redirect(303, '/login');
}
const orgSlug = url.searchParams.get('org');
if (!orgSlug) {
redirect(303, '/');
}
const redirectUri = `${url.origin}/api/google-calendar/callback`;
const state = JSON.stringify({ orgSlug, userId: session.user.id });
const authUrl = getGoogleAuthUrl(redirectUri, encodeURIComponent(state));
redirect(303, authUrl);
};

View File

@@ -0,0 +1,16 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, locals }) => {
const code = url.searchParams.get('code');
const next = url.searchParams.get('next') ?? '/';
if (code) {
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);
if (!error) {
redirect(303, next);
}
}
redirect(303, '/login?error=auth_callback_error');
};

View File

@@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ locals }) => {
const { supabase } = locals;
await supabase.auth.signOut();
redirect(303, '/login');
};

View File

@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({
status: 'healthy',
timestamp: new Date().toISOString()
});
};

49
src/routes/layout.css Normal file
View File

@@ -0,0 +1,49 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@theme {
/* Colors - Dark theme */
--color-dark: #0a0a0f;
--color-surface: #14141f;
--color-light: #f0f0f5;
/* Brand */
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
/* Status */
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
/* Font */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
}
/* Base styles */
body {
background-color: var(--color-dark);
color: var(--color-light);
font-family: var(--font-sans);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-dark);
}
::-webkit-scrollbar-thumb {
background: var(--color-light) / 0.2;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-light) / 0.3;
}

View File

@@ -0,0 +1,167 @@
<script lang="ts">
import { Button, Input, Card } from "$lib/components/ui";
import { createClient } from "$lib/supabase";
import { goto } from "$app/navigation";
let email = $state("");
let password = $state("");
let isLoading = $state(false);
let error = $state("");
let mode = $state<"login" | "signup">("login");
const supabase = createClient();
async function handleSubmit() {
if (!email || !password) {
error = "Please fill in all fields";
return;
}
isLoading = true;
error = "";
try {
if (mode === "login") {
const { error: authError } =
await supabase.auth.signInWithPassword({
email,
password,
});
if (authError) throw authError;
} else {
const { error: authError } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
if (authError) throw authError;
}
goto("/");
} catch (e: unknown) {
error = e instanceof Error ? e.message : "An error occurred";
} finally {
isLoading = false;
}
}
async function handleOAuth(provider: "google" | "github") {
const { error: authError } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (authError) {
error = authError.message;
}
}
</script>
<svelte:head>
<title>{mode === "login" ? "Log In" : "Sign Up"} | Root</title>
</svelte:head>
<div class="min-h-screen bg-dark flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary mb-2">Root</h1>
<p class="text-light/60">Team collaboration, reimagined</p>
</div>
<Card variant="elevated" padding="lg">
<h2 class="text-xl font-semibold text-light mb-6">
{mode === "login" ? "Welcome back" : "Create your account"}
</h2>
{#if error}
<div
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm"
>
{error}
</div>
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
<Input
type="email"
label="Email"
placeholder="you@example.com"
bind:value={email}
required
/>
<Input
type="password"
label="Password"
placeholder="••••••••"
bind:value={password}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{mode === "login" ? "Log In" : "Sign Up"}
</Button>
</form>
<div class="my-6 flex items-center gap-3">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-light/40 text-sm">or continue with</span>
<div class="flex-1 h-px bg-light/10"></div>
</div>
<Button
variant="secondary"
fullWidth
onclick={() => handleOAuth("google")}
>
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<p class="mt-6 text-center text-light/60 text-sm">
{#if mode === "login"}
Don't have an account?
<button
class="text-primary hover:underline"
onclick={() => (mode = "signup")}
>
Sign up
</button>
{:else}
Already have an account?
<button
class="text-primary hover:underline"
onclick={() => (mode = "login")}
>
Log in
</button>
{/if}
</p>
</Card>
</div>
</div>

View File

@@ -0,0 +1,13 @@
import { page } from 'vitest/browser';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,371 @@
<script lang="ts">
import {
Button,
Input,
Textarea,
Select,
Avatar,
Badge,
Card,
Modal,
Spinner,
Toggle
} from '$lib/components/ui';
let inputValue = $state('');
let textareaValue = $state('');
let selectValue = $state('');
let toggleChecked = $state(false);
let modalOpen = $state(false);
const selectOptions = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' }
];
</script>
<svelte:head>
<title>Style Guide | Root</title>
</svelte:head>
<div class="min-h-screen bg-dark p-8">
<div class="max-w-6xl mx-auto space-y-12">
<!-- Header -->
<header class="text-center space-y-4">
<h1 class="text-4xl font-bold text-light">Root Style Guide</h1>
<p class="text-light/60">All UI components and their variants</p>
</header>
<!-- Colors -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Colors</h2>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div class="space-y-2">
<div class="w-full h-20 rounded-xl bg-dark border border-light/20"></div>
<p class="text-sm text-light/60">Dark</p>
<code class="text-xs text-light/40">#0a0a0f</code>
</div>
<div class="space-y-2">
<div class="w-full h-20 rounded-xl bg-surface"></div>
<p class="text-sm text-light/60">Surface</p>
<code class="text-xs text-light/40">#14141f</code>
</div>
<div class="space-y-2">
<div class="w-full h-20 rounded-xl bg-light"></div>
<p class="text-sm text-light/60">Light</p>
<code class="text-xs text-light/40">#f0f0f5</code>
</div>
<div class="space-y-2">
<div class="w-full h-20 rounded-xl bg-primary"></div>
<p class="text-sm text-light/60">Primary</p>
<code class="text-xs text-light/40">#6366f1</code>
</div>
<div class="space-y-2">
<div class="w-full h-20 rounded-xl bg-success"></div>
<p class="text-sm text-light/60">Success</p>
<code class="text-xs text-light/40">#22c55e</code>
</div>
<div class="space-y-2">
<div class="w-full h-20 rounded-xl bg-warning"></div>
<p class="text-sm text-light/60">Warning</p>
<code class="text-xs text-light/40">#f59e0b</code>
</div>
<div class="space-y-2">
<div class="w-full h-20 rounded-xl bg-error"></div>
<p class="text-sm text-light/60">Error</p>
<code class="text-xs text-light/40">#ef4444</code>
</div>
<div class="space-y-2">
<div class="w-full h-20 rounded-xl bg-info"></div>
<p class="text-sm text-light/60">Info</p>
<code class="text-xs text-light/40">#3b82f6</code>
</div>
</div>
</section>
<!-- Buttons -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Buttons</h2>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">Variants</h3>
<div class="flex flex-wrap gap-3">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
<Button variant="success">Success</Button>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
<div class="flex flex-wrap items-center gap-3">
<Button size="xs">Extra Small</Button>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">States</h3>
<div class="flex flex-wrap gap-3">
<Button>Normal</Button>
<Button disabled>Disabled</Button>
<Button loading>Loading</Button>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">Full Width</h3>
<div class="max-w-sm">
<Button fullWidth>Full Width Button</Button>
</div>
</div>
</div>
</section>
<!-- Inputs -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Inputs</h2>
<div class="grid md:grid-cols-2 gap-6">
<Input label="Default Input" placeholder="Enter text..." bind:value={inputValue} />
<Input label="Required Field" placeholder="Required..." required />
<Input label="With Hint" placeholder="Email..." hint="We'll never share your email" />
<Input label="With Error" placeholder="Password..." error="Password is too short" />
<Input label="Disabled" placeholder="Can't edit this" disabled />
<Input type="password" label="Password" placeholder="••••••••" />
</div>
</section>
<!-- Textarea -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Textarea</h2>
<div class="grid md:grid-cols-2 gap-6">
<Textarea label="Default Textarea" placeholder="Enter description..." bind:value={textareaValue} />
<Textarea label="With Error" placeholder="Description..." error="Description is required" />
</div>
</section>
<!-- Select -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Select</h2>
<div class="grid md:grid-cols-2 gap-6">
<Select label="Default Select" options={selectOptions} bind:value={selectValue} />
<Select label="With Error" options={selectOptions} error="Please select an option" />
</div>
</section>
<!-- Avatars -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Avatars</h2>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
<div class="flex items-end gap-4">
<Avatar name="John Doe" size="xs" />
<Avatar name="John Doe" size="sm" />
<Avatar name="John Doe" size="md" />
<Avatar name="John Doe" size="lg" />
<Avatar name="John Doe" size="xl" />
<Avatar name="John Doe" size="2xl" />
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">With Status</h3>
<div class="flex items-center gap-4">
<Avatar name="Online User" size="lg" status="online" />
<Avatar name="Away User" size="lg" status="away" />
<Avatar name="Busy User" size="lg" status="busy" />
<Avatar name="Offline User" size="lg" status="offline" />
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">Different Names (Color Generation)</h3>
<div class="flex items-center gap-4">
<Avatar name="Alice" size="lg" />
<Avatar name="Bob" size="lg" />
<Avatar name="Charlie" size="lg" />
<Avatar name="Diana" size="lg" />
<Avatar name="Eve" size="lg" />
</div>
</div>
</div>
</section>
<!-- Badges -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Badges</h2>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">Variants</h3>
<div class="flex flex-wrap gap-3">
<Badge variant="default">Default</Badge>
<Badge variant="primary">Primary</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="error">Error</Badge>
<Badge variant="info">Info</Badge>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
<div class="flex flex-wrap items-center gap-3">
<Badge size="sm">Small</Badge>
<Badge size="md">Medium</Badge>
<Badge size="lg">Large</Badge>
</div>
</div>
</div>
</section>
<!-- Cards -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Cards</h2>
<div class="grid md:grid-cols-3 gap-6">
<Card variant="default">
<h3 class="font-semibold text-light mb-2">Default Card</h3>
<p class="text-light/60 text-sm">This is a default card with medium padding.</p>
</Card>
<Card variant="elevated">
<h3 class="font-semibold text-light mb-2">Elevated Card</h3>
<p class="text-light/60 text-sm">This card has a shadow for elevation.</p>
</Card>
<Card variant="outlined">
<h3 class="font-semibold text-light mb-2">Outlined Card</h3>
<p class="text-light/60 text-sm">This card has a subtle border.</p>
</Card>
</div>
</section>
<!-- Toggle -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Toggle</h2>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<Toggle size="sm" />
<span class="text-light/60 text-sm">Small</span>
</div>
<div class="flex items-center gap-2">
<Toggle size="md" bind:checked={toggleChecked} />
<span class="text-light/60 text-sm">Medium</span>
</div>
<div class="flex items-center gap-2">
<Toggle size="lg" checked />
<span class="text-light/60 text-sm">Large</span>
</div>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">States</h3>
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<Toggle />
<span class="text-light/60 text-sm">Off</span>
</div>
<div class="flex items-center gap-2">
<Toggle checked />
<span class="text-light/60 text-sm">On</span>
</div>
<div class="flex items-center gap-2">
<Toggle disabled />
<span class="text-light/60 text-sm">Disabled</span>
</div>
</div>
</div>
</div>
</section>
<!-- Spinners -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Spinners</h2>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
<div class="flex items-center gap-6">
<Spinner size="sm" />
<Spinner size="md" />
<Spinner size="lg" />
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">Colors</h3>
<div class="flex items-center gap-6">
<Spinner color="primary" />
<Spinner color="light" />
<div class="text-success">
<Spinner color="current" />
</div>
</div>
</div>
</div>
</section>
<!-- Modal -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Modal</h2>
<div>
<Button onclick={() => (modalOpen = true)}>Open Modal</Button>
</div>
</section>
<Modal isOpen={modalOpen} onClose={() => (modalOpen = false)} title="Example Modal">
<p class="text-light/70 mb-4">
This is an example modal dialog. You can put any content here.
</p>
<div class="flex gap-3 justify-end">
<Button variant="secondary" onclick={() => (modalOpen = false)}>Cancel</Button>
<Button onclick={() => (modalOpen = false)}>Confirm</Button>
</div>
</Modal>
<!-- Typography -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Typography</h2>
<div class="space-y-4">
<h1 class="text-4xl font-bold text-light">Heading 1 (4xl bold)</h1>
<h2 class="text-3xl font-bold text-light">Heading 2 (3xl bold)</h2>
<h3 class="text-2xl font-semibold text-light">Heading 3 (2xl semibold)</h3>
<h4 class="text-xl font-semibold text-light">Heading 4 (xl semibold)</h4>
<h5 class="text-lg font-medium text-light">Heading 5 (lg medium)</h5>
<h6 class="text-base font-medium text-light">Heading 6 (base medium)</h6>
<p class="text-base text-light/80">
Body text (base, 80% opacity) - Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
<p class="text-sm text-light/60">
Small text (sm, 60% opacity) - Used for secondary information and hints.
</p>
<p class="text-xs text-light/40">
Extra small text (xs, 40% opacity) - Used for metadata and timestamps.
</p>
</div>
</section>
<!-- Footer -->
<footer class="text-center py-8 border-t border-light/10">
<p class="text-light/40 text-sm">Root Organization Platform - Style Guide</p>
</footer>
</div>
</div>