Mega push vol 4

This commit is contained in:
AlacrisDevs
2026-02-06 16:08:40 +02:00
parent b517bb975c
commit d8bbfd9dc3
95 changed files with 8019 additions and 3946 deletions

82
src/routes/+error.svelte Normal file
View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { page } from "$app/stores";
import { Button } from "$lib/components/ui";
import { dumpLogs } from "$lib/utils/logger";
let showLogs = $state(false);
let logDump = $state("");
let copied = $state(false);
function handleShowLogs() {
logDump = dumpLogs();
showLogs = !showLogs;
}
async function handleCopyLogs() {
const dump = dumpLogs();
const errorInfo = `--- Error Report ---
URL: ${$page.url.pathname}
Status: ${$page.status}
Message: ${$page.error?.message || "Unknown"}
Error ID: ${$page.error?.errorId || "N/A"}
Context: ${$page.error?.context || "N/A"}
Time: ${new Date().toISOString()}
--- Recent Logs ---
${dump}
`;
await navigator.clipboard.writeText(errorInfo);
copied = true;
setTimeout(() => (copied = false), 2000);
}
</script>
<div class="min-h-screen bg-night flex items-center justify-center p-4">
<div class="max-w-lg w-full text-center space-y-6">
<div class="space-y-2">
<p class="text-[80px] font-heading text-primary">{$page.status}</p>
<h1 class="text-2xl font-heading text-white">
{$page.status === 404 ? "Page not found" : "Something went wrong"}
</h1>
<p class="text-light/60 text-base">
{$page.error?.message || "An unexpected error occurred."}
</p>
{#if $page.error?.errorId}
<p class="text-light/40 text-sm font-mono">
Error ID: {$page.error.errorId}
</p>
{/if}
{#if $page.error?.context}
<p class="text-light/40 text-sm font-mono">
{$page.error.context}
</p>
{/if}
</div>
<div class="flex gap-3 justify-center flex-wrap">
<Button onclick={() => window.location.href = "/"}>
Go Home
</Button>
<Button variant="tertiary" onclick={() => window.location.reload()}>
Retry
</Button>
<Button variant="secondary" onclick={handleCopyLogs}>
{copied ? "Copied!" : "Copy Error Report"}
</Button>
</div>
<div>
<button
type="button"
class="text-sm text-light/40 hover:text-light/60 transition-colors underline"
onclick={handleShowLogs}
>
{showLogs ? "Hide" : "Show"} debug logs
</button>
{#if showLogs}
<pre class="mt-4 p-4 bg-dark rounded-[16px] text-left text-xs text-light/70 overflow-auto max-h-[300px] font-mono whitespace-pre-wrap">{logDump || "No recent logs."}</pre>
{/if}
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { getContext } from "svelte";
import { Button, Card, Modal, Input } from "$lib/components/ui";
import { createOrganization, generateSlug } from "$lib/api/organizations";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
@@ -24,6 +25,9 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
let organizations = $state(data.organizations);
$effect(() => {
organizations = data.organizations;
});
let showCreateModal = $state(false);
let newOrgName = $state("");
let creating = $state(false);
@@ -41,7 +45,9 @@
showCreateModal = false;
newOrgName = "";
} catch (error) {
console.error("Failed to create organization:", error);
toasts.error(
"Failed to create organization. The name may already be taken.",
);
} finally {
creating = false;
}
@@ -63,7 +69,7 @@
>Style Guide</a
>
<form method="POST" action="/auth/logout">
<Button variant="ghost" size="sm" type="submit"
<Button variant="tertiary" size="sm" type="submit"
>Sign Out</Button
>
</form>
@@ -180,7 +186,7 @@
</p>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showCreateModal = false)}
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
>Cancel</Button
>
<Button

View File

@@ -8,6 +8,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
error(401, 'Unauthorized');
}
// Fetch org first (need org.id for subsequent queries)
const { data: org, error: orgError } = await locals.supabase
.from('organizations')
.select('*')
@@ -18,58 +19,62 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
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();
// Now fetch membership, members, and activity in parallel (all depend on org.id)
const [membershipResult, membersResult, activityResult] = await Promise.all([
locals.supabase
.from('org_members')
.select('role')
.eq('org_id', org.id)
.eq('user_id', user.id)
.single(),
locals.supabase
.from('org_members')
.select(`
id,
user_id,
role,
profiles:user_id (
id,
email,
full_name,
avatar_url
)
`)
.eq('org_id', org.id)
.limit(10),
locals.supabase
.from('activity_log')
.select(`
id,
action,
entity_type,
entity_id,
entity_name,
created_at,
profiles:user_id (
full_name,
email
)
`)
.eq('org_id', org.id)
.order('created_at', { ascending: false })
.limit(10)
]);
const { data: membership } = membershipResult;
const { data: members } = membersResult;
const { data: recentActivity } = activityResult;
if (!membership) {
error(403, 'You are not a member of this organization');
}
// Fetch team members for sidebar
const { data: members } = await locals.supabase
.from('org_members')
.select(`
id,
user_id,
role,
profiles:user_id (
id,
email,
full_name,
avatar_url
)
`)
.eq('org_id', org.id)
.limit(10);
// Fetch recent activity
const { data: recentActivity } = await locals.supabase
.from('activity_log')
.select(`
id,
action,
entity_type,
entity_id,
entity_name,
created_at,
profiles:user_id (
full_name,
email
)
`)
.eq('org_id', org.id)
.order('created_at', { ascending: false })
.limit(10);
return {
org,
role: membership.role,
userRole: membership.role,
userRole: membership.role, // kept for backwards compat — same as role
members: members ?? [],
recentActivity: recentActivity ?? []
recentActivity: recentActivity ?? [],
user
};
};

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from "$app/stores";
import { page, navigating } from "$app/stores";
import type { Snippet } from "svelte";
import { Avatar, Logo } from "$lib/components/ui";
interface Member {
id: string;
@@ -16,7 +17,12 @@
interface Props {
data: {
org: { id: string; name: string; slug: string };
org: {
id: string;
name: string;
slug: string;
avatar_url?: string | null;
};
role: string;
userRole: string;
members: Member[];
@@ -26,24 +32,25 @@
let { data, children }: Props = $props();
let sidebarCollapsed = $state(false);
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
// Sidebar collapses on all pages except org overview
const isOrgOverview = $derived($page.url.pathname === `/${data.org.slug}`);
let sidebarHovered = $state(false);
const sidebarCollapsed = $derived(!isOrgOverview && !sidebarHovered);
const navItems = $derived([
{ href: `/${data.org.slug}`, label: "Overview", icon: "home" },
{
href: `/${data.org.slug}/documents`,
label: "Documents",
icon: "file",
label: "Files",
icon: "cloud",
},
{ href: `/${data.org.slug}/kanban`, label: "Kanban", icon: "kanban" },
{
href: `/${data.org.slug}/calendar`,
label: "Calendar",
icon: "calendar",
icon: "calendar_today",
},
// Only show settings for admins
...(isAdmin
@@ -58,7 +65,7 @@
]);
function isActive(href: string): boolean {
return $page.url.pathname === href;
return $page.url.pathname.startsWith(href);
}
</script>
@@ -66,206 +73,107 @@
<div class="flex h-screen bg-background p-4 gap-4">
<!-- Organization Module -->
<aside
class="{sidebarCollapsed
? 'w-20'
: 'w-56'} bg-night rounded-[32px] flex flex-col px-3 py-5 transition-all duration-200 overflow-hidden"
class="
{sidebarCollapsed ? 'w-[72px]' : 'w-64'}
transition-all duration-300
bg-night rounded-[32px] flex flex-col px-4 py-5 gap-4 overflow-hidden shrink-0
"
onmouseenter={() => (sidebarHovered = true)}
onmouseleave={() => (sidebarHovered = false)}
>
<!-- Org Header -->
<div class="flex items-start gap-2 px-1 mb-2">
<a
href="/{data.org.slug}"
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors"
>
<div
class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xl font-heading shrink-0"
class="shrink-0 transition-all duration-300 {sidebarCollapsed
? 'w-8 h-8'
: 'w-12 h-12'}"
>
{data.org.name[0].toUpperCase()}
<Avatar
name={data.org.name}
src={data.org.avatar_url}
size="md"
/>
</div>
{#if !sidebarCollapsed}
<div class="min-w-0 flex-1">
<h1 class="font-heading text-xl text-light truncate">
{data.org.name}
</h1>
<p class="text-xs text-white capitalize">{data.role}</p>
</div>
{/if}
</div>
<div
class="min-w-0 flex-1 overflow-hidden transition-all duration-300 {sidebarCollapsed
? 'opacity-0 max-w-0'
: 'opacity-100 max-w-[200px]'}"
>
<h1
class="font-heading text-h3 text-white truncate whitespace-nowrap"
>
{data.org.name}
</h1>
<p
class="text-body-sm text-white font-body capitalize whitespace-nowrap"
>
{data.role}
</p>
</div>
</a>
<!-- Nav Items -->
<nav class="flex-1 space-y-0.5">
<nav class="flex-1 flex flex-col gap-1">
{#each navItems as item}
<a
href={item.href}
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors {isActive(
class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] transition-colors {isActive(
item.href,
)
? 'bg-primary/20'
: 'hover:bg-light/5'}"
? 'bg-primary'
: 'hover:bg-dark'}"
title={sidebarCollapsed ? item.label : undefined}
>
<!-- Icon circle -->
<div
class="w-8 h-8 rounded-full {isActive(item.href)
? 'bg-primary'
: 'bg-light'} flex items-center justify-center shrink-0"
class="w-8 h-8 flex items-center justify-center p-1 shrink-0"
>
{#if item.icon === "home"}
<svg
class="w-4 h-4 {isActive(item.href)
? 'text-white'
: 'text-night'}"
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-4 h-4 {isActive(item.href)
? 'text-white'
: 'text-night'}"
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-4 h-4 {isActive(item.href)
? 'text-white'
: 'text-night'}"
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-4 h-4 {isActive(item.href)
? 'text-white'
: 'text-night'}"
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-4 h-4 {isActive(item.href)
? 'text-white'
: 'text-night'}"
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}
</div>
{#if !sidebarCollapsed}
<span class="font-bold text-light truncate"
>{item.label}</span
<span
class="material-symbols-rounded {isActive(item.href)
? 'text-background'
: 'text-light'}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{/if}
{item.icon}
</span>
</div>
<span
class="font-body text-body truncate whitespace-nowrap transition-all duration-300 {isActive(
item.href,
)
? 'text-background'
: 'text-white'} {sidebarCollapsed
? 'opacity-0 max-w-0 overflow-hidden'
: 'opacity-100 max-w-[200px]'}">{item.label}</span
>
</a>
{/each}
</nav>
<!-- Team Members -->
{#if !sidebarCollapsed}
<div class="mt-4 pt-4 border-t border-light/10">
<p class="font-heading text-base text-light mb-2 px-1">Team</p>
{#if data.members && data.members.length > 0}
<div class="space-y-0.5">
{#each data.members.slice(0, 5) as member}
<div
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] hover:bg-light/5 transition-colors"
>
<div
class="w-5 h-5 rounded-full bg-gradient-to-br from-primary to-primary/50 flex items-center justify-center text-white text-xs font-medium"
>
{(member.profiles?.full_name ||
member.profiles?.email ||
"?")[0].toUpperCase()}
</div>
<span
class="text-sm font-bold text-light truncate flex-1"
>
{member.profiles?.full_name ||
member.profiles?.email?.split("@")[0] ||
"User"}
</span>
</div>
{/each}
</div>
{:else}
<p class="text-xs text-light/40 px-1">
No team members found
</p>
{/if}
</div>
{/if}
<!-- Back link -->
<div class="mt-auto pt-4">
<a
href="/"
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] text-light/50 hover:text-light hover:bg-light/5 transition-colors"
title={sidebarCollapsed ? "All Organizations" : undefined}
>
<div
class="w-5 h-5 rounded-full bg-light/20 flex items-center justify-center"
>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m15 18-6-6 6-6" />
</svg>
</div>
{#if !sidebarCollapsed}
<span class="text-sm">All Organizations</span>
{/if}
<!-- Logo at bottom -->
<div class="mt-auto">
<a href="/" title="Back to organizations">
<Logo size={sidebarCollapsed ? "sm" : "md"} />
</a>
</div>
</aside>
<!-- Main Content Area -->
<main class="flex-1 bg-night rounded-[32px] overflow-auto">
<main class="flex-1 bg-night rounded-[32px] overflow-auto relative">
{#if $navigating}
<div
class="absolute inset-0 z-10 flex items-center justify-center bg-night/80 backdrop-blur-sm"
>
<span
class="material-symbols-rounded text-primary animate-spin"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 40;"
>
progress_activity
</span>
</div>
{/if}
{@render children()}
</main>
</div>

View File

@@ -1,329 +1,21 @@
<script lang="ts">
import { Card } from "$lib/components/ui";
interface ActivityItem {
id: string;
action: string;
entity_type: string;
entity_name: string | null;
created_at: string;
profiles: { full_name: string | null; email: string } | null;
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
role: string;
members?: Array<{
id: string;
user_id: string;
role: string;
profiles: { full_name: string | null; email: string };
}>;
recentActivity?: ActivityItem[];
};
}
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",
},
];
// Get icon based on entity type
function getActivityIcon(entityType: string): string {
switch (entityType) {
case "document":
return "file";
case "kanban_card":
case "kanban_board":
return "kanban";
case "calendar_event":
return "calendar";
case "member":
return "user";
default:
return "activity";
}
}
// Format relative time
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString();
}
// Format action text
function formatAction(action: string, entityType: string): string {
const typeMap: Record<string, string> = {
document: "document",
kanban_card: "task",
kanban_board: "board",
calendar_event: "event",
member: "member",
};
const type = typeMap[entityType] || entityType;
return `${action.charAt(0).toUpperCase() + action.slice(1)} ${type}`;
}
</script>
<svelte:head>
<title>{data.org.name} - Overview | Root</title>
<title>{data.org.name} | Root</title>
</svelte:head>
<div class="p-8">
<header class="mb-8">
<h1 class="text-3xl font-heading text-light">{data.org.name}</h1>
<p class="text-light/50 mt-1">Organization Overview</p>
<div class="p-4 lg:p-6">
<header>
<h1 class="text-h1 font-heading text-white">{data.org.name}</h1>
<p class="text-body text-light/60 font-body">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-heading text-light mb-4">Recent Activity</h2>
<Card>
{#if data.recentActivity && data.recentActivity.length > 0}
<div class="divide-y divide-light/10">
{#each data.recentActivity as activity}
{@const icon = getActivityIcon(activity.entity_type)}
<div
class="flex items-center gap-4 p-4 hover:bg-light/5 transition-colors"
>
<div
class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0"
>
{#if icon === "file"}
<svg
class="w-5 h-5 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 icon === "kanban"}
<svg
class="w-5 h-5 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 icon === "calendar"}
<svg
class="w-5 h-5 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>
{:else if icon === "user"}
<svg
class="w-5 h-5 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
/>
<circle cx="12" cy="7" r="4" />
</svg>
{:else}
<svg
class="w-5 h-5 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12,6 12,12 16,14" />
</svg>
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-light font-medium">
{formatAction(
activity.action,
activity.entity_type,
)}
</p>
<p class="text-sm text-light/50 truncate">
{activity.entity_name || "Unknown"}
</p>
</div>
<span class="text-xs text-light/40 shrink-0"
>{formatRelativeTime(activity.created_at)}</span
>
</div>
{/each}
</div>
{:else}
<div class="p-6 text-center text-light/50">
<p>No recent activity</p>
</div>
{/if}
</Card>
</section>
<!-- Team Stats -->
{#if data.members && data.members.length > 0}
<section class="mt-8">
<h2 class="text-xl font-heading text-light mb-4">Team</h2>
<Card>
<div class="p-4">
<div class="flex items-center gap-2 flex-wrap">
{#each data.members.slice(0, 8) as member}
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-primary/50 flex items-center justify-center text-white font-medium"
title={member.profiles?.full_name ||
member.profiles?.email}
>
{(member.profiles?.full_name ||
member.profiles?.email ||
"?")[0].toUpperCase()}
</div>
{/each}
{#if data.members.length > 8}
<div
class="w-10 h-10 rounded-full bg-light/10 flex items-center justify-center text-light/50 text-sm"
>
+{data.members.length - 8}
</div>
{/if}
</div>
<p class="text-sm text-light/50 mt-3">
{data.members.length} team member{data.members
.length !== 1
? "s"
: ""}
</p>
</div>
</Card>
</section>
{/if}
</div>

View File

@@ -1,4 +1,7 @@
import type { PageServerLoad } from './$types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.calendar');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org, userRole } = await parent();
@@ -9,7 +12,7 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
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
const { data: events, error } = await supabase
.from('calendar_events')
.select('*')
.eq('org_id', org.id)
@@ -17,6 +20,10 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
.lte('end_time', endDate.toISOString())
.order('start_time');
if (error) {
log.error('Failed to load calendar events', { error, data: { orgId: org.id } });
}
return {
events: events ?? [],
userRole

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getContext, onMount } from "svelte";
import { Button, Modal } from "$lib/components/ui";
import { Button, Modal, Avatar } from "$lib/components/ui";
import { Calendar } from "$lib/components/calendar";
import {
getCalendarSubscribeUrl,
@@ -24,6 +24,9 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
let events = $state(data.events);
$effect(() => {
events = data.events;
});
let googleEvents = $state<CalendarEvent[]>([]);
let isOrgCalendarConnected = $state(false);
let isLoadingGoogle = $state(false);
@@ -133,56 +136,49 @@
<title>Calendar - {data.org.name} | Root</title>
</svelte:head>
<div class="p-6 h-full overflow-auto">
<header class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<h1 class="text-2xl font-bold text-light">Calendar</h1>
{#if isOrgCalendarConnected}
<div class="flex items-center gap-2">
<span
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-500/10 text-blue-400 rounded-lg"
>
<svg class="w-4 h-4" 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"
/>
</svg>
{orgCalendarName ?? "Google Calendar"}
{#if isLoadingGoogle}
<span class="animate-spin"></span>
{/if}
</span>
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-green-500/10 text-green-400 rounded-lg hover:bg-green-500/20 transition-colors"
onclick={subscribeToCalendar}
title="Add to your Google Calendar"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 5v14M5 12h14" />
</svg>
Subscribe
</button>
</div>
{/if}
</div>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name="Calendar" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Calendar</h1>
{#if isOrgCalendarConnected}
<button
type="button"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-[32px] hover:bg-primary/20 transition-colors"
onclick={subscribeToCalendar}
title="Add to your Google Calendar"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>
add
</span>
Subscribe
</button>
{/if}
<button
type="button"
class="p-1 hover:bg-dark rounded-lg transition-colors"
aria-label="More options"
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</button>
</header>
<p class="text-light/50 text-sm mb-4">
View events from connected Google Calendar. Event creation coming soon.
</p>
<Calendar
events={allEvents}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
<!-- Calendar Grid -->
<div class="flex-1 overflow-auto">
<Calendar
events={allEvents}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
</div>
</div>
<Modal

View File

@@ -1,15 +1,24 @@
import type { PageServerLoad } from './$types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.documents');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { supabase } = locals;
const { data: documents } = await supabase
const { data: documents, error } = await supabase
.from('documents')
.select('*')
.eq('org_id', org.id)
.order('name');
if (error) {
log.error('Failed to load documents', { error, data: { orgId: org.id } });
}
log.debug('Documents loaded', { data: { count: documents?.length ?? 0 } });
return {
documents: documents ?? []
};

View File

@@ -1,11 +1,6 @@
<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 { FileBrowser } from "$lib/components/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: {
@@ -17,326 +12,21 @@
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 showEditModal = $state(false);
let editingDoc = $state<Document | null>(null);
let newDocName = $state("");
let newDocType = $state<"folder" | "document">("document");
let parentFolderId = $state<string | null>(null);
let isEditing = $state(false);
const documentTree = $derived(buildDocumentTree(documents));
function handleSelect(doc: Document) {
if (doc.type === "document") {
selectedDoc = doc;
}
}
function handleDoubleClick(doc: Document) {
if (doc.type === "document") {
// Open document in new window
const url = `/${data.org.slug}/documents/${doc.id}`;
window.open(url, "_blank", "width=900,height=700");
}
}
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,
);
}
function handleEdit(doc: Document) {
editingDoc = doc;
newDocName = doc.name;
showEditModal = true;
}
async function handleRename() {
if (!editingDoc || !newDocName.trim()) return;
const { error } = await supabase
.from("documents")
.update({ name: newDocName, updated_at: new Date().toISOString() })
.eq("id", editingDoc.id);
if (!error) {
documents = documents.map((d) =>
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
);
if (selectedDoc?.id === editingDoc.id) {
selectedDoc = { ...selectedDoc, name: newDocName };
}
}
showEditModal = false;
editingDoc = null;
newDocName = "";
}
async function handleDelete(doc: Document) {
const itemType =
doc.type === "folder" ? "folder and all its contents" : "document";
if (!confirm(`Delete this ${itemType}?`)) return;
// If deleting a folder, delete all children first
if (doc.type === "folder") {
const childIds = documents
.filter((d) => d.parent_id === doc.id)
.map((d) => d.id);
if (childIds.length > 0) {
await supabase.from("documents").delete().in("id", childIds);
}
}
const { error } = await supabase
.from("documents")
.delete()
.eq("id", doc.id);
if (!error) {
documents = documents.filter(
(d) => d.id !== doc.id && d.parent_id !== doc.id,
);
if (selectedDoc?.id === doc.id) {
selectedDoc = null;
}
}
}
$effect(() => {
documents = data.documents;
});
</script>
<svelte:head>
<title
>{selectedDoc ? `${selectedDoc.name} - ` : ""}Documents - {data.org
.name} | Root</title
>
<title>Files - {data.org.name} | Root</title>
</svelte:head>
<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"
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">
{#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}
onDoubleClick={handleDoubleClick}
onAdd={handleAdd}
onMove={handleMove}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{/if}
</div>
</aside>
<main class="flex-1 overflow-hidden flex flex-col">
{#if selectedDoc}
<div
class="flex items-center justify-between p-4 border-b border-light/10"
>
<h2 class="text-lg font-semibold text-light">
{selectedDoc.name}
</h2>
<button
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {isEditing
? 'bg-primary text-white'
: 'bg-light/10 text-light hover:bg-light/20'}"
onclick={() => (isEditing = !isEditing)}
>
{isEditing ? "Preview" : "Edit"}
</button>
</div>
<div class="flex-1 overflow-auto">
<Editor
document={selectedDoc}
onSave={handleSave}
editable={isEditing}
/>
</div>
{: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 class="h-full p-4 lg:p-5">
<FileBrowser
org={data.org}
bind:documents
currentFolderId={null}
user={data.user}
/>
</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>
<Modal
isOpen={showEditModal}
onClose={() => {
showEditModal = false;
editingDoc = null;
newDocName = "";
}}
title="Rename"
>
<div class="space-y-4">
<Input
label="Name"
bind:value={newDocName}
placeholder="Enter new name"
/>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onclick={() => {
showEditModal = false;
editingDoc = null;
newDocName = "";
}}>Cancel</Button
>
<Button onclick={handleRename} disabled={!newDocName.trim()}
>Save</Button
>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,31 @@
import type { PageServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.document');
export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { org } = await parent() as { org: { id: string; slug: string } };
const { supabase } = locals;
const { id } = params;
log.debug('Redirecting document by ID', { data: { id, orgId: org.id } });
const { data: document, error: docError } = await supabase
.from('documents')
.select('type')
.eq('org_id', org.id)
.eq('id', id)
.single();
if (docError || !document) {
log.error('Document not found', { error: docError, data: { id, orgId: org.id } });
throw error(404, 'Document not found');
}
if (document.type === 'folder') {
throw redirect(302, `/${org.slug}/documents/folder/${id}`);
}
throw redirect(302, `/${org.slug}/documents/file/${id}`);
};

View File

@@ -0,0 +1,9 @@
<!-- This route redirects to /folder/[id] or /file/[id] via +page.server.ts -->
<div class="flex items-center justify-center h-full">
<span
class="material-symbols-rounded text-primary animate-spin"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 40;"
>
progress_activity
</span>
</div>

View File

@@ -0,0 +1,39 @@
import type { PageServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.file');
export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null };
const { supabase } = locals;
const { id } = params;
log.debug('Loading file by ID', { data: { id, orgId: org.id } });
const { data: document, error: docError } = await supabase
.from('documents')
.select('*')
.eq('org_id', org.id)
.eq('id', id)
.single();
if (docError || !document) {
log.error('File not found', { error: docError, data: { id, orgId: org.id } });
throw error(404, 'File not found');
}
if (document.type === 'folder') {
throw redirect(302, `/${org.slug}/documents/folder/${id}`);
}
const isKanban = document.type === 'kanban';
return {
document,
isKanban,
isFolder: false,
children: [],
user
};
};

View File

@@ -0,0 +1,572 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from "svelte";
import { Button, Modal, Input } from "$lib/components/ui";
import { DocumentViewer } from "$lib/components/documents";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
fetchBoardWithColumns,
createColumn,
moveCard,
deleteCard,
deleteColumn,
subscribeToBoard,
} from "$lib/api/kanban";
import {
getLockInfo,
acquireLock,
releaseLock,
startHeartbeat,
type LockInfo,
} from "$lib/api/document-locks";
import { createLogger } from "$lib/utils/logger";
import { toasts } from "$lib/stores/toast.svelte";
import type {
RealtimeChannel,
SupabaseClient,
} from "@supabase/supabase-js";
import type { Database, KanbanCard, Document } from "$lib/supabase/types";
import type { BoardWithColumns } from "$lib/api/kanban";
const log = createLogger("page.file-viewer");
interface Props {
data: {
org: { id: string; name: string; slug: string };
document: Document;
isKanban: boolean;
isFolder: boolean;
children: any[];
user: { id: string } | null;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let isSaving = $state(false);
// Document lock state
let lockInfo = $state<LockInfo>({
isLocked: false,
lockedBy: null,
lockedByName: null,
isOwnLock: false,
});
let hasLock = $state(false);
let stopHeartbeat: (() => void) | null = null;
// Acquire lock for document editing (not for kanban)
onMount(async () => {
if (data.isKanban || !data.user) return;
// Check current lock status
lockInfo = await getLockInfo(supabase, data.document.id, data.user.id);
if (lockInfo.isLocked && !lockInfo.isOwnLock) {
// Someone else is editing
return;
}
// Try to acquire lock
const acquired = await acquireLock(
supabase,
data.document.id,
data.user.id,
);
if (acquired) {
hasLock = true;
stopHeartbeat = startHeartbeat(
supabase,
data.document.id,
data.user.id,
);
} else {
// Refresh lock info to get who holds it
lockInfo = await getLockInfo(
supabase,
data.document.id,
data.user.id,
);
}
});
// Kanban state
let kanbanBoard = $state<BoardWithColumns | null>(null);
let realtimeChannel = $state<RealtimeChannel | null>(null);
let showCardModal = $state(false);
let selectedCard = $state<KanbanCard | null>(null);
let targetColumnId = $state<string | null>(null);
let cardModalMode = $state<"edit" | "create">("edit");
let showAddColumnModal = $state(false);
let newColumnName = $state("");
async function handleSave(content: import("$lib/supabase/types").Json) {
isSaving = true;
try {
await supabase
.from("documents")
.update({
content,
updated_at: new Date().toISOString(),
})
.eq("id", data.document.id);
} catch (err) {
log.error("Failed to save document", { error: err });
toasts.error("Failed to save document");
}
isSaving = false;
}
// Kanban functions
async function loadKanbanBoard() {
if (!data.isKanban) return;
try {
const content = data.document.content as Record<
string,
unknown
> | null;
const boardId = (content?.board_id as string) || data.document.id;
let board = await fetchBoardWithColumns(supabase, boardId).catch(
() => null,
);
if (!board) {
log.info("Auto-creating kanban_boards entry for document", {
data: { boardId, docId: data.document.id },
});
const { data: newBoard, error: createErr } = await supabase
.from("kanban_boards")
.insert({
id: data.document.id,
org_id: data.org.id,
name: data.document.name,
})
.select()
.single();
if (createErr) {
log.error("Failed to auto-create kanban board", {
error: createErr,
});
toasts.error("Failed to load kanban board");
return;
}
await supabase.from("kanban_columns").insert([
{ board_id: data.document.id, name: "To Do", position: 0 },
{
board_id: data.document.id,
name: "In Progress",
position: 1,
},
{ board_id: data.document.id, name: "Done", position: 2 },
]);
await supabase
.from("documents")
.update({
content: {
type: "kanban",
board_id: data.document.id,
} as import("$lib/supabase/types").Json,
})
.eq("id", data.document.id);
board = await fetchBoardWithColumns(
supabase,
data.document.id,
).catch(() => null);
}
kanbanBoard = board;
} catch (err) {
log.error("Failed to load kanban board", { error: err });
toasts.error("Failed to load kanban board");
}
}
$effect(() => {
if (data.isKanban) {
loadKanbanBoard();
}
});
$effect(() => {
if (!kanbanBoard) return;
const channel = subscribeToBoard(
supabase,
kanbanBoard.id,
() => loadKanbanBoard(),
() => loadKanbanBoard(),
);
realtimeChannel = channel;
return () => {
if (channel) {
supabase.removeChannel(channel);
}
};
});
onDestroy(() => {
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
// Release document lock
if (hasLock && data.user) {
stopHeartbeat?.();
releaseLock(supabase, data.document.id, data.user.id);
}
});
async function handleCardMove(
cardId: string,
toColumnId: string,
toPosition: number,
) {
try {
await moveCard(supabase, cardId, toColumnId, toPosition);
} catch (err) {
log.error("Failed to move card", { error: err });
toasts.error("Failed to move card");
}
}
function handleCardClick(card: KanbanCard) {
selectedCard = card;
cardModalMode = "edit";
showCardModal = true;
}
function handleAddCard(columnId: string) {
targetColumnId = columnId;
selectedCard = null;
cardModalMode = "create";
showCardModal = true;
}
async function handleAddColumn() {
if (!kanbanBoard || !newColumnName.trim()) return;
try {
await createColumn(
supabase,
kanbanBoard.id,
newColumnName,
kanbanBoard.columns.length,
);
newColumnName = "";
showAddColumnModal = false;
await loadKanbanBoard();
} catch (err) {
log.error("Failed to add column", { error: err });
toasts.error("Failed to add column");
}
}
async function handleDeleteColumn(columnId: string) {
if (!confirm("Delete this column and all its cards?")) return;
try {
await deleteColumn(supabase, columnId);
await loadKanbanBoard();
} catch (err) {
log.error("Failed to delete column", { error: err });
toasts.error("Failed to delete column");
}
}
async function handleDeleteCard(cardId: string) {
try {
await deleteCard(supabase, cardId);
await loadKanbanBoard();
} catch (err) {
log.error("Failed to delete card", { error: err });
toasts.error("Failed to delete card");
}
}
// JSON Import for kanban board
let fileInput = $state<HTMLInputElement | null>(null);
let isImporting = $state(false);
function triggerImport() {
fileInput?.click();
}
async function handleJsonImport(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !kanbanBoard) return;
isImporting = true;
try {
const text = await file.text();
const json = JSON.parse(text);
// Support two formats:
// 1. Full board export: { columns: [{ name, cards: [{ title, description, ... }] }] }
// 2. Flat card list: [{ title, description, column?, ... }]
if (Array.isArray(json)) {
// Flat card list — add all to first column
const firstCol = kanbanBoard.columns[0];
if (!firstCol) {
toasts.error("No columns exist to import cards into");
return;
}
let pos = firstCol.cards.length;
for (const card of json) {
await supabase.from("kanban_cards").insert({
column_id: firstCol.id,
title: card.title || "Untitled",
description: card.description || null,
priority: card.priority || null,
due_date: card.due_date || null,
position: pos++,
created_by: data.user?.id ?? null,
});
}
toasts.success(`Imported ${json.length} cards`);
} else if (json.columns && Array.isArray(json.columns)) {
// Full board format with columns
let colPos = kanbanBoard.columns.length;
for (const col of json.columns) {
// Check if column already exists by name
let targetCol = kanbanBoard.columns.find(
(c) =>
c.name.toLowerCase() ===
(col.name || "").toLowerCase(),
);
if (!targetCol) {
const { data: newCol, error: colErr } = await supabase
.from("kanban_columns")
.insert({
board_id: kanbanBoard.id,
name: col.name || `Column ${colPos}`,
position: colPos++,
})
.select()
.single();
if (colErr || !newCol) continue;
targetCol = { ...newCol, cards: [] };
}
if (col.cards && Array.isArray(col.cards)) {
let cardPos = targetCol.cards?.length ?? 0;
for (const card of col.cards) {
await supabase.from("kanban_cards").insert({
column_id: targetCol.id,
title: card.title || "Untitled",
description: card.description || null,
priority: card.priority || null,
due_date: card.due_date || null,
color: card.color || null,
position: cardPos++,
created_by: data.user?.id ?? null,
});
}
}
}
const totalCards = json.columns.reduce(
(sum: number, c: any) => sum + (c.cards?.length ?? 0),
0,
);
toasts.success(
`Imported ${json.columns.length} columns, ${totalCards} cards`,
);
} else {
toasts.error(
"Unrecognized JSON format. Expected { columns: [...] } or [{ title, ... }]",
);
return;
}
await loadKanbanBoard();
} catch (err) {
log.error("JSON import failed", { error: err });
toasts.error("Failed to import JSON — check file format");
} finally {
isImporting = false;
input.value = "";
}
}
function handleExportJson() {
if (!kanbanBoard) return;
const exportData = {
board: kanbanBoard.name,
columns: kanbanBoard.columns.map((col) => ({
name: col.name,
cards: col.cards.map((card) => ({
title: card.title,
description: card.description,
priority: card.priority,
due_date: card.due_date,
color: card.color,
assignee_id: card.assignee_id,
})),
})),
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${kanbanBoard.name || "board"}.json`;
a.click();
URL.revokeObjectURL(url);
toasts.success("Board exported as JSON");
}
</script>
<svelte:head>
<title>{data.document.name} - {data.org.name} | Root</title>
</svelte:head>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
{#if data.isKanban}
<!-- Kanban: needs its own header since DocumentViewer is for documents -->
<input
type="file"
accept=".json"
class="hidden"
bind:this={fileInput}
onchange={handleJsonImport}
/>
<header class="flex items-center gap-2 p-1">
<h1 class="flex-1 font-heading text-h1 text-white truncate">
{data.document.name}
</h1>
<Button
variant="tertiary"
size="sm"
icon="upload"
onclick={triggerImport}
loading={isImporting}
>
Import JSON
</Button>
<Button
variant="tertiary"
size="sm"
icon="download"
onclick={handleExportJson}
>
Export JSON
</Button>
</header>
<div class="flex-1 overflow-auto min-h-0">
<div class="h-full">
{#if kanbanBoard}
<KanbanBoard
columns={kanbanBoard.columns}
onCardClick={handleCardClick}
onCardMove={handleCardMove}
onAddCard={handleAddCard}
onAddColumn={() => (showAddColumnModal = true)}
onDeleteCard={handleDeleteCard}
onDeleteColumn={handleDeleteColumn}
canEdit={true}
/>
{:else}
<div class="flex items-center justify-center h-full">
<div class="text-center">
<span
class="material-symbols-rounded text-light/30 animate-spin mb-4"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
progress_activity
</span>
<p class="text-light/50">Loading board...</p>
</div>
</div>
{/if}
</div>
</div>
{:else}
<!-- Document Editor: use shared DocumentViewer component -->
<DocumentViewer
document={data.document}
onSave={handleSave}
mode="edit"
locked={lockInfo.isLocked && !lockInfo.isOwnLock}
lockedByName={lockInfo.lockedByName}
/>
{/if}
<!-- Status Bar -->
{#if isSaving}
<div class="text-body-sm text-light/50">Saving...</div>
{/if}
</div>
<!-- Kanban Card Detail Modal -->
{#if showCardModal}
<CardDetailModal
isOpen={showCardModal}
card={selectedCard}
mode={cardModalMode}
onClose={() => {
showCardModal = false;
selectedCard = null;
targetColumnId = null;
}}
onUpdate={(updatedCard) => {
if (kanbanBoard) {
kanbanBoard = {
...kanbanBoard,
columns: kanbanBoard.columns.map((col) => ({
...col,
cards: col.cards.map((c) =>
c.id === updatedCard.id ? updatedCard : c,
),
})),
};
}
}}
onDelete={(cardId) => handleDeleteCard(cardId)}
columnId={targetColumnId ?? undefined}
userId={data.user?.id}
orgId={data.org.id}
onCreate={(newCard) => {
loadKanbanBoard();
showCardModal = false;
selectedCard = null;
targetColumnId = null;
}}
/>
{/if}
<!-- Add Column Modal -->
<Modal
isOpen={showAddColumnModal}
onClose={() => {
showAddColumnModal = false;
newColumnName = "";
}}
title="Add Column"
>
<div class="space-y-4">
<Input
label="Column Name"
bind:value={newColumnName}
placeholder="e.g., To Do, In Progress, Done"
/>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showAddColumnModal = false)}
>
Cancel
</Button>
<Button onclick={handleAddColumn} disabled={!newColumnName.trim()}>
Add Column
</Button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,43 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.folder');
export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null };
const { supabase } = locals;
const { id } = params;
log.debug('Loading folder by ID', { data: { id, orgId: org.id } });
const { data: document, error: docError } = await supabase
.from('documents')
.select('*')
.eq('org_id', org.id)
.eq('id', id)
.single();
if (docError || !document) {
log.error('Folder not found', { error: docError, data: { id, orgId: org.id } });
throw error(404, 'Folder not found');
}
if (document.type !== 'folder') {
log.error('Document is not a folder', { data: { id, type: document.type } });
throw error(404, 'Not a folder');
}
// Load all documents in this org (for breadcrumb building and file listing)
const { data: allDocuments } = await supabase
.from('documents')
.select('*')
.eq('org_id', org.id)
.order('name');
return {
folder: document,
documents: allDocuments ?? [],
user
};
};

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { FileBrowser } from "$lib/components/documents";
import type { Document } from "$lib/supabase/types";
interface Props {
data: {
org: { id: string; name: string; slug: string };
folder: Document;
documents: Document[];
user: { id: string } | null;
};
}
let { data }: Props = $props();
let documents = $state(data.documents);
$effect(() => {
documents = data.documents;
});
const currentFolderId = $derived(data.folder.id);
</script>
<svelte:head>
<title>{data.folder.name} - {data.org.name} | Root</title>
</svelte:head>
<div class="h-full p-4 lg:p-5">
<FileBrowser
org={data.org}
bind:documents
{currentFolderId}
user={data.user}
/>
</div>

View File

@@ -1,15 +1,22 @@
import type { PageServerLoad } from './$types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.kanban');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { supabase } = locals;
const { data: boards } = await supabase
const { data: boards, error } = await supabase
.from('kanban_boards')
.select('*')
.eq('org_id', org.id)
.order('created_at');
if (error) {
log.error('Failed to load kanban boards', { error, data: { orgId: org.id } });
}
return {
boards: boards ?? []
};

View File

@@ -1,12 +1,22 @@
<script lang="ts">
import { getContext } from "svelte";
import { Button, Card, Modal, Input } from "$lib/components/ui";
import { getContext, onDestroy } from "svelte";
import {
Button,
Card,
Modal,
Input,
Avatar,
IconButton,
Icon,
} from "$lib/components/ui";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
fetchBoardWithColumns,
createBoard,
moveCard,
subscribeToBoard,
} from "$lib/api/kanban";
import type { RealtimeChannel } from "@supabase/supabase-js";
import type {
KanbanBoard as KanbanBoardType,
KanbanCard,
@@ -28,6 +38,9 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
let boards = $state(data.boards);
$effect(() => {
boards = data.boards;
});
let selectedBoard = $state<BoardWithColumns | null>(null);
let showCreateBoardModal = $state(false);
let showEditBoardModal = $state(false);
@@ -35,15 +48,49 @@
let selectedCard = $state<KanbanCard | null>(null);
let newBoardName = $state("");
let editBoardName = $state("");
let newBoardVisibility = $state<"team" | "personal">("team");
let editBoardVisibility = $state<"team" | "personal">("team");
let targetColumnId = $state<string | null>(null);
let cardModalMode = $state<"edit" | "create">("edit");
let realtimeChannel = $state<RealtimeChannel | null>(null);
async function loadBoard(boardId: string) {
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
}
// Realtime subscription with proper cleanup
$effect(() => {
const board = selectedBoard;
if (!board) return;
// Subscribe to realtime changes for this board
const channel = subscribeToBoard(
supabase,
board.id,
() => {
// Column changed - reload board data
loadBoard(board.id);
},
() => {
// Card changed - reload board data
loadBoard(board.id);
},
);
realtimeChannel = channel;
// Cleanup function - unsubscribe when board changes or component unmounts
return () => {
if (channel) {
supabase.removeChannel(channel);
}
};
});
// Additional cleanup on component destroy
onDestroy(() => {
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
});
async function handleCreateBoard() {
if (!newBoardName.trim()) return;
@@ -58,8 +105,6 @@
let editingBoardId = $state<string | null>(null);
let showAddColumnModal = $state(false);
let newColumnName = $state("");
let sidebarCollapsed = $state(false);
function openEditBoardModal(board: KanbanBoardType) {
editingBoardId = board.id;
editBoardName = board.name;
@@ -254,127 +299,43 @@
>
</svelte:head>
<div class="flex h-full">
<aside
class="{sidebarCollapsed
? 'w-12'
: 'w-64'} border-r border-light/10 flex flex-col transition-all duration-200"
>
<div
class="p-2 border-b border-light/10 flex items-center {sidebarCollapsed
? 'justify-center'
: 'justify-between gap-2'}"
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name="Kanban" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Kanban</h1>
<Button size="md" onclick={() => (showCreateBoardModal = true)}
>+ New</Button
>
{#if !sidebarCollapsed}
<h2 class="font-semibold text-light px-2">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>
{/if}
<button
class="p-1.5 rounded-lg hover:bg-light/10 text-light/50 hover:text-light transition-colors"
onclick={() => (sidebarCollapsed = !sidebarCollapsed)}
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<svg
class="w-4 h-4 transition-transform {sidebarCollapsed
? 'rotate-180'
: ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<IconButton
title="More options"
onclick={() => selectedBoard && openEditBoardModal(selectedBoard)}
>
<Icon name="more_horiz" size={24} />
</IconButton>
</header>
<!-- Board selector (compact) -->
{#if boards.length > 1}
<div class="flex gap-2 overflow-x-auto pb-2">
{#each boards as board}
<button
type="button"
class="px-4 py-2 rounded-[32px] text-sm font-body whitespace-nowrap transition-colors {selectedBoard?.id ===
board.id
? 'bg-primary text-night'
: 'bg-dark text-light hover:bg-dark/80'}"
onclick={() => loadBoard(board.id)}
>
<path d="m11 17-5-5 5-5M17 17l-5-5 5-5" />
</svg>
</button>
{board.name}
</button>
{/each}
</div>
{/if}
<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}
<div
class="group flex items-center gap-1 px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer {selectedBoard?.id ===
board.id
? 'bg-primary text-white'
: 'text-light/70 hover:bg-light/5'}"
onclick={() => loadBoard(board.id)}
role="button"
tabindex="0"
>
<span class="flex-1 truncate">{board.name}</span>
<div
class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity"
>
<button
class="p-1 rounded hover:bg-light/20"
onclick={(e) => {
e.stopPropagation();
openEditBoardModal(board);
}}
title="Rename"
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
</button>
<button
class="p-1 rounded hover:bg-error/20 hover:text-error"
onclick={(e) => handleDeleteBoard(e, board)}
title="Delete"
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3,6 5,6 21,6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
</button>
</div>
</div>
{/each}
{/if}
</div>
</aside>
<main class="flex-1 overflow-hidden p-6">
<!-- Kanban Board -->
<div class="flex-1 overflow-hidden">
{#if selectedBoard}
<header class="mb-6">
<h1 class="text-2xl font-bold text-light">
{selectedBoard.name}
</h1>
</header>
<KanbanBoard
columns={selectedBoard.columns}
onCardClick={handleCardClick}
@@ -384,25 +345,30 @@
onDeleteCard={handleCardDelete}
onDeleteColumn={handleDeleteColumn}
/>
{:else}
{:else if boards.length === 0}
<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"
<span
class="material-symbols-rounded text-light/30 mb-4 block"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
<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>
view_kanban
</span>
<p class="mb-4">Kanban boards are now managed in Files</p>
<Button
onclick={() =>
(window.location.href = `/${data.org.slug}/documents`)}
>
Go to Files
</Button>
</div>
</div>
{:else}
<div class="h-full flex items-center justify-center text-light/40">
<p>Select a board above</p>
</div>
{/if}
</main>
</div>
</div>
<Modal
@@ -418,7 +384,7 @@
/>
<div class="flex justify-end gap-2">
<Button
variant="ghost"
variant="tertiary"
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
>
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
@@ -440,8 +406,9 @@
placeholder="Board name"
/>
<div class="flex justify-end gap-2">
<Button variant="ghost" onclick={() => (showEditBoardModal = false)}
>Cancel</Button
<Button
variant="tertiary"
onclick={() => (showEditBoardModal = false)}>Cancel</Button
>
<Button onclick={handleEditBoard} disabled={!editBoardName.trim()}
>Save</Button
@@ -462,8 +429,9 @@
placeholder="e.g. To Do, In Progress, Done"
/>
<div class="flex justify-end gap-2">
<Button variant="ghost" onclick={() => (showAddColumnModal = false)}
>Cancel</Button
<Button
variant="tertiary"
onclick={() => (showAddColumnModal = false)}>Cancel</Button
>
<Button
onclick={handleCreateColumn}
@@ -486,5 +454,6 @@
mode={cardModalMode}
columnId={targetColumnId ?? undefined}
userId={data.user?.id}
orgId={data.org.id}
onCreate={handleCardCreated}
/>

View File

@@ -1,61 +1,64 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.settings');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org, userRole } = await parent();
const { org, userRole } = await parent() as { org: { id: string; slug: string }; userRole: string };
// Only admins and owners can access settings
if (userRole !== 'owner' && userRole !== 'admin') {
redirect(303, `/${(org as any).slug}`);
redirect(303, `/${org.slug}`);
}
const orgId = (org as any).id;
const orgId = org.id;
// Get org members with profiles
const { data: members } = await locals.supabase
.from('org_members')
.select(`
id,
user_id,
role,
role_id,
created_at,
profiles:user_id (
// Fetch all settings data in parallel
const [membersResult, rolesResult, invitesResult, calendarResult] = await Promise.all([
// Get org members with profiles
locals.supabase
.from('org_members')
.select(`
id,
email,
full_name,
avatar_url
)
`)
.eq('org_id', orgId);
// Get org roles
const { data: roles } = await locals.supabase
.from('org_roles')
.select('*')
.eq('org_id', orgId)
.order('position');
// Get pending invites
const { data: invites } = await locals.supabase
.from('org_invites')
.select('*')
.eq('org_id', orgId)
.is('accepted_at', null)
.gt('expires_at', new Date().toISOString());
// Get org Google Calendar connection
const { data: orgCalendar } = await locals.supabase
.from('org_google_calendars')
.select('*')
.eq('org_id', orgId)
.single();
user_id,
role,
role_id,
created_at,
profiles:user_id (
id,
email,
full_name,
avatar_url
)
`)
.eq('org_id', orgId),
// Get org roles
locals.supabase
.from('org_roles')
.select('*')
.eq('org_id', orgId)
.order('position'),
// Get pending invites
locals.supabase
.from('org_invites')
.select('*')
.eq('org_id', orgId)
.is('accepted_at', null)
.gt('expires_at', new Date().toISOString()),
// Get org Google Calendar connection
locals.supabase
.from('org_google_calendars')
.select('*')
.eq('org_id', orgId)
.single()
]);
return {
members: members ?? [],
roles: roles ?? [],
invites: invites ?? [],
orgCalendar,
members: membersResult.data ?? [],
roles: rolesResult.data ?? [],
invites: invitesResult.data ?? [],
orgCalendar: calendarResult.data,
userRole
};
};

View File

@@ -2,12 +2,22 @@
import { getContext, onMount } from "svelte";
import { page } from "$app/stores";
import { invalidateAll } from "$app/navigation";
import { Button, Modal, Card, Input } from "$lib/components/ui";
import {
Button,
Modal,
Card,
Input,
Select,
Icon,
Avatar,
IconButton,
} from "$lib/components/ui";
import { SettingsGeneral } from "$lib/components/settings";
import {
extractCalendarId,
getCalendarSubscribeUrl,
} from "$lib/api/google-calendar";
import { theme, PRESET_COLORS, type ThemeMode } from "$lib/stores/theme";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
@@ -18,18 +28,20 @@
calendar_name: string | null;
}
interface ProfileData {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
}
interface Member {
id: string;
user_id: string;
role: string;
role_id: string | null;
created_at: string;
profiles: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
profiles: ProfileData | ProfileData[] | null;
}
interface OrgRole {
@@ -55,7 +67,12 @@
interface Props {
data: {
org: { id: string; name: string; slug: string };
org: {
id: string;
name: string;
slug: string;
avatar_url?: string | null;
};
user: { id: string; email?: string } | null;
userRole: string;
members: Member[];
@@ -70,14 +87,16 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
// Active tab
let activeTab = $state<
"general" | "members" | "roles" | "integrations" | "appearance"
>("general");
let activeTab = $state<"general" | "members" | "roles" | "integrations">(
"general",
);
// General settings state
let orgName = $state(data.org.name);
let orgSlug = $state(data.org.slug);
let isSavingGeneral = $state(false);
const tabs: { id: typeof activeTab; label: string }[] = [
{ id: "general", label: "General" },
{ id: "members", label: "Members" },
{ id: "roles", label: "Roles" },
{ id: "integrations", label: "Integrations" },
];
// Members state
let members = $state<Member[]>(data.members as Member[]);
@@ -176,18 +195,45 @@
}
});
// General settings functions
async function saveGeneralSettings() {
isSavingGeneral = true;
async function deleteOrganization() {
if (!isOwner) return;
const confirmText = prompt(
`Type "${data.org.name}" to confirm deletion:`,
);
if (confirmText !== data.org.name) return;
const { error } = await supabase
.from("organizations")
.update({ name: orgName, slug: orgSlug })
.delete()
.eq("id", data.org.id);
if (!error && orgSlug !== data.org.slug) {
window.location.href = `/${orgSlug}/settings`;
if (error) {
toasts.error("Failed to delete organization.");
return;
}
isSavingGeneral = false;
window.location.href = "/";
}
async function leaveOrganization() {
if (isOwner) {
toasts.error(
"Owners cannot leave. Transfer ownership first or delete the organization.",
);
return;
}
if (!confirm(`Are you sure you want to leave ${data.org.name}?`))
return;
const { error } = await supabase
.from("org_members")
.delete()
.eq("org_id", data.org.id)
.eq("user_id", data.user!.id);
if (error) {
toasts.error("Failed to leave organization.");
return;
}
window.location.href = "/";
}
// Member functions
@@ -219,13 +265,12 @@
.single();
if (!error && invite) {
// Remove old invite from UI if exists
invites = invites.filter((i) => i.email !== email);
invites = [...invites, invite as Invite];
inviteEmail = "";
showInviteModal = false;
} else if (error) {
alert("Failed to send invite: " + error.message);
toasts.error("Failed to send invite: " + error.message);
}
isSendingInvite = false;
}
@@ -243,11 +288,15 @@
async function updateMemberRole() {
if (!selectedMember) return;
await supabase
const { error } = await supabase
.from("org_members")
.update({ role: selectedMemberRole })
.eq("id", selectedMember.id);
if (error) {
toasts.error("Failed to update role.");
return;
}
members = members.map((m) =>
m.id === selectedMember!.id
? { ...m, role: selectedMemberRole }
@@ -258,14 +307,23 @@
async function removeMember() {
if (!selectedMember) return;
const rp = selectedMember.profiles;
const prof = Array.isArray(rp) ? rp[0] : rp;
if (
!confirm(
`Remove ${selectedMember.profiles.full_name || selectedMember.profiles.email} from the organization?`,
`Remove ${prof?.full_name || prof?.email || "this member"} from the organization?`,
)
)
return;
await supabase.from("org_members").delete().eq("id", selectedMember.id);
const { error } = await supabase
.from("org_members")
.delete()
.eq("id", selectedMember.id);
if (error) {
toasts.error("Failed to remove member.");
return;
}
members = members.filter((m) => m.id !== selectedMember!.id);
showMemberModal = false;
}
@@ -348,7 +406,14 @@
)
return;
await supabase.from("org_roles").delete().eq("id", role.id);
const { error } = await supabase
.from("org_roles")
.delete()
.eq("id", role.id);
if (error) {
toasts.error("Failed to delete role.");
return;
}
roles = roles.filter((r) => r.id !== role.id);
}
@@ -417,192 +482,56 @@
async function disconnectOrgCalendar() {
if (!confirm("Disconnect Google Calendar?")) return;
await supabase
const { error } = await supabase
.from("org_google_calendars")
.delete()
.eq("org_id", data.org.id);
if (error) {
toasts.error("Failed to disconnect calendar.");
return;
}
orgCalendar = null;
}
async function deleteOrganization() {
if (!isOwner) return;
const confirmText = prompt(
`Type "${data.org.name}" to confirm deletion:`,
);
if (confirmText !== data.org.name) return;
await supabase.from("organizations").delete().eq("id", data.org.id);
window.location.href = "/";
}
async function leaveOrganization() {
if (isOwner) {
alert(
"Owners cannot leave. Transfer ownership first or delete the organization.",
);
return;
}
if (!confirm(`Are you sure you want to leave ${data.org.name}?`))
return;
const { error } = await supabase
.from("org_members")
.delete()
.eq("org_id", data.org.id)
.eq("user_id", data.user?.id);
if (!error) {
window.location.href = "/";
}
}
</script>
<svelte:head>
<title>Settings - {data.org.name} | Root</title>
</svelte:head>
<div class="p-6 h-full overflow-auto">
<header class="mb-6">
<h1 class="text-2xl font-bold text-light">Settings</h1>
<p class="text-light/50 mt-1">Manage {data.org.name}</p>
</header>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4 overflow-auto">
<!-- Header -->
<div class="flex flex-col gap-4">
<header class="flex flex-wrap items-center gap-2 p-1 rounded-[32px]">
<Avatar name="Settings" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Settings</h1>
<IconButton title="More options">
<Icon name="more_horiz" size={24} />
</IconButton>
</header>
<!-- Tabs -->
<div class="flex gap-1 mb-6 border-b border-light/10">
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'general'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "general")}>General</button
>
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'members'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "members")}>Members</button
>
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'roles'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "roles")}>Roles</button
>
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'integrations'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "integrations")}>Integrations</button
>
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'appearance'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "appearance")}>Appearance</button
>
<!-- Pill Tab Navigation -->
<div class="flex flex-wrap gap-4">
{#each tabs as tab}
<Button
variant={activeTab === tab.id ? "primary" : "secondary"}
size="md"
onclick={() => (activeTab = tab.id)}
>
{tab.label}
</Button>
{/each}
</div>
</div>
<!-- General Tab -->
{#if activeTab === "general"}
<div class="space-y-6 max-w-2xl">
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold text-light mb-4">
Organization Details
</h2>
<div class="space-y-4">
<div>
<label
for="org-name"
class="block text-sm font-medium text-light mb-1"
>Name</label
>
<input
id="org-name"
type="text"
bind:value={orgName}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
/>
</div>
<div>
<label
for="org-slug"
class="block text-sm font-medium text-light mb-1"
>URL Slug</label
>
<div class="flex items-center gap-2">
<span class="text-light/40 text-sm"
>yoursite.com/</span
>
<input
id="org-slug"
type="text"
bind:value={orgSlug}
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-light font-mono text-sm focus:outline-none focus:border-primary"
/>
</div>
<p class="text-xs text-light/40 mt-1">
Changing the slug will update all URLs for this
organization.
</p>
</div>
<div class="pt-2">
<Button
onclick={saveGeneralSettings}
loading={isSavingGeneral}>Save Changes</Button
>
</div>
</div>
</div>
</Card>
{#if !isOwner}
<Card>
<div class="p-6 border-l-4 border-warning">
<h2 class="text-lg font-semibold text-warning">
Leave Organization
</h2>
<p class="text-sm text-light/50 mt-1">
Leave this organization. You will need to be
re-invited to rejoin.
</p>
<div class="mt-4">
<Button
variant="secondary"
onclick={leaveOrganization}
>Leave {data.org.name}</Button
>
</div>
</div>
</Card>
{/if}
{#if isOwner}
<Card>
<div class="p-6 border-l-4 border-error">
<h2 class="text-lg font-semibold text-error">
Danger Zone
</h2>
<p class="text-sm text-light/50 mt-1">
Permanently delete this organization and all its
data.
</p>
<div class="mt-4">
<Button
variant="danger"
onclick={deleteOrganization}
>Delete Organization</Button
>
</div>
</div>
</Card>
{/if}
</div>
<SettingsGeneral
{supabase}
org={data.org}
{isOwner}
onLeave={leaveOrganization}
onDelete={deleteOrganization}
/>
{/if}
<!-- Members Tab -->
@@ -654,18 +583,20 @@
</p>
</div>
<div class="flex items-center gap-2">
<button
class="text-xs text-light/50 hover:text-light"
<Button
variant="tertiary"
size="sm"
onclick={() =>
navigator.clipboard.writeText(
`${window.location.origin}/invite/${invite.token}`,
)}>Copy Link</button
)}>Copy Link</Button
>
<button
class="text-xs text-error hover:text-error/80"
<Button
variant="danger"
size="sm"
onclick={() =>
cancelInvite(invite.id)}
>Cancel</button
>Cancel</Button
>
</div>
</div>
@@ -679,7 +610,10 @@
<Card>
<div class="divide-y divide-light/10">
{#each members as member}
{@const profile = member.profiles}
{@const rawProfile = member.profiles}
{@const profile = Array.isArray(rawProfile)
? rawProfile[0]
: rawProfile}
<div
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
>
@@ -717,10 +651,11 @@
)?.color ?? '#6366f1'}">{member.role}</span
>
{#if member.user_id !== data.user?.id && member.role !== "owner"}
<button
class="text-sm text-light/50 hover:text-light"
<Button
variant="tertiary"
size="sm"
onclick={() => openMemberModal(member)}
>Edit</button
>Edit</Button
>
{/if}
</div>
@@ -741,16 +676,7 @@
Create custom roles with specific permissions.
</p>
</div>
<Button onclick={() => openRoleModal()}>
<svg
class="w-4 h-4 mr-2"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 5v14M5 12h14" />
</svg>
<Button onclick={() => openRoleModal()} icon="add">
Create Role
</Button>
</div>
@@ -783,17 +709,19 @@
</div>
<div class="flex items-center gap-2">
{#if !role.is_system || role.name !== "Owner"}
<button
class="text-sm text-light/50 hover:text-light"
<Button
variant="tertiary"
size="sm"
onclick={() => openRoleModal(role)}
>Edit</button
>Edit</Button
>
{/if}
{#if !role.is_system}
<button
class="text-sm text-error/70 hover:text-error"
<Button
variant="danger"
size="sm"
onclick={() => deleteRole(role)}
>Delete</button
>Delete</Button
>
{/if}
</div>
@@ -977,198 +905,6 @@
</Card>
</div>
{/if}
<!-- Appearance Tab -->
{#if activeTab === "appearance"}
<div class="space-y-6 max-w-2xl">
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold text-light mb-4">Theme</h2>
<p class="text-sm text-light/50 mb-6">
Customize the look and feel of your workspace.
</p>
<!-- Mode Selector -->
<div class="mb-6">
<label class="block text-sm font-medium text-light mb-3"
>Mode</label
>
<div class="flex gap-2">
{#each ["dark", "light", "system"] as mode}
<button
class="flex-1 px-4 py-3 rounded-lg border transition-all {$theme.mode ===
mode
? 'border-primary bg-primary/10 text-primary'
: 'border-light/20 text-light/60 hover:border-light/40'}"
onclick={() =>
theme.setMode(mode as ThemeMode)}
>
<div
class="flex flex-col items-center gap-1"
>
{#if mode === "dark"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
/>
</svg>
{:else if mode === "light"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5" />
<line
x1="12"
y1="1"
x2="12"
y2="3"
/>
<line
x1="12"
y1="21"
x2="12"
y2="23"
/>
<line
x1="4.22"
y1="4.22"
x2="5.64"
y2="5.64"
/>
<line
x1="18.36"
y1="18.36"
x2="19.78"
y2="19.78"
/>
<line
x1="1"
y1="12"
x2="3"
y2="12"
/>
<line
x1="21"
y1="12"
x2="23"
y2="12"
/>
<line
x1="4.22"
y1="19.78"
x2="5.64"
y2="18.36"
/>
<line
x1="18.36"
y1="5.64"
x2="19.78"
y2="4.22"
/>
</svg>
{:else}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect
x="2"
y="3"
width="20"
height="14"
rx="2"
/>
<line
x1="8"
y1="21"
x2="16"
y2="21"
/>
<line
x1="12"
y1="17"
x2="12"
y2="21"
/>
</svg>
{/if}
<span class="text-xs capitalize"
>{mode}</span
>
</div>
</button>
{/each}
</div>
</div>
<!-- Accent Color -->
<div>
<label class="block text-sm font-medium text-light mb-3"
>Accent Color</label
>
<div class="grid grid-cols-4 gap-3">
{#each PRESET_COLORS as color}
<button
class="group relative h-12 rounded-lg transition-all {$theme.primaryColor ===
color.primary
? 'ring-2 ring-offset-2 ring-offset-dark ring-white'
: 'hover:scale-105'}"
style="background-color: {color.primary}"
onclick={() =>
theme.setPrimaryColor(color.primary)}
title={color.name}
>
{#if $theme.primaryColor === color.primary}
<svg
class="absolute inset-0 m-auto w-5 h-5 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20,6 9,17 4,12" />
</svg>
{/if}
</button>
{/each}
</div>
<p class="text-xs text-light/40 mt-3">
Selected: {PRESET_COLORS.find(
(c) => c.primary === $theme.primaryColor,
)?.name || "Custom"}
</p>
</div>
</div>
</Card>
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold text-light mb-2">
Reset Theme
</h2>
<p class="text-sm text-light/50 mb-4">
Reset to the default theme settings.
</p>
<Button variant="secondary" onclick={() => theme.reset()}>
Reset to Default
</Button>
</div>
</Card>
</div>
{/if}
</div>
<!-- Invite Member Modal -->
@@ -1178,44 +914,34 @@
title="Invite Member"
>
<div class="space-y-4">
<div>
<label
for="invite-email"
class="block text-sm font-medium text-light mb-1"
>Email address</label
>
<input
id="invite-email"
type="email"
bind:value={inviteEmail}
placeholder="colleague@example.com"
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
/>
</div>
<div>
<label
for="invite-role"
class="block text-sm font-medium text-light mb-1">Role</label
>
<select
id="invite-role"
bind:value={inviteRole}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
>
<option value="viewer">Viewer - Can view content</option>
<option value="commenter"
>Commenter - Can view and comment</option
>
<option value="editor"
>Editor - Can create and edit content</option
>
<option value="admin"
>Admin - Can manage members and settings</option
>
</select>
</div>
<Input
type="email"
label="Email address"
bind:value={inviteEmail}
placeholder="colleague@example.com"
/>
<Select
label="Role"
bind:value={inviteRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer - Can view content" },
{
value: "commenter",
label: "Commenter - Can view and comment",
},
{
value: "editor",
label: "Editor - Can create and edit content",
},
{
value: "admin",
label: "Admin - Can manage members and settings",
},
]}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showInviteModal = false)}
<Button variant="tertiary" onclick={() => (showInviteModal = false)}
>Cancel</Button
>
<Button
@@ -1234,48 +960,44 @@
title="Edit Member"
>
{#if selectedMember}
{@const rawP = selectedMember.profiles}
{@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP}
<div class="space-y-4">
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
>
{(selectedMember.profiles.full_name ||
selectedMember.profiles.email ||
{(memberProfile?.full_name ||
memberProfile?.email ||
"?")[0].toUpperCase()}
</div>
<div>
<p class="text-light font-medium">
{selectedMember.profiles.full_name || "No name"}
{memberProfile?.full_name || "No name"}
</p>
<p class="text-sm text-light/50">
{selectedMember.profiles.email}
{memberProfile?.email || "No email"}
</p>
</div>
</div>
<div>
<label
for="member-role"
class="block text-sm font-medium text-light mb-1"
>Role</label
>
<select
id="member-role"
bind:value={selectedMemberRole}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
>
<option value="viewer">Viewer</option>
<option value="commenter">Commenter</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
<Select
label="Role"
bind:value={selectedMemberRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer" },
{ value: "commenter", label: "Commenter" },
{ value: "editor", label: "Editor" },
{ value: "admin", label: "Admin" },
]}
/>
<div class="flex items-center justify-between pt-2">
<Button variant="danger" onclick={removeMember}
>Remove from Org</Button
>
<div class="flex gap-2">
<Button
variant="ghost"
variant="tertiary"
onclick={() => (showMemberModal = false)}>Cancel</Button
>
<Button onclick={updateMemberRole}>Save</Button>
@@ -1292,20 +1014,12 @@
title={editingRole ? "Edit Role" : "Create Role"}
>
<div class="space-y-4">
<div>
<label
for="role-name"
class="block text-sm font-medium text-light mb-1">Name</label
>
<input
id="role-name"
type="text"
bind:value={newRoleName}
placeholder="e.g., Moderator"
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
disabled={editingRole?.is_system}
/>
</div>
<Input
label="Name"
bind:value={newRoleName}
placeholder="e.g., Moderator"
disabled={editingRole?.is_system}
/>
<div>
<label class="block text-sm font-medium text-light mb-2"
>Color</label
@@ -1357,7 +1071,7 @@
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showRoleModal = false)}
<Button variant="tertiary" onclick={() => (showRoleModal = false)}
>Cancel</Button
>
<Button
@@ -1412,8 +1126,9 @@
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showConnectModal = false)}
>Cancel</Button
<Button
variant="tertiary"
onclick={() => (showConnectModal = false)}>Cancel</Button
>
<Button
onclick={handleSaveOrgCalendar}

View File

@@ -2,8 +2,10 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { GOOGLE_API_KEY } from '$env/static/private';
import { fetchPublicCalendarEvents } from '$lib/api/google-calendar';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api:google-calendar');
// Fetch events from a public Google Calendar
export const GET: RequestHandler = async ({ url, locals }) => {
const orgId = url.searchParams.get('org_id');
@@ -11,6 +13,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return json({ error: 'org_id required' }, { status: 400 });
}
// Auth check — must be logged in and a member of this org
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const { data: membership } = await locals.supabase
.from('org_members')
.select('id')
.eq('org_id', orgId)
.eq('user_id', user.id)
.single();
if (!membership) {
return json({ error: 'Forbidden' }, { status: 403 });
}
if (!GOOGLE_API_KEY) {
return json({ error: 'Google API key not configured' }, { status: 500 });
}
@@ -24,7 +43,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
.single();
if (dbError) {
console.error('DB error fetching calendar:', dbError);
log.error('DB error fetching calendar', { data: { orgId }, error: dbError });
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
}
@@ -32,7 +51,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
}
console.log('Fetching events for calendar:', (orgCal as any).calendar_id);
log.debug('Fetching events for calendar', { data: { calendarId: orgCal.calendar_id } });
// Fetch events for the next 3 months
const now = new Date();
@@ -40,21 +59,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0);
const events = await fetchPublicCalendarEvents(
(orgCal as any).calendar_id,
orgCal.calendar_id,
GOOGLE_API_KEY,
timeMin,
timeMax
);
console.log('Fetched', events.length, 'events');
log.debug('Fetched events', { data: { count: events.length } });
return json({
events,
calendar_id: (orgCal as any).calendar_id,
calendar_name: (orgCal as any).calendar_name
calendar_id: orgCal.calendar_id,
calendar_name: orgCal.calendar_name
});
} catch (err) {
console.error('Failed to fetch calendar events:', err);
log.error('Failed to fetch calendar events', { data: { orgId }, error: err });
return json({ error: 'Failed to fetch events. Make sure the calendar is public.', events: [] }, { status: 500 });
}
};

View File

@@ -1,9 +1,14 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
function safeRedirect(target: string): string {
if (target.startsWith('/') && !target.startsWith('//')) return target;
return '/';
}
export const GET: RequestHandler = async ({ url, locals }) => {
const code = url.searchParams.get('code');
const next = url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/';
const next = safeRedirect(url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/');
if (code) {
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);

View File

@@ -21,10 +21,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
};
}
const inv = invite as any;
// Check if invite is expired
if (new Date(inv.expires_at) < new Date()) {
if (invite.expires_at && new Date(invite.expires_at) < new Date()) {
return {
error: 'This invite has expired',
token
@@ -36,10 +34,10 @@ export const load: PageServerLoad = async ({ params, locals }) => {
return {
invite: {
id: inv.id,
email: inv.email,
role: inv.role,
org: inv.organizations
id: invite.id,
email: invite.email,
role: invite.role,
org: (invite as any).organizations // join not typed
},
user,
token

View File

@@ -46,6 +46,7 @@
org_id: data.invite.org.id,
user_id: data.user.id,
role: data.invite.role,
joined_at: new Date().toISOString(),
});
if (memberError) {
@@ -84,7 +85,7 @@
function goToSignup() {
const returnUrl = `/invite/${data.token}`;
goto(
`/signup?redirect=${encodeURIComponent(returnUrl)}&email=${encodeURIComponent(data.invite?.email || "")}`,
`/login?tab=signup&redirect=${encodeURIComponent(returnUrl)}&email=${encodeURIComponent(data.invite?.email || "")}`,
);
}
</script>
@@ -166,7 +167,7 @@
</div>
<p class="text-light/40 text-xs mt-3">
Wrong account? <a
href="/logout"
href="/auth/logout"
class="text-primary hover:underline">Sign out</a
>
</p>
@@ -177,7 +178,7 @@
</p>
<div class="flex flex-col gap-2">
<Button onclick={goToLogin}>Sign In</Button>
<Button onclick={goToSignup} variant="ghost"
<Button onclick={goToSignup} variant="tertiary"
>Create Account</Button
>
</div>

View File

@@ -1,4 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap');
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@@ -26,8 +27,26 @@
/* Typography - Figma Fonts */
--font-heading: 'Tilt Warp', sans-serif;
--font-body: 'Work Sans', sans-serif;
--font-input: 'Inter', sans-serif;
--font-sans: 'Work Sans', system-ui, -apple-system, sans-serif;
/* Font Sizes - Figma Text Styles (--text-* → text-* utilities) */
/* Headings (heading font) */
--text-h1: 32px;
--text-h2: 28px;
--text-h3: 24px;
--text-h4: 20px;
--text-h5: 16px;
--text-h6: 14px;
/* Button text (heading font) */
--text-btn-lg: 20px;
--text-btn-md: 16px;
--text-btn-sm: 14px;
/* Body text (body font) */
--text-body: 16px;
--text-body-md: 14px;
--text-body-sm: 12px;
/* Border Radius - Figma Design */
--radius-sm: 8px;
--radius-md: 16px;
@@ -37,127 +56,44 @@
--radius-circle: 128px;
}
/* Base styles */
html, body {
background-color: var(--color-background);
color: var(--color-light);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Base layer — element defaults via Tailwind utilities */
@layer base {
html, body {
@apply bg-background text-light font-body antialiased;
}
h1 { @apply font-heading font-normal text-h1 leading-normal; }
h2 { @apply font-heading font-normal text-h2 leading-normal; }
h3 { @apply font-heading font-normal text-h3 leading-normal; }
h4 { @apply font-heading font-normal text-h4 leading-normal; }
h5 { @apply font-heading font-normal text-h5 leading-normal; }
h6 { @apply font-heading font-normal text-h6 leading-normal; }
}
/* Headings */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 400;
}
/* Scrollbar — no Tailwind equivalent for pseudo-elements */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { @apply bg-night rounded-pill; }
::-webkit-scrollbar-thumb:hover { @apply bg-dark; }
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
/* Focus & Selection — pseudo-elements require raw CSS */
:focus-visible { @apply outline-2 outline-primary outline-offset-2; }
::selection { @apply text-light; background-color: rgba(0, 163, 224, 0.3); }
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-night);
border-radius: var(--radius-pill);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-dark);
}
/* Focus styles */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Selection */
::selection {
background-color: rgba(0, 163, 224, 0.3);
color: var(--color-light);
}
/* Prose/Markdown styles */
.prose {
line-height: 1.6;
}
.prose p {
margin: 0.5em 0;
}
.prose strong {
font-weight: 700;
color: var(--color-light);
}
.prose code {
background: var(--color-night);
padding: 0.15em 0.4em;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
color: var(--color-primary);
}
.prose pre {
background: var(--color-night);
padding: 1em;
border-radius: var(--radius-sm);
overflow-x: auto;
margin: 0.5em 0;
}
.prose pre code {
background: none;
padding: 0;
color: var(--color-light);
}
.prose blockquote {
border-left: 3px solid var(--color-primary);
padding-left: 1em;
margin: 0.5em 0;
color: var(--color-text-muted);
font-style: italic;
}
.prose ul, .prose ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.prose ul {
list-style-type: disc;
}
.prose ol {
list-style-type: decimal;
}
.prose li {
margin: 0.25em 0;
}
.prose h1, .prose h2, .prose h3, .prose h4 {
color: var(--color-light);
margin: 0.75em 0 0.5em;
font-family: var(--font-heading);
}
.prose a {
color: var(--color-primary);
text-decoration: underline;
}
.prose hr {
border: none;
border-top: 1px solid var(--color-dark);
margin: 1em 0;
/* Prose/Markdown styles — used by the TipTap editor */
@layer components {
.prose { @apply leading-relaxed; }
.prose p { @apply my-2; }
.prose strong { @apply font-bold text-light; }
.prose code { @apply bg-night px-1.5 py-0.5 rounded text-primary text-[0.9em]; font-family: 'Consolas', 'Monaco', monospace; }
.prose pre { @apply bg-night p-4 rounded-sm overflow-x-auto my-2; }
.prose pre code { @apply bg-transparent p-0 text-light; }
.prose blockquote { @apply border-l-3 border-primary pl-4 my-2 text-text-muted italic; }
.prose ul, .prose ol { @apply pl-6 my-2; }
.prose ul { @apply list-disc; }
.prose ol { @apply list-decimal; }
.prose li { @apply my-1; }
.prose h1, .prose h2, .prose h3, .prose h4 { @apply text-light font-heading; margin: 0.75em 0 0.5em; }
.prose a { @apply text-primary underline; }
.prose hr { @apply border-t border-dark my-4; }
}

View File

@@ -4,17 +4,29 @@
import { goto } from "$app/navigation";
import { page } from "$app/stores";
let email = $state("");
let email = $state($page.url.searchParams.get("email") || "");
let password = $state("");
let isLoading = $state(false);
let error = $state("");
let mode = $state<"login" | "signup">("login");
let signupSuccess = $state(false);
let mode = $state<"login" | "signup">(
($page.url.searchParams.get("tab") as "login" | "signup") || "login",
);
const supabase = createClient();
// Get redirect URL from query params (for invite flow)
const redirectUrl = $derived($page.url.searchParams.get("redirect") || "/");
// Show error from callback (e.g. OAuth failure)
const callbackError = $page.url.searchParams.get("error");
if (callbackError) {
error =
callbackError === "auth_callback_error"
? "Authentication failed. Please try again."
: callbackError;
}
async function handleSubmit() {
if (!email || !password) {
error = "Please fill in all fields";
@@ -32,17 +44,24 @@
password,
});
if (authError) throw authError;
goto(redirectUrl);
} else {
const { error: authError } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
const { data: signUpData, error: authError } =
await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirectUrl)}`,
},
});
if (authError) throw authError;
// If email confirmation is required, session will be null
if (signUpData.session) {
goto(redirectUrl);
} else {
signupSuccess = true;
}
}
goto(redirectUrl);
} catch (e: unknown) {
error = e instanceof Error ? e.message : "An error occurred";
} finally {
@@ -79,97 +98,129 @@
</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}
{#if signupSuccess}
<div class="text-center py-4">
<div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center"
>
<span
class="material-symbols-rounded text-success"
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 32;"
>
mark_email_read
</span>
</div>
<h2 class="text-xl font-semibold text-light mb-2">
Check your email
</h2>
<p class="text-light/60 text-sm mb-4">
We've sent a confirmation link to <strong
class="text-light">{email}</strong
>. Click the link to activate your account.
</p>
<Button
variant="tertiary"
onclick={() => {
signupSuccess = false;
mode = "login";
}}
>
Back to Login
</Button>
</div>
{/if}
{:else}
<h2 class="text-xl font-semibold text-light mb-6">
{mode === "login" ? "Welcome back" : "Create your account"}
</h2>
<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")}
{#if error}
<div
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm"
>
Sign up
</button>
{:else}
Already have an account?
<button
class="text-primary hover:underline"
onclick={() => (mode = "login")}
>
Log in
</button>
{error}
</div>
{/if}
</p>
<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>
{/if}
</Card>
</div>
</div>

View File

@@ -1,13 +0,0 @@
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

@@ -11,6 +11,12 @@
Spinner,
Toggle,
Toast,
Chip,
ListItem,
OrgHeader,
CalendarDay,
Logo,
ContentHeader,
} from "$lib/components/ui";
let inputValue = $state("");
@@ -124,7 +130,7 @@
<div class="flex flex-wrap gap-3">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="tertiary">Tertiary</Button>
<Button variant="danger">Danger</Button>
<Button variant="success">Success</Button>
</div>
@@ -141,6 +147,18 @@
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
With Icons (Material Symbols)
</h3>
<div class="flex flex-wrap items-center gap-3">
<Button icon="add">Add Item</Button>
<Button variant="secondary" icon="edit">Edit</Button>
<Button variant="tertiary" icon="delete">Delete</Button>
<Button icon="send">Send</Button>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
States
@@ -157,7 +175,9 @@
Full Width
</h3>
<div class="max-w-sm">
<Button fullWidth>Full Width Button</Button>
<Button fullWidth icon="rocket_launch"
>Full Width Button</Button
>
</div>
</div>
</div>
@@ -202,6 +222,8 @@
label="Password"
placeholder="••••••••"
/>
<Input placeholder="Message input with icon..." icon="add" />
<Input label="Search" placeholder="Search..." icon="search" />
</div>
</section>
@@ -263,28 +285,22 @@
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
With Status (placeholder)
</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"
/>
<Avatar name="Online User" size="lg" />
<Avatar name="Away User" size="lg" />
<Avatar name="Busy User" size="lg" />
<Avatar name="Offline User" size="lg" />
</div>
</div>
@@ -303,6 +319,88 @@
</div>
</section>
<!-- Chips -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
Chips
</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">
<Chip variant="primary">Primary</Chip>
<Chip variant="success">Success</Chip>
<Chip variant="warning">Warning</Chip>
<Chip variant="error">Error</Chip>
<Chip variant="default">Default</Chip>
</div>
</div>
</div>
</section>
<!-- List Items -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
List Items
</h2>
<div class="max-w-[240px] space-y-2">
<ListItem icon="info">Default Item</ListItem>
<ListItem icon="settings" variant="hover">Hover State</ListItem>
<ListItem icon="check_circle" variant="active"
>Active Item</ListItem
>
<ListItem icon="folder">Documents</ListItem>
<ListItem icon="dashboard">Dashboard</ListItem>
</div>
</section>
<!-- Org Header -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
Organization Header
</h2>
<div class="max-w-[240px] space-y-4">
<OrgHeader name="Acme Corp" role="Admin" />
<OrgHeader name="Design Team" role="Editor" isHover />
<OrgHeader name="Small" size="sm" />
</div>
</section>
<!-- Calendar Day -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
Calendar Day
</h2>
<div class="flex gap-1 max-w-[720px]">
<CalendarDay day="Mon" isHeader />
<CalendarDay day="Tue" isHeader />
<CalendarDay day="Wed" isHeader />
</div>
<div class="flex gap-1 max-w-[720px]">
<CalendarDay day="1" />
<CalendarDay day="2">
{#snippet events()}
<Chip>Meeting</Chip>
{/snippet}
</CalendarDay>
<CalendarDay day="3" isPast />
</div>
</section>
<!-- Badges -->
<section class="space-y-4">
<h2
@@ -492,38 +590,132 @@
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 class="space-y-6">
<!-- Headings (Tilt Warp) -->
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
Headings &mdash; Tilt Warp
</h3>
<div class="space-y-3">
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h1 · 32</span
>
<h1 class="text-light">Heading 1</h1>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h2 · 28</span
>
<h2 class="text-light">Heading 2</h2>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h3 · 24</span
>
<h3 class="text-light">Heading 3</h3>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h4 · 20</span
>
<h4 class="text-light">Heading 4</h4>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h5 · 16</span
>
<h5 class="text-light">Heading 5</h5>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h6 · 14</span
>
<h6 class="text-light">Heading 6</h6>
</div>
</div>
</div>
<!-- Button Text (Tilt Warp) -->
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
Button Text &mdash; Tilt Warp
</h3>
<div class="space-y-3">
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>btn-lg · 20</span
>
<span class="font-heading text-btn-lg text-light"
>Button Large</span
>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>btn-md · 16</span
>
<span class="font-heading text-btn-md text-light"
>Button Medium</span
>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>btn-sm · 14</span
>
<span class="font-heading text-btn-sm text-light"
>Button Small</span
>
</div>
</div>
</div>
<!-- Body Text (Work Sans) -->
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
Body &mdash; Work Sans
</h3>
<div class="space-y-3">
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>p · 16</span
>
<p class="text-body text-light">
Body text — Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
</p>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>p-md · 14</span
>
<p class="text-body-md text-light/80">
Body medium — Used for secondary information and
descriptions.
</p>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>p-sm · 12</span
>
<p class="text-body-sm text-light/60">
Body small — Used for metadata, timestamps, and
hints.
</p>
</div>
</div>
</div>
</div>
</section>
@@ -558,6 +750,51 @@
</div>
</section>
<!-- Logo -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
Logo
</h2>
<p class="text-light/60">
Brand logo component with size variants.
</p>
<div class="flex items-center gap-8 bg-night p-6 rounded-xl">
<div class="flex flex-col items-center gap-2">
<Logo size="sm" />
<span class="text-xs text-light/60">Small</span>
</div>
<div class="flex flex-col items-center gap-2">
<Logo size="md" />
<span class="text-xs text-light/60">Medium</span>
</div>
</div>
</section>
<!-- ContentHeader -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
Content Header
</h2>
<p class="text-light/60">
Page header component with avatar, title, action button, and
more menu.
</p>
<div class="bg-night p-6 rounded-xl space-y-4">
<ContentHeader
title="Page Title"
actionLabel="+ New"
onAction={() => {}}
onMore={() => {}}
/>
<ContentHeader title="Without Action" onMore={() => {}} />
<ContentHeader title="Simple Header" />
</div>
</section>
<!-- Footer -->
<footer class="text-center py-8 border-t border-light/10">
<p class="text-light/40 text-sm">