Mega push vol 4
This commit is contained in:
82
src/routes/+error.svelte
Normal file
82
src/routes/+error.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? []
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
31
src/routes/[orgSlug]/documents/[id]/+page.server.ts
Normal file
31
src/routes/[orgSlug]/documents/[id]/+page.server.ts
Normal 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}`);
|
||||
};
|
||||
9
src/routes/[orgSlug]/documents/[id]/+page.svelte
Normal file
9
src/routes/[orgSlug]/documents/[id]/+page.svelte
Normal 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>
|
||||
39
src/routes/[orgSlug]/documents/file/[id]/+page.server.ts
Normal file
39
src/routes/[orgSlug]/documents/file/[id]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
572
src/routes/[orgSlug]/documents/file/[id]/+page.svelte
Normal file
572
src/routes/[orgSlug]/documents/file/[id]/+page.svelte
Normal 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>
|
||||
43
src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts
Normal file
43
src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
34
src/routes/[orgSlug]/documents/folder/[id]/+page.svelte
Normal file
34
src/routes/[orgSlug]/documents/folder/[id]/+page.svelte
Normal 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>
|
||||
@@ -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 ?? []
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 — 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 — 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 — 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">
|
||||
|
||||
Reference in New Issue
Block a user