First commit
This commit is contained in:
6
src/routes/+layout.server.ts
Normal file
6
src/routes/+layout.server.ts
Normal 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
14
src/routes/+layout.svelte
Normal 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()}
|
||||
27
src/routes/+page.server.ts
Normal file
27
src/routes/+page.server.ts
Normal 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
190
src/routes/+page.svelte
Normal 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>
|
||||
36
src/routes/[orgSlug]/+layout.server.ts
Normal file
36
src/routes/[orgSlug]/+layout.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
151
src/routes/[orgSlug]/+layout.svelte
Normal file
151
src/routes/[orgSlug]/+layout.svelte
Normal 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>
|
||||
68
src/routes/[orgSlug]/+page.svelte
Normal file
68
src/routes/[orgSlug]/+page.svelte
Normal 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>
|
||||
23
src/routes/[orgSlug]/calendar/+page.server.ts
Normal file
23
src/routes/[orgSlug]/calendar/+page.server.ts
Normal 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 ?? []
|
||||
};
|
||||
};
|
||||
239
src/routes/[orgSlug]/calendar/+page.svelte
Normal file
239
src/routes/[orgSlug]/calendar/+page.svelte
Normal 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>
|
||||
16
src/routes/[orgSlug]/documents/+page.server.ts
Normal file
16
src/routes/[orgSlug]/documents/+page.server.ts
Normal 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 ?? []
|
||||
};
|
||||
};
|
||||
212
src/routes/[orgSlug]/documents/+page.svelte
Normal file
212
src/routes/[orgSlug]/documents/+page.svelte
Normal 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>
|
||||
16
src/routes/[orgSlug]/kanban/+page.server.ts
Normal file
16
src/routes/[orgSlug]/kanban/+page.server.ts
Normal 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 ?? []
|
||||
};
|
||||
};
|
||||
256
src/routes/[orgSlug]/kanban/+page.svelte
Normal file
256
src/routes/[orgSlug]/kanban/+page.svelte
Normal 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}
|
||||
/>
|
||||
43
src/routes/api/google-calendar/callback/+server.ts
Normal file
43
src/routes/api/google-calendar/callback/+server.ts
Normal 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`);
|
||||
}
|
||||
};
|
||||
22
src/routes/api/google-calendar/connect/+server.ts
Normal file
22
src/routes/api/google-calendar/connect/+server.ts
Normal 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);
|
||||
};
|
||||
16
src/routes/auth/callback/+server.ts
Normal file
16
src/routes/auth/callback/+server.ts
Normal 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');
|
||||
};
|
||||
8
src/routes/auth/logout/+server.ts
Normal file
8
src/routes/auth/logout/+server.ts
Normal 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');
|
||||
};
|
||||
9
src/routes/health/+server.ts
Normal file
9
src/routes/health/+server.ts
Normal 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
49
src/routes/layout.css
Normal 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;
|
||||
}
|
||||
167
src/routes/login/+page.svelte
Normal file
167
src/routes/login/+page.svelte
Normal 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>
|
||||
13
src/routes/page.svelte.spec.ts
Normal file
13
src/routes/page.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
371
src/routes/style/+page.svelte
Normal file
371
src/routes/style/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user