Mega push vol 4

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

7
src/app.d.ts vendored
View File

@@ -11,7 +11,12 @@ declare global {
session: Session | null;
user: User | null;
}
// interface Error {}
interface Error {
message: string;
context?: string;
code?: string;
errorId?: string;
}
// interface PageState {}
// interface Platform {}
}

View File

@@ -1,7 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

19
src/hooks.client.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { HandleClientError } from '@sveltejs/kit';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('client.error');
export const handleError: HandleClientError = async ({ error, status, message }) => {
const errorId = crypto.randomUUID().slice(0, 8);
log.error(`Unhandled client error [${errorId}]`, {
error,
data: { errorId, status, message },
});
return {
message: message || 'An unexpected error occurred',
errorId,
code: String(status),
};
};

View File

@@ -1,9 +1,11 @@
import { createServerClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import type { Handle } from '@sveltejs/kit';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import type { Database } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
event.locals.supabase = createServerClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
getAll() {
return event.cookies.getAll();
@@ -43,3 +45,27 @@ export const handle: Handle = async ({ event, resolve }) => {
}
});
};
const serverLog = createLogger('server.error');
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
const errorId = crypto.randomUUID().slice(0, 8);
serverLog.error(`Unhandled server error [${errorId}]`, {
error,
data: {
errorId,
status,
message,
url: event.url.pathname,
method: event.request.method,
},
});
return {
message: message || 'An unexpected error occurred',
errorId,
context: `${event.request.method} ${event.url.pathname}`,
code: String(status),
};
};

View File

@@ -1,5 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, CalendarEvent } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.calendar');
export async function fetchEvents(
supabase: SupabaseClient<Database>,
@@ -15,7 +18,10 @@ export async function fetchEvents(
.lte('end_time', endDate.toISOString())
.order('start_time');
if (error) throw error;
if (error) {
log.error('fetchEvents failed', { error, data: { orgId } });
throw error;
}
return data ?? [];
}
@@ -47,7 +53,10 @@ export async function createEvent(
.select()
.single();
if (error) throw error;
if (error) {
log.error('createEvent failed', { error, data: { orgId, title: event.title } });
throw error;
}
return data;
}
@@ -57,7 +66,10 @@ export async function updateEvent(
updates: Partial<Pick<CalendarEvent, 'title' | 'description' | 'start_time' | 'end_time' | 'all_day' | 'color'>>
): Promise<void> {
const { error } = await supabase.from('calendar_events').update(updates).eq('id', id);
if (error) throw error;
if (error) {
log.error('updateEvent failed', { error, data: { id, updates } });
throw error;
}
}
export async function deleteEvent(
@@ -65,7 +77,10 @@ export async function deleteEvent(
id: string
): Promise<void> {
const { error } = await supabase.from('calendar_events').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteEvent failed', { error, data: { id } });
throw error;
}
}
export function subscribeToEvents(
@@ -85,8 +100,11 @@ export function getMonthDays(year: number, month: number): Date[] {
const lastDay = new Date(year, month + 1, 0);
const days: Date[] = [];
// Week starts on Monday (0=Mon, 6=Sun)
let startDayOfWeek = firstDay.getDay() - 1;
if (startDayOfWeek < 0) startDayOfWeek = 6; // Sunday becomes 6
// Add days from previous month to fill first week
const startDayOfWeek = firstDay.getDay();
for (let i = startDayOfWeek - 1; i >= 0; i--) {
days.push(new Date(year, month, -i));
}
@@ -96,8 +114,8 @@ export function getMonthDays(year: number, month: number): Date[] {
days.push(new Date(year, month, i));
}
// Add days from next month to fill last week
const remainingDays = 42 - days.length; // 6 weeks * 7 days
// Add days from next month to fill last week (up to 6 rows)
const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) {
days.push(new Date(year, month + 1, i));
}

View File

@@ -0,0 +1,152 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.document-locks');
const LOCK_EXPIRY_SECONDS = 60;
const HEARTBEAT_INTERVAL_MS = 30_000; // 30 seconds
export interface LockInfo {
isLocked: boolean;
lockedBy: string | null;
lockedByName: string | null;
isOwnLock: boolean;
}
/**
* Get the current lock status for a document.
* Only returns active locks (heartbeat within LOCK_EXPIRY_SECONDS).
*/
export async function getLockInfo(
supabase: SupabaseClient<Database>,
documentId: string,
currentUserId: string
): Promise<LockInfo> {
const cutoff = new Date(Date.now() - LOCK_EXPIRY_SECONDS * 1000).toISOString();
const { data: lock } = await supabase
.from('document_locks')
.select(`
id,
document_id,
user_id,
locked_at,
last_heartbeat,
profiles:user_id (full_name, email)
`)
.eq('document_id', documentId)
.gt('last_heartbeat', cutoff)
.single();
if (!lock) {
return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false };
}
const profile = (lock as any).profiles; // join type not inferred by Supabase
return {
isLocked: true,
lockedBy: lock.user_id,
lockedByName: profile?.full_name || profile?.email || 'Someone',
isOwnLock: lock.user_id === currentUserId,
};
}
/**
* Acquire a lock on a document. Cleans up expired locks first.
* Returns true if lock was acquired, false if someone else holds it.
*/
export async function acquireLock(
supabase: SupabaseClient<Database>,
documentId: string,
userId: string
): Promise<boolean> {
const cutoff = new Date(Date.now() - LOCK_EXPIRY_SECONDS * 1000).toISOString();
// Delete expired locks for this document
await supabase
.from('document_locks')
.delete()
.eq('document_id', documentId)
.lt('last_heartbeat', cutoff);
// Try to insert our lock
const { error } = await supabase
.from('document_locks')
.insert({
document_id: documentId,
user_id: userId,
locked_at: new Date().toISOString(),
last_heartbeat: new Date().toISOString(),
});
if (error) {
if (error.code === '23505') {
// Unique constraint violation — someone else holds the lock
log.debug('Lock already held', { data: { documentId } });
return false;
}
log.error('acquireLock failed', { error, data: { documentId } });
return false;
}
log.info('Lock acquired', { data: { documentId, userId } });
return true;
}
/**
* Send a heartbeat to keep the lock alive.
*/
export async function heartbeatLock(
supabase: SupabaseClient<Database>,
documentId: string,
userId: string
): Promise<boolean> {
const { error } = await supabase
.from('document_locks')
.update({ last_heartbeat: new Date().toISOString() })
.eq('document_id', documentId)
.eq('user_id', userId);
if (error) {
log.error('heartbeatLock failed', { error, data: { documentId } });
return false;
}
return true;
}
/**
* Release a lock on a document.
*/
export async function releaseLock(
supabase: SupabaseClient<Database>,
documentId: string,
userId: string
): Promise<void> {
const { error } = await supabase
.from('document_locks')
.delete()
.eq('document_id', documentId)
.eq('user_id', userId);
if (error) {
log.error('releaseLock failed', { error, data: { documentId } });
} else {
log.info('Lock released', { data: { documentId, userId } });
}
}
/**
* Start a heartbeat interval. Returns a cleanup function.
*/
export function startHeartbeat(
supabase: SupabaseClient<Database>,
documentId: string,
userId: string
): () => void {
const interval = setInterval(() => {
heartbeatLock(supabase, documentId, userId);
}, HEARTBEAT_INTERVAL_MS);
return () => clearInterval(interval);
}

View File

@@ -1,9 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, Document } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
export interface DocumentWithChildren extends Document {
children?: DocumentWithChildren[];
}
const log = createLogger('api.documents');
export async function fetchDocuments(
supabase: SupabaseClient<Database>,
@@ -16,7 +15,11 @@ export async function fetchDocuments(
.order('type', { ascending: false }) // folders first
.order('name');
if (error) throw error;
if (error) {
log.error('fetchDocuments failed', { error, data: { orgId } });
throw error;
}
log.debug('fetchDocuments ok', { data: { count: data?.length ?? 0 } });
return data ?? [];
}
@@ -41,7 +44,11 @@ export async function createDocument(
.select()
.single();
if (error) throw error;
if (error) {
log.error('createDocument failed', { error, data: { orgId, name, type, parentId } });
throw error;
}
log.info('createDocument ok', { data: { id: data.id, name, type } });
return data;
}
@@ -57,7 +64,10 @@ export async function updateDocument(
.select()
.single();
if (error) throw error;
if (error) {
log.error('updateDocument failed', { error, data: { id, updates } });
throw error;
}
return data;
}
@@ -66,7 +76,10 @@ export async function deleteDocument(
id: string
): Promise<void> {
const { error } = await supabase.from('documents').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteDocument failed', { error, data: { id } });
throw error;
}
}
export async function moveDocument(
@@ -79,30 +92,12 @@ export async function moveDocument(
.update({ parent_id: newParentId, updated_at: new Date().toISOString() })
.eq('id', id);
if (error) throw error;
if (error) {
log.error('moveDocument failed', { error, data: { id, newParentId } });
throw error;
}
}
export function buildDocumentTree(documents: Document[]): DocumentWithChildren[] {
const map = new Map<string, DocumentWithChildren>();
const roots: DocumentWithChildren[] = [];
// First pass: create map
documents.forEach((doc) => {
map.set(doc.id, { ...doc, children: [] });
});
// Second pass: build tree
documents.forEach((doc) => {
const node = map.get(doc.id)!;
if (doc.parent_id && map.has(doc.parent_id)) {
map.get(doc.parent_id)!.children!.push(node);
} else {
roots.push(node);
}
});
return roots;
}
export function subscribeToDocuments(
supabase: SupabaseClient<Database>,

View File

@@ -1,5 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, KanbanBoard, KanbanColumn, KanbanCard } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.kanban');
export interface ColumnWithCards extends KanbanColumn {
cards: KanbanCard[];
@@ -19,7 +22,11 @@ export async function fetchBoards(
.eq('org_id', orgId)
.order('created_at');
if (error) throw error;
if (error) {
log.error('fetchBoards failed', { error, data: { orgId } });
throw error;
}
log.debug('fetchBoards ok', { data: { count: data?.length ?? 0 } });
return data ?? [];
}
@@ -33,7 +40,10 @@ export async function fetchBoardWithColumns(
.eq('id', boardId)
.single();
if (boardError) throw boardError;
if (boardError) {
log.error('fetchBoardWithColumns failed (board)', { error: boardError, data: { boardId } });
throw boardError;
}
if (!board) return null;
const { data: columns, error: colError } = await supabase
@@ -42,22 +52,55 @@ export async function fetchBoardWithColumns(
.eq('board_id', boardId)
.order('position');
if (colError) throw colError;
if (colError) {
log.error('fetchBoardWithColumns failed (columns)', { error: colError, data: { boardId } });
throw colError;
}
const columnIds = (columns ?? []).map((c) => c.id);
const { data: cards, error: cardError } = await supabase
.from('kanban_cards')
.select('*')
.in('column_id', (columns ?? []).map((c) => c.id))
.in('column_id', columnIds)
.order('position');
if (cardError) throw cardError;
if (cardError) {
log.error('fetchBoardWithColumns failed (cards)', { error: cardError, data: { boardId } });
throw cardError;
}
const cardsByColumn = new Map<string, KanbanCard[]>();
// Fetch tags for all cards in one query
const cardIds = (cards ?? []).map((c) => c.id);
let cardTagsMap = new Map<string, { id: string; name: string; color: string | null }[]>();
if (cardIds.length > 0) {
const { data: cardTags } = await supabase
.from('card_tags')
.select('card_id, tags:tag_id (id, name, color)')
.in('card_id', cardIds);
(cardTags ?? []).forEach((ct: any) => {
const tag = Array.isArray(ct.tags) ? ct.tags[0] : ct.tags;
if (!tag) return;
if (!cardTagsMap.has(ct.card_id)) {
cardTagsMap.set(ct.card_id, []);
}
cardTagsMap.get(ct.card_id)!.push(tag);
});
}
const cardsByColumn = new Map<string, (KanbanCard & { tags?: { id: string; name: string; color: string | null }[] })[]>();
(cards ?? []).forEach((card) => {
if (!cardsByColumn.has(card.column_id)) {
cardsByColumn.set(card.column_id, []);
const colId = card.column_id;
if (!colId) return;
if (!cardsByColumn.has(colId)) {
cardsByColumn.set(colId, []);
}
cardsByColumn.get(card.column_id)!.push(card);
cardsByColumn.get(colId)!.push({
...card,
tags: cardTagsMap.get(card.id) ?? []
});
});
return {
@@ -74,13 +117,17 @@ export async function createBoard(
orgId: string,
name: string
): Promise<KanbanBoard> {
log.info('createBoard', { data: { orgId, name } });
const { data, error } = await supabase
.from('kanban_boards')
.insert({ org_id: orgId, name })
.select()
.single();
if (error) throw error;
if (error) {
log.error('createBoard failed', { error, data: { orgId, name } });
throw error;
}
// Create default columns
const defaultColumns = ['To Do', 'In Progress', 'Done'];
@@ -101,7 +148,10 @@ export async function updateBoard(
name: string
): Promise<void> {
const { error } = await supabase.from('kanban_boards').update({ name }).eq('id', id);
if (error) throw error;
if (error) {
log.error('updateBoard failed', { error, data: { id, name } });
throw error;
}
}
export async function deleteBoard(
@@ -109,7 +159,10 @@ export async function deleteBoard(
id: string
): Promise<void> {
const { error } = await supabase.from('kanban_boards').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteBoard failed', { error, data: { id } });
throw error;
}
}
export async function createColumn(
@@ -124,7 +177,10 @@ export async function createColumn(
.select()
.single();
if (error) throw error;
if (error) {
log.error('createColumn failed', { error, data: { boardId, name, position } });
throw error;
}
return data;
}
@@ -134,7 +190,10 @@ export async function updateColumn(
updates: Partial<Pick<KanbanColumn, 'name' | 'position' | 'color'>>
): Promise<void> {
const { error } = await supabase.from('kanban_columns').update(updates).eq('id', id);
if (error) throw error;
if (error) {
log.error('updateColumn failed', { error, data: { id, updates } });
throw error;
}
}
export async function deleteColumn(
@@ -142,7 +201,10 @@ export async function deleteColumn(
id: string
): Promise<void> {
const { error } = await supabase.from('kanban_columns').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteColumn failed', { error, data: { id } });
throw error;
}
}
export async function createCard(
@@ -163,7 +225,10 @@ export async function createCard(
.select()
.single();
if (error) throw error;
if (error) {
log.error('createCard failed', { error, data: { columnId, title, position } });
throw error;
}
return data;
}
@@ -173,7 +238,10 @@ export async function updateCard(
updates: Partial<Pick<KanbanCard, 'title' | 'description' | 'column_id' | 'position' | 'due_date' | 'color'>>
): Promise<void> {
const { error } = await supabase.from('kanban_cards').update(updates).eq('id', id);
if (error) throw error;
if (error) {
log.error('updateCard failed', { error, data: { id, updates } });
throw error;
}
}
export async function deleteCard(
@@ -181,7 +249,10 @@ export async function deleteCard(
id: string
): Promise<void> {
const { error } = await supabase.from('kanban_cards').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteCard failed', { error, data: { id } });
throw error;
}
}
export async function moveCard(
@@ -190,12 +261,48 @@ export async function moveCard(
newColumnId: string,
newPosition: number
): Promise<void> {
const { error } = await supabase
// Fetch all cards in the target column (ordered by position)
const { data: targetCards, error: fetchErr } = await supabase
.from('kanban_cards')
.update({ column_id: newColumnId, position: newPosition })
.eq('id', cardId);
.select('id, position')
.eq('column_id', newColumnId)
.order('position');
if (error) throw error;
if (fetchErr) {
log.error('moveCard: failed to fetch target column cards', { error: fetchErr });
throw fetchErr;
}
// Remove the moved card from the list if it's already in this column
const otherCards = (targetCards ?? []).filter((c) => c.id !== cardId);
// Insert at the new position and reassign sequential positions
const reordered = [
...otherCards.slice(0, newPosition),
{ id: cardId },
...otherCards.slice(newPosition),
];
// Batch update: move card to column + set position, then update siblings
const updates = reordered.map((c, i) => {
if (c.id === cardId) {
return supabase
.from('kanban_cards')
.update({ column_id: newColumnId, position: i })
.eq('id', c.id);
}
return supabase
.from('kanban_cards')
.update({ position: i })
.eq('id', c.id);
});
const results = await Promise.all(updates);
const failed = results.find((r) => r.error);
if (failed?.error) {
log.error('moveCard failed', { error: failed.error, data: { cardId, newColumnId, newPosition } });
throw failed.error;
}
}
export function subscribeToBoard(

View File

@@ -1,6 +1,13 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, Organization, MemberRole } from '$lib/supabase/types';
import type { OrgWithRole } from '$lib/stores/organizations.svelte';
import { createLogger } from '$lib/utils/logger';
export interface OrgWithRole extends Organization {
role: MemberRole;
memberCount?: number;
}
const log = createLogger('api.organizations');
export async function fetchUserOrganizations(
supabase: SupabaseClient<Database>
@@ -20,7 +27,10 @@ export async function fetchUserOrganizations(
`)
.not('joined_at', 'is', null);
if (error) throw error;
if (error) {
log.error('fetchUserOrganizations failed', { error });
throw error;
}
return (data ?? [])
.filter((item) => item.organizations)
@@ -35,13 +45,17 @@ export async function createOrganization(
name: string,
slug: string
): Promise<Organization> {
log.info('createOrganization', { data: { name, slug } });
const { data, error } = await supabase
.from('organizations')
.insert({ name, slug })
.select()
.single();
if (error) throw error;
if (error) {
log.error('createOrganization failed', { error, data: { name, slug } });
throw error;
}
return data;
}
@@ -57,7 +71,10 @@ export async function updateOrganization(
.select()
.single();
if (error) throw error;
if (error) {
log.error('updateOrganization failed', { error, data: { id, updates } });
throw error;
}
return data;
}
@@ -66,7 +83,10 @@ export async function deleteOrganization(
id: string
): Promise<void> {
const { error } = await supabase.from('organizations').delete().eq('id', id);
if (error) throw error;
if (error) {
log.error('deleteOrganization failed', { error, data: { id } });
throw error;
}
}
export async function fetchOrgMembers(
@@ -90,7 +110,10 @@ export async function fetchOrgMembers(
`)
.eq('org_id', orgId);
if (error) throw error;
if (error) {
log.error('fetchOrgMembers failed', { error, data: { orgId } });
throw error;
}
return data ?? [];
}
@@ -108,6 +131,7 @@ export async function inviteMember(
.single();
if (profileError || !profile) {
log.warn('inviteMember: user not found', { data: { email } });
throw new Error('User not found. They need to sign up first.');
}
@@ -120,6 +144,7 @@ export async function inviteMember(
.single();
if (existing) {
log.warn('inviteMember: already a member', { data: { email, orgId } });
throw new Error('User is already a member of this organization.');
}
@@ -131,7 +156,10 @@ export async function inviteMember(
joined_at: new Date().toISOString() // Auto-join for now
});
if (error) throw error;
if (error) {
log.error('inviteMember failed', { error, data: { orgId, email, role } });
throw error;
}
}
export async function updateMemberRole(
@@ -144,7 +172,10 @@ export async function updateMemberRole(
.update({ role })
.eq('id', memberId);
if (error) throw error;
if (error) {
log.error('updateMemberRole failed', { error, data: { memberId, role } });
throw error;
}
}
export async function removeMember(
@@ -152,7 +183,10 @@ export async function removeMember(
memberId: string
): Promise<void> {
const { error } = await supabase.from('org_members').delete().eq('id', memberId);
if (error) throw error;
if (error) {
log.error('removeMember failed', { error, data: { memberId } });
throw error;
}
}
export function generateSlug(name: string): string {

View File

@@ -22,31 +22,20 @@
let currentView = $state<ViewType>(initialView);
const today = new Date();
const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const weekDayHeaders = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const days = $derived(
getMonthDays(currentDate.getFullYear(), currentDate.getMonth()),
);
function prevMonth() {
currentDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() - 1,
1,
);
}
function nextMonth() {
currentDate = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
1,
);
}
function goToToday() {
currentDate = new Date();
}
// Group days into weeks (rows of 7)
const weeks = $derived.by(() => {
const result: Date[][] = [];
for (let i = 0; i < days.length; i += 7) {
result.push(days.slice(i, i + 7));
}
return result;
});
function getEventsForDay(date: Date): CalendarEvent[] {
return events.filter((event) => {
@@ -66,10 +55,12 @@
}),
);
// Get week days for week view
// Get week days for week view (Mon-Sun)
function getWeekDays(date: Date): Date[] {
const startOfWeek = new Date(date);
startOfWeek.setDate(date.getDate() - date.getDay());
const dayOfWeek = startOfWeek.getDay();
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
startOfWeek.setDate(date.getDate() + mondayOffset);
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(startOfWeek);
d.setDate(startOfWeek.getDate() + i);
@@ -79,7 +70,6 @@
const weekDates = $derived(getWeekDays(currentDate));
// Navigation functions for different views
function prev() {
if (currentView === "month") {
currentDate = new Date(
@@ -112,7 +102,11 @@
}
}
const headerTitle = $derived(() => {
function goToToday() {
currentDate = new Date();
}
const headerTitle = $derived.by(() => {
if (currentView === "day") {
return currentDate.toLocaleDateString("en-US", {
weekday: "long",
@@ -129,207 +123,200 @@
});
</script>
<div class="bg-surface rounded-xl p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-light">{headerTitle()}</h2>
<div class="flex flex-col h-full gap-2">
<!-- Navigation bar -->
<div class="flex items-center justify-between px-2">
<div class="flex items-center gap-2">
<!-- View Switcher -->
<div class="flex bg-dark rounded-lg p-0.5">
<button
class="px-3 py-1 text-sm rounded-md transition-colors {currentView ===
'day'
? 'bg-primary text-white'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "day")}
>
Day
</button>
<button
class="px-3 py-1 text-sm rounded-md transition-colors {currentView ===
'week'
? 'bg-primary text-white'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "week")}
>
Week
</button>
<button
class="px-3 py-1 text-sm rounded-md transition-colors {currentView ===
'month'
? 'bg-primary text-white'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "month")}
>
Month
</button>
</div>
<button
class="px-3 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
onclick={prev}
aria-label="Previous"
>
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>chevron_left</span
>
</button>
<span
class="font-heading text-h4 text-white min-w-[200px] text-center"
>{headerTitle}</span
>
<button
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
onclick={next}
aria-label="Next"
>
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>chevron_right</span
>
</button>
<button
class="px-3 py-1 text-body-md font-body text-light/60 hover:text-white hover:bg-dark rounded-[32px] transition-colors ml-2"
onclick={goToToday}
>
Today
</button>
</div>
<div class="flex bg-dark rounded-[32px] p-0.5">
<button
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={prev}
aria-label="Previous"
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
'day'
? 'bg-primary text-night'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "day")}>Day</button
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={next}
aria-label="Next"
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
'week'
? 'bg-primary text-night'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "week")}>Week</button
>
<button
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
'month'
? 'bg-primary text-night'
: 'text-light/60 hover:text-light'}"
onclick={() => (currentView = "month")}>Month</button
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
</div>
<!-- Month View -->
{#if currentView === "month"}
<div
class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden"
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
>
{#each weekDays as day}
<div
class="bg-dark px-2 py-2 text-center text-sm font-medium text-light/50"
>
{day}
</div>
{/each}
{#each days as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
{@const inMonth = isCurrentMonth(day)}
<button
class="bg-dark min-h-[80px] p-1 text-left transition-colors hover:bg-light/5"
class:opacity-40={!inMonth}
onclick={() => onDateClick?.(day)}
>
<div class="flex items-center justify-center w-7 h-7 mb-1">
<!-- Day Headers -->
<div class="grid grid-cols-7 gap-2">
{#each weekDayHeaders as day}
<div class="flex items-center justify-center py-2 px-2">
<span
class="text-sm {isToday
? 'bg-primary text-white rounded-full w-7 h-7 flex items-center justify-center'
: 'text-light/80'}"
class="font-heading text-h4 text-white text-center"
>{day}</span
>
{day.getDate()}
</span>
</div>
<div class="space-y-0.5">
{#each dayEvents.slice(0, 3) as event}
<button
class="w-full text-xs px-1 py-0.5 rounded truncate text-left"
style="background-color: {event.color ??
'#6366f1'}20; color: {event.color ??
'#6366f1'}"
onclick={(e) => {
e.stopPropagation();
onEventClick?.(event);
}}
{/each}
</div>
<!-- Calendar Grid -->
<div class="flex-1 flex flex-col gap-2 min-h-0">
{#each weeks as week}
<div class="grid grid-cols-7 gap-2 flex-1">
{#each week as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
{@const inMonth = isCurrentMonth(day)}
<div
class="bg-night rounded-none flex flex-col items-start px-4 py-5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer
{!inMonth ? 'opacity-50' : ''}"
onclick={() => onDateClick?.(day)}
>
{event.title}
</button>
<span
class="font-body text-body text-white {isToday
? 'text-primary font-bold'
: ''}"
>
{day.getDate()}
</span>
{#each dayEvents.slice(0, 2) as event}
<button
class="w-full mt-1 px-2 py-0.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
style="background-color: {event.color ??
'#00A3E0'}"
onclick={(e) => {
e.stopPropagation();
onEventClick?.(event);
}}
>
{event.title}
</button>
{/each}
{#if dayEvents.length > 2}
<span
class="text-body-sm text-light/40 mt-0.5"
>+{dayEvents.length - 2} more</span
>
{/if}
</div>
{/each}
{#if dayEvents.length > 3}
<p class="text-xs text-light/40 px-1">
+{dayEvents.length - 3} more
</p>
{/if}
</div>
</button>
{/each}
{/each}
</div>
</div>
{/if}
<!-- Week View -->
{#if currentView === "week"}
<div
class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden"
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
>
{#each weekDates as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
<div class="bg-dark">
<div class="px-2 py-2 text-center border-b border-light/10">
<div class="text-xs text-light/50">
{weekDays[day.getDay()]}
</div>
<div
class="text-lg font-medium {isToday
? 'text-primary'
: 'text-light'}"
>
{day.getDate()}
</div>
</div>
<div class="min-h-[300px] p-1 space-y-1">
{#each dayEvents as event}
<button
class="w-full text-xs px-2 py-1.5 rounded text-left"
style="background-color: {event.color ??
'#6366f1'}20; color: {event.color ??
'#6366f1'}"
onclick={() => onEventClick?.(event)}
<div class="grid grid-cols-7 gap-2 flex-1">
{#each weekDates as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
<div class="flex flex-col overflow-hidden">
<div class="px-4 py-3 text-center">
<div
class="font-heading text-h4 {isToday
? 'text-primary'
: 'text-white'}"
>
<div class="font-medium truncate">
{weekDayHeaders[(day.getDay() + 6) % 7]}
</div>
<div
class="font-body text-body-md {isToday
? 'text-primary'
: 'text-light/60'}"
>
{day.getDate()}
</div>
</div>
<div class="flex-1 px-2 pb-2 space-y-1 overflow-y-auto">
{#each dayEvents as event}
<button
class="w-full px-2 py-1.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
style="background-color: {event.color ??
'#00A3E0'}"
onclick={() => onEventClick?.(event)}
>
{event.title}
</div>
<div class="text-[10px] opacity-70">
{new Date(
event.start_time,
).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})}
</div>
</button>
{/each}
</button>
{/each}
</div>
</div>
</div>
{/each}
{/each}
</div>
</div>
{/if}
<!-- Day View -->
{#if currentView === "day"}
{@const dayEvents = getEventsForDay(currentDate)}
<div class="bg-dark rounded-lg p-4 min-h-[400px]">
<div class="flex-1 bg-night px-4 py-5 min-h-0 overflow-auto">
{#if dayEvents.length === 0}
<div class="text-center text-light/40 py-12">
<p>No events for this day</p>
<p class="font-body text-body">No events for this day</p>
</div>
{:else}
<div class="space-y-2">
{#each dayEvents as event}
<button
class="w-full text-left p-3 rounded-lg transition-colors hover:opacity-80"
class="w-full text-left p-3 rounded-[8px] transition-colors hover:opacity-80"
style="background-color: {event.color ??
'#6366f1'}20; border-left: 3px solid {event.color ??
'#6366f1'}"
'#00A3E0'}20; border-left: 3px solid {event.color ??
'#00A3E0'}"
onclick={() => onEventClick?.(event)}
>
<div class="font-medium text-light">
<div class="font-heading text-h5 text-white">
{event.title}
</div>
<div class="text-sm text-light/60 mt-1">
<div
class="font-body text-body-md text-light/60 mt-1"
>
{new Date(event.start_time).toLocaleTimeString(
"en-US",
{ hour: "numeric", minute: "2-digit" },
@@ -340,7 +327,9 @@
)}
</div>
{#if event.description}
<div class="text-sm text-light/50 mt-2">
<div
class="font-body text-body-md text-light/50 mt-2"
>
{event.description}
</div>
{/if}

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { Button } from "$lib/components/ui";
import { Editor } from "$lib/components/documents";
import type { Document, Json } from "$lib/supabase/types";
interface Props {
document: Document;
onSave?: (content: Json) => void;
/** "preview" = read-only with Edit button that navigates to editUrl. "edit" = editable inline. */
mode?: "preview" | "edit";
/** URL to navigate to when clicking "+ Edit" in preview mode */
editUrl?: string;
/** Whether the document is locked by another user */
locked?: boolean;
/** Name of the user who holds the lock */
lockedByName?: string | null;
}
let {
document,
onSave,
mode = "preview",
editUrl,
locked = false,
lockedByName = null,
}: Props = $props();
let isEditing = $state(false);
$effect(() => {
isEditing = mode === "edit" && !locked;
});
function handleEditClick() {
if (locked) return;
if (mode === "preview" && editUrl) {
goto(editUrl);
} else {
isEditing = !isEditing;
}
}
</script>
<div
class="bg-night rounded-[32px] overflow-hidden flex flex-col min-w-0 h-full"
>
<!-- Lock Banner -->
{#if locked}
<div
class="flex items-center gap-2 px-4 py-2.5 bg-warning/10 border-b border-warning/20"
>
<span
class="material-symbols-rounded text-warning"
style="font-size: 20px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
lock
</span>
<span class="text-body-sm text-warning">
{lockedByName || "Someone"} is currently editing this document. View-only
mode.
</span>
</div>
{/if}
<!-- Header -->
<header class="flex items-center gap-2 px-4 py-5">
<h2 class="flex-1 font-heading text-h1 text-white truncate">
{document.name}
</h2>
{#if locked}
<Button size="md" disabled>
<span
class="material-symbols-rounded mr-1"
style="font-size: 16px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>lock</span
>
Locked
</Button>
{:else if mode === "edit"}
<Button size="md" onclick={handleEditClick}>
{isEditing ? "Preview" : "Edit"}
</Button>
{:else}
<Button size="md" onclick={handleEditClick}>Edit</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>
<!-- Editor Area -->
<div class="flex-1 bg-background rounded-[32px] mx-4 mb-4 overflow-auto">
<Editor {document} {onSave} editable={isEditing} />
</div>
</div>

View File

@@ -3,15 +3,15 @@
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import type { Document } from "$lib/supabase/types";
import type { Document, Json } from "$lib/supabase/types";
interface Props {
document?: Document | null;
content?: object | null;
editable?: boolean;
placeholder?: string;
onUpdate?: (content: object) => void;
onSave?: (content: object) => void;
onUpdate?: (content: Json) => void;
onSave?: (content: Json) => void;
}
let {
@@ -29,6 +29,7 @@
let element: HTMLDivElement;
let editor: Editor | null = $state(null);
let saveStatus = $state<"idle" | "saving" | "saved" | "error">("idle");
let isMounted = $state(true);
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -37,24 +38,25 @@
if (saveTimeout) clearTimeout(saveTimeout);
saveStatus = "idle";
saveTimeout = setTimeout(async () => {
await saveNow();
if (isMounted) await saveNow();
}, 1000); // Auto-save after 1 second of inactivity
}
async function saveNow() {
if (editor && onSave) {
saveStatus = "saving";
try {
await onSave(editor.getJSON());
saveStatus = "saved";
// Reset status after 2 seconds
if (statusTimeout) clearTimeout(statusTimeout);
statusTimeout = setTimeout(() => {
saveStatus = "idle";
}, 2000);
} catch {
saveStatus = "error";
}
if (!isMounted || !editor || !onSave) return;
saveStatus = "saving";
try {
await onSave(editor.getJSON());
if (!isMounted) return; // Guard after async
saveStatus = "saved";
// Reset status after 2 seconds
if (statusTimeout) clearTimeout(statusTimeout);
statusTimeout = setTimeout(() => {
if (isMounted) saveStatus = "idle";
}, 2000);
} catch {
if (isMounted) saveStatus = "error";
}
}
@@ -71,7 +73,7 @@
},
editorProps: {
attributes: {
class: "prose prose-invert max-w-none focus:outline-none min-h-[200px] p-4",
class: "prose prose-invert max-w-3xl mx-auto focus:outline-none min-h-[200px] p-4",
},
handleKeyDown: (view, event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
@@ -86,6 +88,7 @@
});
onDestroy(() => {
isMounted = false;
if (saveTimeout) clearTimeout(saveTimeout);
if (statusTimeout) clearTimeout(statusTimeout);
editor?.destroy();
@@ -124,11 +127,9 @@
}
</script>
<div class="bg-surface rounded-xl border border-light/10 overflow-hidden">
<div class="bg-background rounded-xl overflow-hidden">
{#if editable}
<div
class="flex items-center gap-1 px-2 py-1.5 border-b border-light/10 bg-dark/50"
>
<div class="flex items-center gap-1 px-2 py-1.5 bg-background">
<!-- Save Button -->
<button
class="flex items-center gap-1.5 px-2 py-1 mr-2 text-xs rounded hover:bg-light/10 transition-colors {saveStatus ===
@@ -346,7 +347,7 @@
</button>
</div>
{/if}
<div bind:this={element}></div>
<div class="border-none" bind:this={element}></div>
</div>
<style>

View File

@@ -0,0 +1,874 @@
<script lang="ts">
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import {
Button,
Modal,
Input,
Avatar,
IconButton,
Icon,
} from "$lib/components/ui";
import { DocumentViewer } from "$lib/components/documents";
import { createLogger } from "$lib/utils/logger";
import { toasts } from "$lib/stores/toast.svelte";
import type { Document } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
const log = createLogger("component.file-browser");
interface Props {
org: { id: string; name: string; slug: string };
documents: Document[];
currentFolderId: string | null;
user: { id: string } | null;
/** Page title shown in the header */
title?: string;
}
let {
org,
documents = $bindable(),
currentFolderId,
user,
title = "Files",
}: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
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" | "kanban">("document");
let viewMode = $state<"list" | "grid">("grid");
// Context menu state
let contextMenu = $state<{ x: number; y: number; doc: Document } | null>(
null,
);
let showOrganizeMenu = $state(false);
// Sort: folders first, then documents, then kanbans, alphabetical
function typeOrder(type: string): number {
if (type === "folder") return 0;
if (type === "document") return 1;
if (type === "kanban") return 2;
return 3;
}
const currentFolderItems = $derived(
documents
.filter((d) =>
currentFolderId === null
? d.parent_id === null
: d.parent_id === currentFolderId,
)
.sort((a, b) => {
const typeA = typeOrder(a.type);
const typeB = typeOrder(b.type);
if (typeA !== typeB) return typeA - typeB;
return a.name.localeCompare(b.name);
}),
);
// Drag and drop state
let draggedItem = $state<Document | null>(null);
let dragOverFolder = $state<string | null>(null);
let isDragging = $state(false);
let dragOverBreadcrumb = $state<string | null | undefined>(undefined);
// Build breadcrumb path
const breadcrumbPath = $derived.by(() => {
const path: { id: string | null; name: string }[] = [
{ id: null, name: "Home" },
];
if (currentFolderId === null) return path;
let current = documents.find((d) => d.id === currentFolderId);
const ancestors: { id: string; name: string }[] = [];
while (current) {
ancestors.unshift({ id: current.id, name: current.name });
current = current.parent_id
? documents.find((d) => d.id === current!.parent_id)
: undefined;
}
return [...path, ...ancestors];
});
// URL helpers
function getFolderUrl(folderId: string | null): string {
if (!folderId) return `/${org.slug}/documents`;
return `/${org.slug}/documents/folder/${folderId}`;
}
function getFileUrl(doc: Document): string {
return `/${org.slug}/documents/file/${doc.id}`;
}
function getDocIcon(doc: Document): string {
if (doc.type === "folder") return "folder";
if (doc.type === "kanban") return "view_kanban";
return "description";
}
function handleItemClick(doc: Document) {
if (isDragging) {
isDragging = false;
return;
}
if (doc.type === "folder") {
goto(getFolderUrl(doc.id));
} else if (doc.type === "kanban") {
goto(getFileUrl(doc));
} else {
selectedDoc = doc;
}
}
function handleDoubleClick(doc: Document) {
if (doc.type === "folder") {
window.open(getFolderUrl(doc.id), "_blank");
} else {
window.open(getFileUrl(doc), "_blank");
}
}
function handleAuxClick(e: MouseEvent, doc: Document) {
if (e.button === 1) {
e.preventDefault();
if (doc.type === "folder") {
window.open(getFolderUrl(doc.id), "_blank");
} else {
window.open(getFileUrl(doc), "_blank");
}
}
}
// Context menu handlers
function handleContextMenu(e: MouseEvent, doc: Document) {
e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY, doc };
showOrganizeMenu = false;
}
function closeContextMenu() {
contextMenu = null;
showOrganizeMenu = false;
}
function contextRename() {
if (!contextMenu) return;
editingDoc = contextMenu.doc;
newDocName = contextMenu.doc.name;
showEditModal = true;
closeContextMenu();
}
async function contextCopy() {
if (!contextMenu || !user) return;
const doc = contextMenu.doc;
closeContextMenu();
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: org.id,
name: `${doc.name} (copy)`,
type: doc.type,
parent_id: doc.parent_id,
created_by: user.id,
content: doc.content,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc as Document];
toasts.success(`Copied "${doc.name}"`);
} else if (error) {
log.error("Failed to copy document", { error });
toasts.error("Failed to copy document");
}
}
function contextOrganize() {
showOrganizeMenu = !showOrganizeMenu;
}
async function contextMoveToFolder(folderId: string | null) {
if (!contextMenu) return;
const doc = contextMenu.doc;
closeContextMenu();
await handleMove(doc.id, folderId);
toasts.success(
`Moved "${doc.name}" to ${folderId ? (documents.find((d) => d.id === folderId)?.name ?? "folder") : "Home"}`,
);
}
function contextDelete() {
if (!contextMenu) return;
const doc = contextMenu.doc;
closeContextMenu();
handleDelete(doc);
}
const availableFolders = $derived(
documents.filter(
(d) => d.type === "folder" && d.id !== contextMenu?.doc.id,
),
);
function handleAdd() {
showCreateModal = true;
}
// Drag handlers
function handleDragStart(e: DragEvent, doc: Document) {
isDragging = true;
draggedItem = doc;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", doc.id);
}
}
function handleDragEnd() {
resetDragState();
}
function handleDragOver(e: DragEvent, doc: Document) {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (draggedItem?.id === doc.id) return;
if (doc.type === "folder") {
dragOverFolder = doc.id;
} else {
dragOverFolder = null;
}
}
function handleDragLeave() {
dragOverFolder = null;
}
async function handleDrop(e: DragEvent, targetDoc: Document) {
e.preventDefault();
e.stopPropagation();
if (!draggedItem || draggedItem.id === targetDoc.id) {
resetDragState();
return;
}
if (targetDoc.type === "folder") {
const draggedName = draggedItem.name;
await handleMove(draggedItem.id, targetDoc.id);
toasts.success(`Moved "${draggedName}" into "${targetDoc.name}"`);
}
resetDragState();
}
function handleContainerDragOver(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
}
async function handleDropOnEmpty(e: DragEvent) {
e.preventDefault();
if (!draggedItem) return;
if (draggedItem.parent_id !== currentFolderId) {
await handleMove(draggedItem.id, currentFolderId);
}
resetDragState();
}
function resetDragState() {
draggedItem = null;
dragOverFolder = null;
setTimeout(() => {
isDragging = false;
}, 100);
}
async function handleMove(docId: string, newParentId: string | null) {
documents = documents.map((d) =>
d.id === docId ? { ...d, parent_id: newParentId } : d,
);
const { error } = await supabase
.from("documents")
.update({
parent_id: newParentId,
updated_at: new Date().toISOString(),
})
.eq("id", docId);
if (error) {
log.error("Failed to move document", {
error,
data: { docId, newParentId },
});
toasts.error("Failed to move file");
const { data: freshDocs } = await supabase
.from("documents")
.select("*")
.eq("org_id", org.id)
.order("name");
if (freshDocs) documents = freshDocs as Document[];
}
}
async function handleCreate() {
if (!newDocName.trim() || !user) return;
if (newDocType === "kanban") {
const { data: newBoard, error: boardError } = await supabase
.from("kanban_boards")
.insert({ org_id: org.id, name: newDocName })
.select()
.single();
if (boardError || !newBoard) {
toasts.error("Failed to create kanban board");
return;
}
await supabase.from("kanban_columns").insert([
{ board_id: newBoard.id, name: "To Do", position: 0 },
{ board_id: newBoard.id, name: "In Progress", position: 1 },
{ board_id: newBoard.id, name: "Done", position: 2 },
]);
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
id: newBoard.id,
org_id: org.id,
name: newDocName,
type: "kanban",
parent_id: currentFolderId,
created_by: user.id,
content: {
type: "kanban",
board_id: newBoard.id,
} as import("$lib/supabase/types").Json,
})
.select()
.single();
if (!error && newDoc) {
goto(getFileUrl(newDoc as Document));
} else if (error) {
toasts.error("Failed to create kanban document");
}
} else {
let content: any = null;
if (newDocType === "document") {
content = { type: "doc", content: [] };
}
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: org.id,
name: newDocName,
type: newDocType as "folder" | "document",
parent_id: currentFolderId,
created_by: user.id,
content,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc as Document];
if (newDocType === "document") {
goto(getFileUrl(newDoc as Document));
}
} else if (error) {
toasts.error("Failed to create document");
}
}
showCreateModal = false;
newDocName = "";
newDocType = "document";
}
async function handleSave(content: import("$lib/supabase/types").Json) {
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,
);
}
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;
// Recursively collect all descendant IDs for proper deletion
function collectDescendantIds(parentId: string): string[] {
const children = documents.filter((d) => d.parent_id === parentId);
let ids: string[] = [];
for (const child of children) {
ids.push(child.id);
if (child.type === "folder") {
ids = ids.concat(collectDescendantIds(child.id));
}
}
return ids;
}
if (doc.type === "folder") {
const descendantIds = collectDescendantIds(doc.id);
if (descendantIds.length > 0) {
await supabase
.from("documents")
.delete()
.in("id", descendantIds);
}
}
const { error } = await supabase
.from("documents")
.delete()
.eq("id", doc.id);
if (!error) {
const deletedIds = new Set([
doc.id,
...(doc.type === "folder" ? collectDescendantIds(doc.id) : []),
]);
documents = documents.filter((d) => !deletedIds.has(d.id));
if (selectedDoc?.id === doc.id) {
selectedDoc = null;
}
}
}
</script>
<div class="flex h-full gap-4">
<!-- Files Panel -->
<div
class="bg-night rounded-[32px] flex flex-col gap-4 px-4 py-5 overflow-hidden flex-1 min-w-0 h-full"
>
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name={title} size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">{title}</h1>
<Button size="md" onclick={handleAdd}>+ New</Button>
<IconButton
title="Toggle view"
onclick={() =>
(viewMode = viewMode === "list" ? "grid" : "list")}
>
<Icon
name={viewMode === "list" ? "grid_view" : "view_list"}
size={24}
/>
</IconButton>
</header>
<!-- Breadcrumb Path -->
<nav class="flex items-center gap-2 text-h3 font-heading">
{#each breadcrumbPath as crumb, i}
{#if i > 0}
<span
class="material-symbols-rounded text-light/30"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
chevron_right
</span>
{/if}
<a
href={getFolderUrl(crumb.id)}
class="px-3 py-1 rounded-xl transition-colors
{crumb.id === currentFolderId
? 'text-white'
: 'text-light/60 hover:text-primary'}
{dragOverBreadcrumb === (crumb.id ?? '__root__')
? 'ring-2 ring-primary bg-primary/10'
: ''}"
ondragover={(e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
dragOverBreadcrumb = crumb.id ?? "__root__";
}}
ondragleave={() => {
dragOverBreadcrumb = undefined;
}}
ondrop={async (e) => {
e.preventDefault();
e.stopPropagation();
dragOverBreadcrumb = undefined;
if (!draggedItem) return;
if (draggedItem.parent_id === crumb.id) {
resetDragState();
return;
}
const draggedName = draggedItem.name;
await handleMove(draggedItem.id, crumb.id);
toasts.success(
`Moved "${draggedName}" to "${crumb.name}"`,
);
resetDragState();
}}
>
{crumb.name}
</a>
{/each}
</nav>
<!-- File List/Grid -->
<div class="flex-1 overflow-auto min-h-0">
{#if viewMode === "list"}
<div
class="flex flex-col gap-1"
ondragover={handleContainerDragOver}
ondrop={handleDropOnEmpty}
role="list"
>
{#if currentFolderItems.length === 0}
<div class="text-center text-light/40 py-8 text-sm">
<p>
No files yet. Drag files here or create a new
one.
</p>
</div>
{:else}
{#each currentFolderItems as item}
<button
type="button"
class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] w-full text-left transition-colors hover:bg-dark
{selectedDoc?.id === item.id ? 'bg-dark' : ''}
{draggedItem?.id === item.id ? 'opacity-50' : ''}
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
draggable="true"
ondragstart={(e) => handleDragStart(e, item)}
ondragend={handleDragEnd}
ondragover={(e) => handleDragOver(e, item)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, item)}
onclick={() => handleItemClick(item)}
ondblclick={() => handleDoubleClick(item)}
onauxclick={(e) => handleAuxClick(e, item)}
oncontextmenu={(e) =>
handleContextMenu(e, item)}
>
<div
class="w-8 h-8 flex items-center justify-center p-1"
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{getDocIcon(item)}
</span>
</div>
<span
class="font-body text-body text-white truncate flex-1"
>{item.name}</span
>
{#if item.type === "folder"}
<span
class="material-symbols-rounded text-light/50"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
chevron_right
</span>
{/if}
</button>
{/each}
{/if}
</div>
{:else}
<!-- Grid View -->
<div
class="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"
ondragover={handleContainerDragOver}
ondrop={handleDropOnEmpty}
role="list"
>
{#if currentFolderItems.length === 0}
<div
class="col-span-full text-center text-light/40 py-8 text-sm"
>
<p>
No files yet. Drag files here or create a new
one.
</p>
</div>
{:else}
{#each currentFolderItems as item}
<button
type="button"
class="flex flex-col items-center gap-2 p-4 rounded-xl transition-colors hover:bg-dark
{selectedDoc?.id === item.id ? 'bg-dark' : ''}
{draggedItem?.id === item.id ? 'opacity-50' : ''}
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
draggable="true"
ondragstart={(e) => handleDragStart(e, item)}
ondragend={handleDragEnd}
ondragover={(e) => handleDragOver(e, item)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, item)}
onclick={() => handleItemClick(item)}
ondblclick={() => handleDoubleClick(item)}
onauxclick={(e) => handleAuxClick(e, item)}
oncontextmenu={(e) =>
handleContextMenu(e, item)}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
{getDocIcon(item)}
</span>
<span
class="font-body text-body-md text-white text-center truncate w-full"
>{item.name}</span
>
</button>
{/each}
{/if}
</div>
{/if}
</div>
</div>
<!-- Compact Editor Panel (shown when a doc is selected) -->
{#if selectedDoc}
<div class="flex-1 min-w-0 h-full">
<DocumentViewer
document={selectedDoc}
onSave={handleSave}
mode="preview"
editUrl={getFileUrl(selectedDoc)}
/>
</div>
{/if}
</div>
<Modal
isOpen={showCreateModal}
onClose={() => (showCreateModal = false)}
title="Create New"
>
<div class="space-y-4">
<div class="flex gap-2">
<button
type="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")}
>
<span
class="material-symbols-rounded text-h4 mr-1"
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>description</span
>
Document
</button>
<button
type="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")}
>
<span
class="material-symbols-rounded text-h4 mr-1"
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>folder</span
>
Folder
</button>
<button
type="button"
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
'kanban'
? 'border-primary bg-primary/10'
: 'border-light/20'}"
onclick={() => (newDocType = "kanban")}
>
<span
class="material-symbols-rounded text-h4 mr-1"
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>view_kanban</span
>
Kanban
</button>
</div>
<Input
label="Name"
bind:value={newDocName}
placeholder={newDocType === "folder"
? "Folder name"
: newDocType === "kanban"
? "Kanban board name"
: "Document name"}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
>Cancel</Button
>
<Button onclick={handleCreate} disabled={!newDocName.trim()}
>Create</Button
>
</div>
</div>
</Modal>
<!-- Context Menu -->
{#if contextMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-50" onclick={closeContextMenu}></div>
<div
class="fixed z-50 bg-night border border-light/10 rounded-xl shadow-2xl py-1 min-w-[200px]"
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
onclick={contextRename}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>edit</span
>
Rename
</button>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
onclick={contextCopy}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>content_copy</span
>
Make a copy
</button>
<div class="relative">
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
onclick={contextOrganize}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>drive_file_move</span
>
Organize
<span
class="material-symbols-rounded text-light/50 ml-auto"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>chevron_right</span
>
</button>
{#if showOrganizeMenu}
<div
class="absolute left-full top-0 ml-1 bg-night border border-light/10 rounded-xl shadow-2xl py-1 min-w-[180px] max-h-[240px] overflow-auto"
>
{#if contextMenu.doc.parent_id !== null}
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
onclick={() => contextMoveToFolder(null)}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>home</span
>
Home
</button>
{/if}
{#each availableFolders as folder}
{#if folder.id !== contextMenu.doc.parent_id}
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors"
onclick={() => contextMoveToFolder(folder.id)}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>folder</span
>
{folder.name}
</button>
{/if}
{/each}
</div>
{/if}
</div>
<div class="border-t border-light/10 my-1"></div>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-error hover:bg-error/10 transition-colors"
onclick={contextDelete}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>delete</span
>
Delete
</button>
</div>
{/if}
<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="tertiary"
onclick={() => {
showEditModal = false;
editingDoc = null;
newDocName = "";
}}>Cancel</Button
>
<Button onclick={handleRename} disabled={!newDocName.trim()}
>Save</Button
>
</div>
</div>
</Modal>

View File

@@ -1,253 +0,0 @@
<script lang="ts">
import type { DocumentWithChildren } from "$lib/api/documents";
interface Props {
items: DocumentWithChildren[];
selectedId?: string | null;
onSelect: (doc: DocumentWithChildren) => void;
onDoubleClick?: (doc: DocumentWithChildren) => void;
onAdd?: (parentId: string | null) => void;
onMove?: (docId: string, newParentId: string | null) => void;
onEdit?: (doc: DocumentWithChildren) => void;
onDelete?: (doc: DocumentWithChildren) => void;
level?: number;
}
let {
items,
selectedId = null,
onSelect,
onDoubleClick,
onAdd,
onMove,
onEdit,
onDelete,
level = 0,
}: Props = $props();
let expandedFolders = $state<Set<string>>(new Set());
let dragOverId = $state<string | null>(null);
function toggleFolder(id: string, e?: MouseEvent) {
e?.stopPropagation();
const newSet = new Set(expandedFolders);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
expandedFolders = newSet;
}
function handleSelect(doc: DocumentWithChildren) {
onSelect(doc);
}
function handleAdd(e: MouseEvent, parentId: string | null) {
e.stopPropagation();
onAdd?.(parentId);
}
function handleDragStart(e: DragEvent, doc: DocumentWithChildren) {
if (!e.dataTransfer) return;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", doc.id);
}
function handleDragOver(
e: DragEvent,
targetId: string | null,
isFolder: boolean,
) {
if (!isFolder && targetId !== null) return;
e.preventDefault();
dragOverId = targetId;
}
function handleDragLeave() {
dragOverId = null;
}
function handleDrop(e: DragEvent, targetFolderId: string | null) {
e.preventDefault();
dragOverId = null;
const docId = e.dataTransfer?.getData("text/plain");
if (docId && docId !== targetFolderId) {
onMove?.(docId, targetFolderId);
}
}
</script>
<div
class="space-y-0.5"
ondragover={(e) => level === 0 && handleDragOver(e, null, true)}
ondragleave={handleDragLeave}
ondrop={(e) => level === 0 && handleDrop(e, null)}
role="tree"
>
{#each items as item}
<div role="treeitem">
<div
class="group w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors cursor-pointer
{selectedId === item.id
? 'bg-primary/20 text-primary'
: 'text-light/80 hover:bg-light/5'}
{dragOverId === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
onclick={() => handleSelect(item)}
ondblclick={() => onDoubleClick?.(item)}
draggable="true"
ondragstart={(e) => handleDragStart(e, item)}
ondragover={(e) =>
handleDragOver(e, item.id, item.type === "folder")}
ondragleave={handleDragLeave}
ondrop={(e) => item.type === "folder" && handleDrop(e, item.id)}
role="button"
tabindex="0"
>
{#if item.type === "folder"}
<button
class="p-0.5 hover:bg-light/10 rounded"
onclick={(e) => toggleFolder(item.id, e)}
aria-label="Toggle folder"
>
<svg
class="w-4 h-4 transition-transform {expandedFolders.has(
item.id,
)
? 'rotate-90'
: ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
<svg
class="w-4 h-4 text-warning"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H12L10 5H5C3.89543 5 3 5.89543 3 7Z"
/>
</svg>
{:else}
<div class="w-5"></div>
<svg
class="w-4 h-4 text-light/50"
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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
{/if}
<span class="flex-1 truncate text-sm">{item.name}</span>
<div
class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity"
>
{#if item.type === "folder" && onAdd}
<button
class="p-1 hover:bg-light/10 rounded"
onclick={(e) => handleAdd(e, item.id)}
aria-label="Add to folder"
>
<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}
{#if onEdit}
<button
class="p-1 hover:bg-light/10 rounded"
onclick={(e) => {
e.stopPropagation();
onEdit(item);
}}
aria-label="Rename"
>
<svg
class="w-4 h-4"
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>
{/if}
{#if onDelete}
<button
class="p-1 hover:bg-error/20 hover:text-error rounded"
onclick={(e) => {
e.stopPropagation();
onDelete(item);
}}
aria-label="Delete"
>
<svg
class="w-4 h-4"
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>
{/if}
</div>
</div>
{#if item.type === "folder" && expandedFolders.has(item.id)}
<div class="ml-4 border-l border-light/10 pl-2">
{#if item.children?.length}
<svelte:self
items={item.children}
{selectedId}
{onSelect}
{onAdd}
{onMove}
{onEdit}
{onDelete}
level={level + 1}
/>
{:else}
<p class="text-light/30 text-xs px-3 py-2 italic">
Empty folder
</p>
{/if}
</div>
{/if}
</div>
{/each}
{#if items.length === 0 && level === 0}
<p class="text-light/40 text-sm px-3 py-2">No documents yet</p>
{/if}
</div>

View File

@@ -1,2 +1,3 @@
export { default as FileTree } from './FileTree.svelte';
export { default as Editor } from './Editor.svelte';
export { default as DocumentViewer } from './DocumentViewer.svelte';
export { default as FileBrowser } from './FileBrowser.svelte';

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { getContext, onDestroy } from "svelte";
import { Button, Input, Icon } from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface ChecklistItem {
id: string;
card_id: string;
title: string;
completed: boolean;
position: number;
}
interface Props {
cardId: string;
items: ChecklistItem[];
onItemsChange: (items: ChecklistItem[]) => void;
}
let { cardId, items, onItemsChange }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let isMounted = $state(true);
let newItemTitle = $state("");
let isAdding = $state(false);
onDestroy(() => {
isMounted = false;
});
const completedCount = $derived(items.filter((i) => i.completed).length);
const progress = $derived(
items.length > 0 ? (completedCount / items.length) * 100 : 0,
);
async function handleAddItem() {
if (!newItemTitle.trim() || !isMounted) return;
isAdding = true;
const position = items.length;
const { data, error } = await supabase
.from("kanban_checklist_items")
.insert({
card_id: cardId,
title: newItemTitle.trim(),
position,
completed: false,
})
.select()
.single();
if (!isMounted) return;
if (!error && data) {
onItemsChange([...items, data as ChecklistItem]);
newItemTitle = "";
}
isAdding = false;
}
async function toggleItem(item: ChecklistItem) {
if (!isMounted) return;
// Optimistic update
const updated = items.map((i) =>
i.id === item.id ? { ...i, completed: !i.completed } : i,
);
onItemsChange(updated);
const { error } = await supabase
.from("kanban_checklist_items")
.update({ completed: !item.completed })
.eq("id", item.id);
if (error && isMounted) {
// Rollback on error
onItemsChange(items);
}
}
async function deleteItem(itemId: string) {
if (!isMounted) return;
const { error } = await supabase
.from("kanban_checklist_items")
.delete()
.eq("id", itemId);
if (!error && isMounted) {
onItemsChange(items.filter((i) => i.id !== itemId));
}
}
</script>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-light">Checklist</h4>
<span class="text-xs text-light/50"
>{completedCount}/{items.length}</span
>
</div>
<!-- Progress bar -->
{#if items.length > 0}
<div class="h-1.5 bg-dark rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all duration-300"
style="width: {progress}%"
></div>
</div>
{/if}
<!-- Checklist items -->
<div class="space-y-1">
{#each items as item (item.id)}
<div class="flex items-center gap-2 group py-1">
<button
type="button"
class="w-4 h-4 rounded border flex items-center justify-center transition-colors {item.completed
? 'bg-primary border-primary'
: 'border-light/30 hover:border-primary'}"
onclick={() => toggleItem(item)}
>
{#if item.completed}
<Icon name="check" size={12} class="text-white" />
{/if}
</button>
<span
class="flex-1 text-sm {item.completed
? 'line-through text-light/40'
: 'text-light'}"
>
{item.title}
</span>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all"
onclick={() => deleteItem(item.id)}
aria-label="Delete item"
>
<Icon name="close" size={14} />
</button>
</div>
{/each}
</div>
<!-- Add item form -->
<form
class="flex gap-2 items-end"
onsubmit={(e) => {
e.preventDefault();
handleAddItem();
}}
>
<Input
placeholder="Add checklist item..."
bind:value={newItemTitle}
disabled={isAdding}
/>
<Button
type="submit"
size="md"
disabled={!newItemTitle.trim() || isAdding}
>
Add
</Button>
</form>
</div>

View File

@@ -0,0 +1,159 @@
<script lang="ts">
import { getContext, onDestroy } from "svelte";
import { Button, Input, Icon, Avatar } from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Comment {
id: string;
card_id: string;
user_id: string;
content: string;
created_at: string;
profiles?: { full_name: string | null; email: string };
}
interface Props {
cardId: string;
userId: string;
comments: Comment[];
onCommentsChange: (comments: Comment[]) => void;
}
let { cardId, userId, comments, onCommentsChange }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let isMounted = $state(true);
let newComment = $state("");
let isAdding = $state(false);
onDestroy(() => {
isMounted = false;
});
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
async function handleAddComment() {
if (!newComment.trim() || !isMounted) return;
isAdding = true;
const { data, error } = await supabase
.from("kanban_comments")
.insert({
card_id: cardId,
user_id: userId,
content: newComment.trim(),
})
.select(
`
id,
card_id,
user_id,
content,
created_at,
profiles:user_id (full_name, email)
`,
)
.single();
if (!isMounted) return;
if (!error && data) {
onCommentsChange([...comments, data as Comment]);
newComment = "";
}
isAdding = false;
}
async function deleteComment(commentId: string) {
if (!isMounted) return;
const { error } = await supabase
.from("kanban_comments")
.delete()
.eq("id", commentId);
if (!error && isMounted) {
onCommentsChange(comments.filter((c) => c.id !== commentId));
}
}
</script>
<div class="space-y-3">
<span class="px-3 font-bold font-body text-body text-white">Comments</span>
<!-- Comment list -->
{#if comments.length > 0}
<div class="space-y-3 max-h-48 overflow-y-auto">
{#each comments as comment (comment.id)}
<div class="flex gap-2 group">
<Avatar
name={comment.profiles?.full_name ||
comment.profiles?.email ||
"?"}
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-light truncate"
>
{comment.profiles?.full_name ||
comment.profiles?.email ||
"Unknown"}
</span>
<span class="text-xs text-light/40">
{formatDate(comment.created_at)}
</span>
{#if comment.user_id === userId}
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-error transition-all ml-auto"
onclick={() => deleteComment(comment.id)}
aria-label="Delete comment"
>
<Icon name="close" size={12} />
</button>
{/if}
</div>
<p class="text-sm text-light/70 break-words">
{comment.content}
</p>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-light/40 text-center py-2">No comments yet</p>
{/if}
<!-- Add comment form -->
<form
class="flex gap-2 items-end"
onsubmit={(e) => {
e.preventDefault();
handleAddComment();
}}
>
<Input
placeholder="Add a comment..."
bind:value={newComment}
disabled={isAdding}
/>
<Button
type="submit"
size="md"
disabled={!newComment.trim() || isAdding}
>
Send
</Button>
</form>
</div>

View File

@@ -1,10 +1,23 @@
<script lang="ts">
import { getContext } from "svelte";
import { Modal, Button, Input, Textarea } from "$lib/components/ui";
import { getContext, onDestroy } from "svelte";
import {
Modal,
Button,
Input,
Textarea,
Select,
AssigneePicker,
Icon,
} from "$lib/components/ui";
import type { KanbanCard } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
let isMounted = $state(true);
onDestroy(() => {
isMounted = false;
});
interface ChecklistItem {
id: string;
card_id: string;
@@ -33,6 +46,12 @@
};
}
interface OrgTag {
id: string;
name: string;
color: string | null;
}
interface Props {
card: KanbanCard | null;
isOpen: boolean;
@@ -42,6 +61,7 @@
mode?: "edit" | "create";
columnId?: string;
userId?: string;
orgId?: string;
onCreate?: (card: KanbanCard) => void;
members?: Member[];
}
@@ -55,6 +75,7 @@
mode = "edit",
columnId,
userId,
orgId,
onCreate,
members = [],
}: Props = $props();
@@ -74,20 +95,35 @@
let isSaving = $state(false);
let showAssigneePicker = $state(false);
// Tags state
let orgTags = $state<OrgTag[]>([]);
let cardTagIds = $state<Set<string>>(new Set());
let newTagName = $state("");
let showTagInput = $state(false);
const TAG_COLORS = [
"#00A3E0",
"#33E000",
"#E03D00",
"#FFAB00",
"#A855F7",
"#EC4899",
"#6366F1",
];
$effect(() => {
if (isOpen) {
if (mode === "edit" && card) {
title = card.title;
description = card.description ?? "";
assigneeId = (card as any).assignee_id ?? null;
dueDate = (card as any).due_date
? new Date((card as any).due_date)
.toISOString()
.split("T")[0]
assigneeId = card.assignee_id ?? null;
dueDate = card.due_date
? new Date(card.due_date).toISOString().split("T")[0]
: "";
priority = (card as any).priority ?? "medium";
priority = card.priority ?? "medium";
loadChecklist();
loadComments();
loadTags();
} else if (mode === "create") {
title = "";
description = "";
@@ -96,12 +132,14 @@
priority = "medium";
checklist = [];
comments = [];
cardTagIds = new Set();
loadOrgTags();
}
}
});
async function loadChecklist() {
if (!card) return;
if (!card || !isMounted) return;
isLoading = true;
const { data } = await supabase
@@ -110,12 +148,13 @@
.eq("card_id", card.id)
.order("position");
if (!isMounted) return;
checklist = (data ?? []) as ChecklistItem[];
isLoading = false;
}
async function loadComments() {
if (!card) return;
if (!card || !isMounted) return;
const { data } = await supabase
.from("kanban_comments")
@@ -132,10 +171,75 @@
.eq("card_id", card.id)
.order("created_at", { ascending: true });
if (!isMounted) return;
comments = (data ?? []) as Comment[];
}
async function loadOrgTags() {
if (!orgId) return;
const { data } = await supabase
.from("tags")
.select("id, name, color")
.eq("org_id", orgId)
.order("name");
if (!isMounted) return;
orgTags = (data ?? []) as OrgTag[];
}
async function loadTags() {
await loadOrgTags();
if (!card) return;
const { data } = await supabase
.from("card_tags")
.select("tag_id")
.eq("card_id", card.id);
if (!isMounted) return;
cardTagIds = new Set((data ?? []).map((t) => t.tag_id));
}
async function toggleTag(tagId: string) {
if (!card) return;
if (cardTagIds.has(tagId)) {
await supabase
.from("card_tags")
.delete()
.eq("card_id", card.id)
.eq("tag_id", tagId);
cardTagIds.delete(tagId);
cardTagIds = new Set(cardTagIds);
} else {
await supabase
.from("card_tags")
.insert({ card_id: card.id, tag_id: tagId });
cardTagIds.add(tagId);
cardTagIds = new Set(cardTagIds);
}
}
async function createTag() {
if (!newTagName.trim() || !orgId) return;
const color = TAG_COLORS[orgTags.length % TAG_COLORS.length];
const { data: newTag, error } = await supabase
.from("tags")
.insert({ name: newTagName.trim(), org_id: orgId, color })
.select()
.single();
if (!error && newTag) {
orgTags = [...orgTags, newTag as OrgTag];
if (card) {
await supabase
.from("card_tags")
.insert({ card_id: card.id, tag_id: newTag.id });
cardTagIds.add(newTag.id);
cardTagIds = new Set(cardTagIds);
}
}
newTagName = "";
showTagInput = false;
}
async function handleSave() {
if (!isMounted) return;
if (mode === "create") {
await handleCreate();
return;
@@ -178,7 +282,7 @@
.eq("id", columnId)
.single();
const position = (column as any)?.cards?.[0]?.count ?? 0;
const position = (column as any)?.cards?.[0]?.count ?? 0; // join aggregation not typed
const { data: newCard, error } = await supabase
.from("kanban_cards")
@@ -186,6 +290,9 @@
column_id: columnId,
title,
description: description || null,
priority: priority || null,
due_date: dueDate || null,
assignee_id: assigneeId || null,
position,
created_by: userId,
})
@@ -320,133 +427,97 @@
rows={3}
/>
<!-- Assignee, Due Date, Priority Row -->
<div class="grid grid-cols-3 gap-4">
<!-- Assignee -->
<div class="relative">
<label class="block text-sm font-medium text-light mb-1"
>Assignee</label
>
<button
type="button"
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-left text-sm flex items-center gap-2 hover:border-light/40 transition-colors"
onclick={() =>
(showAssigneePicker = !showAssigneePicker)}
>
{#if assigneeId && getAssignee(assigneeId)}
{@const assignee = getAssignee(assigneeId)}
<div
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs text-primary"
>
{(assignee?.profiles.full_name ||
assignee?.profiles.email ||
"?")[0].toUpperCase()}
</div>
<span class="text-light truncate"
>{assignee?.profiles.full_name ||
assignee?.profiles.email}</span
>
{:else}
<div
class="w-6 h-6 rounded-full bg-light/10 flex items-center justify-center"
>
<svg
class="w-3 h-3 text-light/40"
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>
</div>
<span class="text-light/40">Unassigned</span>
{/if}
</button>
{#if showAssigneePicker}
<div
class="absolute top-full left-0 right-0 mt-1 bg-dark border border-light/20 rounded-lg shadow-lg z-10 max-h-48 overflow-y-auto"
<!-- Tags -->
<div>
<span
class="px-3 font-bold font-body text-body text-white mb-2 block"
>Tags</span
>
<div class="flex flex-wrap gap-2 items-center">
{#each orgTags as tag}
<button
type="button"
class="rounded-[4px] px-2 py-1 font-body font-bold text-[13px] leading-none transition-all border-2"
style="background-color: {cardTagIds.has(tag.id)
? tag.color || '#00A3E0'
: 'transparent'}; color: {cardTagIds.has(tag.id)
? '#0A121F'
: tag.color ||
'#00A3E0'}; border-color: {tag.color ||
'#00A3E0'};"
onclick={() => toggleTag(tag.id)}
>
{tag.name}
</button>
{/each}
{#if showTagInput}
<div class="flex gap-1 items-center">
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white w-24 focus:outline-none focus:border-primary"
placeholder="Tag name"
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
/>
<button
class="w-full px-3 py-2 text-left text-sm text-light/60 hover:bg-light/5 flex items-center gap-2"
type="button"
class="text-primary text-sm font-bold hover:text-primary/80"
onclick={createTag}
>
Add
</button>
<button
type="button"
class="text-light/40 text-sm hover:text-light"
onclick={() => {
assigneeId = null;
showAssigneePicker = false;
showTagInput = false;
newTagName = "";
}}
>
<div
class="w-6 h-6 rounded-full bg-light/10"
></div>
Unassigned
Cancel
</button>
{#each members as member}
<button
class="w-full px-3 py-2 text-left text-sm hover:bg-light/5 flex items-center gap-2 {assigneeId ===
member.user_id
? 'bg-primary/10 text-primary'
: 'text-light'}"
onclick={() => {
assigneeId = member.user_id;
showAssigneePicker = false;
}}
>
<div
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs"
>
{(member.profiles.full_name ||
member.profiles.email ||
"?")[0].toUpperCase()}
</div>
{member.profiles.full_name ||
member.profiles.email}
</button>
{/each}
</div>
{:else}
<button
type="button"
class="rounded-lg px-2 py-1 text-sm text-light/50 hover:text-light border border-dashed border-light/20 hover:border-light/40 transition-colors"
onclick={() => (showTagInput = true)}
>
+ New tag
</button>
{/if}
</div>
</div>
<!-- Due Date -->
<div>
<label
for="due-date"
class="block text-sm font-medium text-light mb-1"
>Due Date</label
>
<input
id="due-date"
type="date"
bind:value={dueDate}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary"
/>
</div>
<!-- Assignee, Due Date, Priority Row -->
<div class="grid grid-cols-3 gap-4">
<AssigneePicker
label="Assignee"
value={assigneeId}
{members}
onchange={(id) => (assigneeId = id)}
/>
<!-- Priority -->
<div>
<label
for="priority"
class="block text-sm font-medium text-light mb-1"
>Priority</label
>
<select
id="priority"
bind:value={priority}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<Input type="date" label="Due Date" bind:value={dueDate} />
<Select
label="Priority"
bind:value={priority}
placeholder=""
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
]}
/>
</div>
<div>
<div class="flex items-center justify-between mb-3">
<label class="text-sm font-medium text-light"
>Checklist</label
<span class="px-3 font-bold font-body text-body text-white"
>Checklist</span
>
{#if checklist.length > 0}
<span class="text-xs text-light/50"
@@ -499,36 +570,26 @@
{item.title}
</span>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all"
onclick={() => deleteItem(item.id)}
aria-label="Delete item"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<Icon name="close" size={16} />
</button>
</div>
{/each}
</div>
<div class="flex gap-2">
<input
type="text"
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
<div class="flex gap-2 items-end">
<Input
placeholder="Add an item..."
bind:value={newItemTitle}
onkeydown={(e) =>
e.key === "Enter" && handleAddItem()}
/>
<Button
size="sm"
size="md"
onclick={handleAddItem}
disabled={!newItemTitle.trim()}
>
@@ -541,8 +602,9 @@
<!-- Comments Section -->
{#if mode === "edit"}
<div>
<label class="block text-sm font-medium text-light mb-3"
>Comments</label
<span
class="px-3 font-bold font-body text-body text-white mb-3 block"
>Comments</span
>
<div class="space-y-3 mb-3 max-h-48 overflow-y-auto">
{#each comments as comment}
@@ -550,8 +612,8 @@
<div
class="w-8 h-8 rounded-full bg-primary/20 flex-shrink-0 flex items-center justify-center text-xs text-primary"
>
{((comment.profiles as any)?.full_name ||
(comment.profiles as any)?.email ||
{(comment.profiles?.full_name ||
comment.profiles?.email ||
"?")[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0">
@@ -559,10 +621,8 @@
<span
class="text-sm font-medium text-light"
>
{(comment.profiles as any)
?.full_name ||
(comment.profiles as any)
?.email ||
{comment.profiles?.full_name ||
comment.profiles?.email ||
"Unknown"}
</span>
<span class="text-xs text-light/40"
@@ -583,17 +643,15 @@
</p>
{/if}
</div>
<div class="flex gap-2">
<input
type="text"
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
<div class="flex gap-2 items-end">
<Input
placeholder="Add a comment..."
bind:value={newComment}
onkeydown={(e) =>
e.key === "Enter" && handleAddComment()}
/>
<Button
size="sm"
size="md"
onclick={handleAddComment}
disabled={!newComment.trim()}
>
@@ -614,7 +672,7 @@
<div></div>
{/if}
<div class="flex gap-2">
<Button variant="ghost" onclick={onClose}>Cancel</Button>
<Button variant="tertiary" onclick={onClose}>Cancel</Button>
<Button
onclick={handleSave}
loading={isSaving}

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { Input, Select, AssigneePicker, Badge } from "$lib/components/ui";
interface Member {
id: string;
user_id: string;
profiles: {
id: string;
full_name: string | null;
email: string;
avatar_url: string | null;
};
}
interface Props {
assigneeId: string | null;
dueDate: string;
priority: string;
members: Member[];
onAssigneeChange: (id: string | null) => void;
onDueDateChange: (date: string) => void;
onPriorityChange: (priority: string) => void;
}
let {
assigneeId,
dueDate,
priority,
members,
onAssigneeChange,
onDueDateChange,
onPriorityChange,
}: Props = $props();
let dueDateLocal = $state("");
$effect(() => {
dueDateLocal = dueDate;
});
const priorityColors: Record<string, string> = {
low: "bg-green-500/20 text-green-400",
medium: "bg-yellow-500/20 text-yellow-400",
high: "bg-orange-500/20 text-orange-400",
urgent: "bg-red-500/20 text-red-400",
};
</script>
<div class="grid grid-cols-3 gap-3">
<AssigneePicker
label="Assignee"
value={assigneeId}
{members}
onchange={onAssigneeChange}
/>
<Input
type="date"
label="Due Date"
bind:value={dueDateLocal}
onchange={() => onDueDateChange(dueDateLocal)}
/>
<Select
label="Priority"
value={priority}
placeholder=""
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
]}
onchange={(e) =>
onPriorityChange((e.target as HTMLSelectElement).value)}
/>
</div>
<!-- Priority indicator pill -->
{#if priority && priority !== "medium"}
<div class="mt-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {priorityColors[
priority
] || priorityColors.medium}"
>
{priority.charAt(0).toUpperCase() + priority.slice(1)} Priority
</span>
</div>
{/if}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { ColumnWithCards } from "$lib/api/kanban";
import type { KanbanCard } from "$lib/supabase/types";
import { Button, Card, Badge } from "$lib/components/ui";
import KanbanCardComponent from "./KanbanCard.svelte";
interface Props {
columns: ColumnWithCards[];
@@ -29,15 +29,11 @@
canEdit = true,
}: Props = $props();
function handleDeleteCard(e: MouseEvent, cardId: string) {
e.stopPropagation();
if (confirm("Are you sure you want to delete this task?")) {
onDeleteCard?.(cardId);
}
}
let draggedCard = $state<KanbanCard | null>(null);
let dragOverColumn = $state<string | null>(null);
let dragOverCardIndex = $state<{ columnId: string; index: number } | null>(
null,
);
function handleDragStart(e: DragEvent, card: KanbanCard) {
draggedCard = card;
@@ -47,272 +43,193 @@
}
}
function handleDragOver(e: DragEvent, columnId: string) {
function handleColumnDragOver(e: DragEvent, columnId: string) {
e.preventDefault();
dragOverColumn = columnId;
}
function handleDragLeave() {
function handleColumnDragLeave() {
dragOverColumn = null;
dragOverCardIndex = null;
}
function handleCardDragOver(e: DragEvent, columnId: string, index: number) {
e.preventDefault();
e.stopPropagation();
if (!draggedCard) return;
// Determine if we're in the top or bottom half of the card
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const dropIndex = e.clientY < midY ? index : index + 1;
dragOverColumn = columnId;
dragOverCardIndex = { columnId, index: dropIndex };
}
function handleDrop(e: DragEvent, columnId: string) {
e.preventDefault();
const targetIndex = dragOverCardIndex;
dragOverColumn = null;
dragOverCardIndex = null;
if (draggedCard && draggedCard.column_id !== columnId) {
const column = columns.find((c) => c.id === columnId);
const newPosition = column?.cards.length ?? 0;
onCardMove?.(draggedCard.id, columnId, newPosition);
if (!draggedCard) return;
const column = columns.find((c) => c.id === columnId);
if (!column) {
draggedCard = null;
return;
}
let newPosition: number;
if (targetIndex && targetIndex.columnId === columnId) {
newPosition = targetIndex.index;
// If moving within the same column and the card is above the target, adjust
if (draggedCard.column_id === columnId) {
const currentIndex = column.cards.findIndex(
(c) => c.id === draggedCard!.id,
);
if (currentIndex !== -1 && currentIndex < newPosition) {
newPosition = Math.max(0, newPosition - 1);
}
// No-op if dropping in the same position
if (currentIndex === newPosition) {
draggedCard = null;
return;
}
}
} else {
newPosition = column.cards.length;
}
onCardMove?.(draggedCard.id, columnId, newPosition);
draggedCard = null;
}
function formatDueDate(dateStr: string | null): string {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) return "Overdue";
if (days === 0) return "Today";
if (days === 1) return "Tomorrow";
return date.toLocaleDateString();
}
function getDueDateColor(
dateStr: string | null,
): "error" | "warning" | "default" {
if (!dateStr) return "default";
const date = new Date(dateStr);
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) return "error";
if (days <= 2) return "warning";
return "default";
}
</script>
<div class="flex gap-4 overflow-x-auto pb-4 min-h-[500px] scrollbar-visible">
<div class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll">
{#each columns as column}
<div
class="flex-shrink-0 w-72 bg-surface/80 backdrop-blur-sm rounded-xl p-3 flex flex-col max-h-[calc(100vh-200px)] border border-light/10 shadow-lg {dragOverColumn ===
class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full {dragOverColumn ===
column.id
? 'ring-2 ring-primary bg-primary/5'
? 'ring-2 ring-primary'
: ''}"
ondragover={(e) => handleDragOver(e, column.id)}
ondragleave={handleDragLeave}
ondragover={(e) => handleColumnDragOver(e, column.id)}
ondragleave={handleColumnDragLeave}
ondrop={(e) => handleDrop(e, column.id)}
role="list"
>
<div class="flex items-center justify-between mb-3 px-1">
<h3 class="font-medium text-light flex items-center gap-2">
{column.name}
<span
class="text-xs text-light/50 bg-light/10 px-1.5 py-0.5 rounded"
<!-- Column Header -->
<div class="flex items-center gap-2 p-1 rounded-[32px]">
<div class="flex items-center gap-2 flex-1 min-w-0">
<h3 class="font-heading text-h4 text-white truncate">
{column.name}
</h3>
<div
class="bg-dark flex items-center justify-center px-1.5 py-0.5 rounded-[8px] shrink-0"
>
{column.cards.length}
<span class="font-heading text-h6 text-white"
>{column.cards.length}</span
>
</div>
</div>
<button
type="button"
class="p-1 hover:bg-night rounded-lg transition-colors shrink-0"
onclick={() => onDeleteColumn?.(column.id)}
aria-label="Column options"
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</h3>
<div class="flex items-center gap-1">
{#if column.color}
</button>
</div>
<!-- Cards -->
<div class="flex-1 overflow-y-auto flex flex-col gap-0">
{#each column.cards as card, cardIndex}
<!-- Drop indicator before card -->
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === cardIndex && draggedCard.id !== card.id}
<div
class="w-3 h-3 rounded-full"
style="background-color: {column.color}"
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
></div>
{/if}
{#if canEdit}
<button
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-error/20 text-light/40 hover:text-error transition-all"
onclick={() => onDeleteColumn?.(column.id)}
title="Delete column"
>
<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>
{/if}
</div>
</div>
<div class="flex-1 overflow-y-auto space-y-2">
{#each column.cards as card}
<div
class="group bg-dark rounded-lg p-3 cursor-pointer hover:ring-1 hover:ring-light/20 transition-all relative"
class:opacity-50={draggedCard?.id === card.id}
draggable={canEdit}
ondragstart={(e) => handleDragStart(e, card)}
onclick={() => onCardClick?.(card)}
onkeydown={(e) =>
e.key === "Enter" && onCardClick?.(card)}
role="listitem"
tabindex="0"
class="mb-2"
ondragover={(e) =>
handleCardDragOver(e, column.id, cardIndex)}
>
{#if canEdit}
<button
class="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-error/20 text-light/40 hover:text-error transition-all"
onclick={(e) => handleDeleteCard(e, card.id)}
title="Delete task"
>
<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>
{/if}
{#if card.color}
<div
class="w-full h-1 rounded-full mb-2"
style="background-color: {card.color}"
></div>
{/if}
<p class="text-sm text-light pr-6">{card.title}</p>
{#if card.description}
<p class="text-xs text-light/50 mt-1 line-clamp-2">
{card.description}
</p>
{/if}
{#if card.due_date || (card as any).checklist_total > 0 || (card as any).assignee_id}
<div class="mt-2 flex items-center gap-2 flex-wrap">
{#if card.due_date}
<Badge
size="sm"
variant={getDueDateColor(card.due_date)}
>
{formatDueDate(card.due_date)}
</Badge>
{/if}
{#if (card as any).checklist_total > 0}
<span
class="text-xs flex items-center gap-1 {(
card as any
).checklist_done ===
(card as any).checklist_total
? 'text-success'
: 'text-light/50'}"
>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline
points="9,11 12,14 22,4"
/>
<path
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"
/>
</svg>
{(card as any).checklist_done}/{(
card as any
).checklist_total}
</span>
{/if}
{#if (card as any).assignee_id}
<div
class="w-5 h-5 rounded-full bg-primary/30 flex items-center justify-center text-[10px] text-primary ml-auto"
title="Assigned"
>
<svg
class="w-3 h-3"
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>
</div>
{/if}
</div>
{/if}
<KanbanCardComponent
{card}
isDragging={draggedCard?.id === card.id}
draggable={canEdit}
ondragstart={(e) => handleDragStart(e, card)}
onclick={() => onCardClick?.(card)}
ondelete={canEdit
? (id) => onDeleteCard?.(id)
: undefined}
/>
</div>
{/each}
<!-- Drop indicator at end of column -->
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === column.cards.length}
<div
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
></div>
{/if}
</div>
<!-- Add Card Button (secondary style) -->
{#if canEdit}
<button
class="mt-2 w-full py-2 text-sm text-light/50 hover:text-light hover:bg-light/5 rounded-lg transition-colors flex items-center justify-center gap-1"
type="button"
class="w-full py-3 border-[3px] border-primary text-primary font-heading text-h5 rounded-[32px] hover:bg-primary/10 transition-colors"
onclick={() => onAddCard?.(column.id)}
>
<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>
Add card
</button>
{/if}
</div>
{/each}
<!-- Add Column Button -->
{#if canEdit}
<button
class="flex-shrink-0 w-72 h-12 bg-light/5 hover:bg-light/10 rounded-xl flex items-center justify-center gap-2 text-light/50 hover:text-light transition-colors"
type="button"
class="flex-shrink-0 w-[256px] h-12 border-[3px] border-primary/30 hover:border-primary rounded-[32px] flex items-center justify-center gap-2 text-primary/50 hover:text-primary transition-colors"
onclick={() => onAddColumn?.()}
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
add
</span>
Add column
</button>
{/if}
</div>
<style>
.scrollbar-visible {
.kanban-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(229, 230, 240, 0.3) transparent;
}
.scrollbar-visible::-webkit-scrollbar {
.kanban-scroll::-webkit-scrollbar {
height: 8px;
}
.scrollbar-visible::-webkit-scrollbar-track {
background: rgba(229, 230, 240, 0.1);
.kanban-scroll::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
.scrollbar-visible::-webkit-scrollbar-thumb {
.kanban-scroll::-webkit-scrollbar-thumb {
background: rgba(229, 230, 240, 0.3);
border-radius: 4px;
}
.scrollbar-visible::-webkit-scrollbar-thumb:hover {
.kanban-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(229, 230, 240, 0.5);
}
</style>

View File

@@ -1,17 +1,24 @@
<script lang="ts">
import type { KanbanCard as KanbanCardType } from "$lib/supabase/types";
import { Badge } from "$lib/components/ui";
import { Avatar } from "$lib/components/ui";
// Extended card type with optional new fields from migration
interface ExtendedCard extends KanbanCardType {
priority?: "low" | "medium" | "high" | "urgent" | null;
assignee_id?: string | null;
interface Tag {
id: string;
name: string;
color: string;
}
interface Props {
card: ExtendedCard;
card: KanbanCardType & {
tags?: Tag[];
checklist_done?: number;
checklist_total?: number;
assignee_name?: string | null;
assignee_avatar?: string | null;
};
isDragging?: boolean;
onclick?: () => void;
ondelete?: (cardId: string) => void;
draggable?: boolean;
ondragstart?: (e: DragEvent) => void;
}
@@ -20,114 +27,125 @@
card,
isDragging = false,
onclick,
ondelete,
draggable = true,
ondragstart,
}: Props = $props();
function handleDelete(e: MouseEvent) {
e.stopPropagation();
if (confirm("Are you sure you want to delete this card?")) {
ondelete?.(card.id);
}
}
function formatDueDate(dateStr: string | null): string {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) return "Overdue";
if (days === 0) return "Today";
if (days === 1) return "Tomorrow";
return date.toLocaleDateString();
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function getDueDateVariant(
dateStr: string | null,
): "error" | "warning" | "default" {
if (!dateStr) return "default";
const date = new Date(dateStr);
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) return "error";
if (days <= 2) return "warning";
return "default";
}
function getPriorityColor(priority: string | null): string {
switch (priority) {
case "urgent":
return "#E03D00";
case "high":
return "#FFAB00";
case "medium":
return "#00A3E0";
case "low":
return "#33E000";
default:
return "#E5E6F0";
}
}
const hasFooter = $derived(
!!card.due_date ||
(card.checklist_total ?? 0) > 0 ||
!!card.assignee_id,
);
</script>
<div
class="bg-night rounded-[16px] p-3 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group"
<button
type="button"
class="bg-night rounded-[16px] p-2 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group w-full text-left overflow-clip flex flex-col gap-2 relative"
class:opacity-50={isDragging}
{draggable}
{ondragstart}
{onclick}
onkeydown={(e) => e.key === "Enter" && onclick?.()}
role="listitem"
tabindex="0"
>
<!-- Priority indicator -->
{#if card.priority}
<div
class="w-full h-1 rounded-full mb-2"
style="background-color: {getPriorityColor(card.priority)}"
></div>
{:else if card.color}
<div
class="w-full h-1 rounded-full mb-2"
style="background-color: {card.color}"
></div>
<!-- Delete button (top-right, visible on hover) -->
{#if ondelete}
<button
type="button"
class="absolute top-1 right-1 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/20 transition-all z-10"
onclick={handleDelete}
aria-label="Delete card"
>
<span
class="material-symbols-rounded text-light/40 hover:text-error"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
delete
</span>
</button>
{/if}
<!-- Tags / Chips -->
{#if card.tags && card.tags.length > 0}
<div class="flex gap-[10px] items-start flex-wrap">
{#each card.tags as tag}
<span
class="rounded-[4px] px-1 py-[4px] font-body font-bold text-[14px] text-night leading-none overflow-clip"
style="background-color: {tag.color || '#00A3E0'}"
>
{tag.name}
</span>
{/each}
</div>
{/if}
<!-- Title -->
<p class="text-sm font-medium text-light">{card.title}</p>
<p class="font-body text-body text-white w-full leading-none">
{card.title}
</p>
<!-- Description -->
{#if card.description}
<p class="text-xs text-light/50 mt-1 line-clamp-2">
{card.description}
</p>
{/if}
<!-- Bottom row: details + avatar -->
{#if hasFooter}
<div class="flex items-center justify-between w-full">
<div class="flex gap-1 items-center">
<!-- Due date -->
{#if card.due_date}
<div class="flex items-center">
<span
class="material-symbols-rounded text-light p-1"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
calendar_today
</span>
<span
class="font-body text-[12px] text-light leading-none"
>
{formatDueDate(card.due_date)}
</span>
</div>
{/if}
<!-- Footer with metadata -->
<div class="mt-3 flex items-center justify-between gap-2">
<!-- Due date -->
{#if card.due_date}
<Badge size="sm" variant={getDueDateVariant(card.due_date)}>
<svg
class="w-3 h-3 mr-1"
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>
{formatDueDate(card.due_date)}
</Badge>
{/if}
<!-- Assignee placeholder -->
{#if card.assignee_id}
<div
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium"
>
A
<!-- Checklist -->
{#if (card.checklist_total ?? 0) > 0}
<div class="flex items-center">
<span
class="material-symbols-rounded text-light p-1"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
check_box
</span>
<span
class="font-body text-[12px] text-light leading-none"
>
{card.checklist_done ?? 0}/{card.checklist_total}
</span>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Assignee avatar -->
{#if card.assignee_id}
<Avatar
name={card.assignee_name || "?"}
src={card.assignee_avatar}
size="sm"
/>
{/if}
</div>
{/if}
</button>

View File

@@ -1,3 +1,6 @@
export { default as KanbanBoard } from './KanbanBoard.svelte';
export { default as CardDetailModal } from './CardDetailModal.svelte';
export { default as KanbanCard } from './KanbanCard.svelte';
export { default as CardChecklist } from './CardChecklist.svelte';
export { default as CardComments } from './CardComments.svelte';
export { default as CardMetadata } from './CardMetadata.svelte';

View File

@@ -0,0 +1,216 @@
<script lang="ts">
import { Button, Input, Avatar } from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { toasts } from "$lib/stores/toast.svelte";
import { invalidateAll } from "$app/navigation";
interface Props {
supabase: SupabaseClient<Database>;
org: {
id: string;
name: string;
slug: string;
avatar_url?: string | null;
};
isOwner: boolean;
onLeave: () => void;
onDelete: () => void;
}
let { supabase, org, isOwner, onLeave, onDelete }: Props = $props();
let orgName = $state(org.name);
let orgSlug = $state(org.slug);
let avatarUrl = $state(org.avatar_url ?? null);
let isSaving = $state(false);
let isUploading = $state(false);
let avatarInput = $state<HTMLInputElement | null>(null);
$effect(() => {
orgName = org.name;
orgSlug = org.slug;
avatarUrl = org.avatar_url ?? null;
});
async function handleAvatarUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Validate file
if (!file.type.startsWith("image/")) {
toasts.error("Please select an image file.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toasts.error("Image must be under 2MB.");
return;
}
isUploading = true;
try {
const ext = file.name.split(".").pop() || "png";
const path = `org-avatars/${org.id}.${ext}`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, file, { upsert: true });
if (uploadError) {
toasts.error("Failed to upload avatar.");
return;
}
const { data: urlData } = supabase.storage
.from("avatars")
.getPublicUrl(path);
const publicUrl = `${urlData.publicUrl}?t=${Date.now()}`;
const { error: dbError } = await supabase
.from("organizations")
.update({ avatar_url: publicUrl })
.eq("id", org.id);
if (dbError) {
toasts.error("Failed to save avatar URL.");
return;
}
avatarUrl = publicUrl;
await invalidateAll();
toasts.success("Avatar updated.");
} catch (err) {
toasts.error("Avatar upload failed.");
} finally {
isUploading = false;
input.value = "";
}
}
async function removeAvatar() {
isSaving = true;
const { error } = await supabase
.from("organizations")
.update({ avatar_url: null })
.eq("id", org.id);
if (error) {
toasts.error("Failed to remove avatar.");
} else {
avatarUrl = null;
await invalidateAll();
toasts.success("Avatar removed.");
}
isSaving = false;
}
async function saveGeneralSettings() {
isSaving = true;
const { error } = await supabase
.from("organizations")
.update({ name: orgName, slug: orgSlug })
.eq("id", org.id);
if (error) {
toasts.error("Failed to save settings.");
} else if (orgSlug !== org.slug) {
window.location.href = `/${orgSlug}/settings`;
} else {
toasts.success("Settings saved.");
}
isSaving = false;
}
</script>
<div class="flex flex-col gap-8">
<!-- Organization Details -->
<h2 class="font-heading text-h2 text-white">Organization details</h2>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<!-- Avatar Upload -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light">Avatar</span>
<div class="flex items-center gap-4">
<Avatar name={orgName || "?"} src={avatarUrl} size="lg" />
<div class="flex gap-2">
<input
type="file"
accept="image/*"
class="hidden"
bind:this={avatarInput}
onchange={handleAvatarUpload}
/>
<Button
variant="secondary"
size="sm"
onclick={() => avatarInput?.click()}
loading={isUploading}
>
Upload
</Button>
{#if avatarUrl}
<Button
variant="tertiary"
size="sm"
onclick={removeAvatar}
>
Remove
</Button>
{/if}
</div>
</div>
</div>
<Input
label="Name"
bind:value={orgName}
placeholder="Organization name"
/>
<Input
label="URL slug (yoursite.com/...)"
bind:value={orgSlug}
placeholder="my-org"
/>
<div>
<Button onclick={saveGeneralSettings} loading={isSaving}
>Save Changes</Button
>
</div>
</div>
<!-- Danger Zone -->
{#if isOwner}
<div class="flex flex-col gap-4">
<h4 class="font-heading text-h4 text-white">Danger Zone</h4>
<p class="font-body text-body text-white">
Permanently delete this organization and all its data.
</p>
<div>
<Button variant="danger" onclick={onDelete}
>Delete Organization</Button
>
</div>
</div>
{/if}
<!-- Leave Organization (non-owners) -->
{#if !isOwner}
<div class="flex flex-col gap-4">
<h4 class="font-heading text-h4 text-white">
Leave Organization
</h4>
<p class="font-body text-body text-white">
Leave this organization. You will need to be re-invited to
rejoin.
</p>
<div>
<Button variant="secondary" onclick={onLeave}
>Leave {org.name}</Button
>
</div>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1 @@
export { default as SettingsGeneral } from './SettingsGeneral.svelte';

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
interface Member {
id: string;
user_id: string;
profiles: {
id: string;
full_name: string | null;
email: string;
avatar_url: string | null;
};
}
interface Props {
value: string | null;
members: Member[];
label?: string;
onchange: (userId: string | null) => void;
}
let { value, members, label, onchange }: Props = $props();
let isOpen = $state(false);
function getAssignee(id: string | null) {
if (!id) return null;
return members.find((m) => m.user_id === id);
}
const assignee = $derived(getAssignee(value));
function select(userId: string | null) {
onchange(userId);
isOpen = false;
}
</script>
<div class="flex flex-col gap-3 w-full">
{#if label}
<span class="px-3 font-bold font-body text-body text-white">
{label}
</span>
{/if}
<div class="relative">
<button
type="button"
class="w-full p-3 bg-background text-white rounded-[32px] min-w-[192px]
font-medium font-input text-body
focus:outline-none focus:ring-2 focus:ring-primary
transition-colors text-left flex items-center gap-3"
onclick={() => (isOpen = !isOpen)}
>
{#if assignee}
<Avatar
name={assignee.profiles.full_name ||
assignee.profiles.email}
size="sm"
/>
<span class="truncate">
{assignee.profiles.full_name || assignee.profiles.email}
</span>
{:else}
<Avatar name="?" size="sm" />
<span class="text-white/40">Unassigned</span>
{/if}
</button>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-40"
onclick={() => (isOpen = false)}
></div>
<div
class="absolute top-full left-0 right-0 mt-2 bg-night border border-light/10 rounded-2xl shadow-xl z-50 max-h-48 overflow-y-auto py-1"
>
<button
type="button"
class="w-full px-4 py-2.5 text-left text-body-md text-white/60 hover:bg-dark transition-colors flex items-center gap-3"
onclick={() => select(null)}
>
<Avatar name="?" size="sm" />
Unassigned
</button>
{#each members as member}
<button
type="button"
class="w-full px-4 py-2.5 text-left text-body-md hover:bg-dark transition-colors flex items-center gap-3
{value === member.user_id ? 'bg-primary/10 text-primary' : 'text-white'}"
onclick={() => select(member.user_id)}
>
<Avatar
name={member.profiles.full_name ||
member.profiles.email}
size="sm"
/>
<span class="truncate">
{member.profiles.full_name || member.profiles.email}
</span>
</button>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -1,92 +1,35 @@
<script lang="ts">
interface Props {
name: string;
src?: string | null;
name?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
status?: 'online' | 'offline' | 'away' | 'busy' | null;
size?: "sm" | "md" | "lg" | "xl";
}
let { src = null, name = '?', size = 'md', status = null }: Props = $props();
let { name, src = null, size = "md" }: Props = $props();
const sizeClasses = {
xs: 'w-6 h-6 text-xs',
sm: 'w-8 h-8 text-sm',
md: 'w-10 h-10 text-base',
lg: 'w-12 h-12 text-lg',
xl: 'w-16 h-16 text-xl',
'2xl': 'w-20 h-20 text-2xl'
const initial = $derived(name ? name[0].toUpperCase() : "?");
const sizes = {
sm: { box: "w-8 h-8", text: "text-body", radius: "rounded-[16px]" },
md: { box: "w-12 h-12", text: "text-h3", radius: "rounded-[24px]" },
lg: { box: "w-16 h-16", text: "text-h2", radius: "rounded-[32px]" },
xl: { box: "w-24 h-24", text: "text-h1", radius: "rounded-[48px]" },
};
const statusSizes = {
xs: 'w-2 h-2',
sm: 'w-2.5 h-2.5',
md: 'w-3 h-3',
lg: 'w-3.5 h-3.5',
xl: 'w-4 h-4',
'2xl': 'w-5 h-5'
};
const statusColors = {
online: 'bg-success',
offline: 'bg-light/30',
away: 'bg-warning',
busy: 'bg-error'
};
function getInitials(name: string): string {
return name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
function getColorFromName(name: string): string {
const colors = [
'bg-red-500',
'bg-orange-500',
'bg-amber-500',
'bg-yellow-500',
'bg-lime-500',
'bg-green-500',
'bg-emerald-500',
'bg-teal-500',
'bg-cyan-500',
'bg-sky-500',
'bg-blue-500',
'bg-indigo-500',
'bg-violet-500',
'bg-purple-500',
'bg-fuchsia-500',
'bg-pink-500'
];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
</script>
<div class="relative inline-block">
{#if src}
<img
{src}
alt={name}
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
/>
{:else}
<div
class="rounded-full flex items-center justify-center font-medium text-white overflow-hidden {sizeClasses[
size
]} {!src ? getColorFromName(name) : 'bg-surface'}"
class="{sizes[size].box} {sizes[size]
.radius} bg-primary flex items-center justify-center shrink-0"
>
{#if src}
<img {src} alt={name} class="w-full h-full object-cover" />
{:else}
{getInitials(name)}
{/if}
<span class="font-heading {sizes[size].text} text-night leading-none">
{initial}
</span>
</div>
{#if status}
<div
class="absolute bottom-0 right-0 rounded-full border-2 border-dark {statusSizes[size]} {statusColors[
status
]}"
></div>
{/if}
</div>
{/if}

View File

@@ -1,30 +1,40 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { Snippet } from "svelte";
interface Props {
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info';
size?: 'sm' | 'md' | 'lg';
variant?:
| "default"
| "primary"
| "success"
| "warning"
| "error"
| "info";
size?: "sm" | "md" | "lg";
children: Snippet;
}
let { variant = 'default', size = 'md', children }: Props = $props();
let { variant = "default", size = "md", children }: Props = $props();
const variantClasses = {
default: 'bg-light/10 text-light',
primary: 'bg-primary/20 text-primary',
success: 'bg-success/20 text-success',
warning: 'bg-warning/20 text-warning',
error: 'bg-error/20 text-error',
info: 'bg-info/20 text-info'
default: "bg-light/10 text-light",
primary: "bg-primary/20 text-primary",
success: "bg-success/20 text-success",
warning: "bg-warning/20 text-warning",
error: "bg-error/20 text-error",
info: "bg-info/20 text-info",
};
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-0.5 text-sm',
lg: 'px-2.5 py-1 text-sm'
sm: "px-1.5 py-0.5 text-xs",
md: "px-2 py-0.5 text-sm",
lg: "px-2.5 py-1 text-sm",
};
</script>
<span class="inline-flex items-center font-medium rounded-full {variantClasses[variant]} {sizeClasses[size]}">
<span
class="inline-flex items-center font-medium rounded-full {variantClasses[
variant
]} {sizeClasses[size]}"
>
{@render children()}
</span>

View File

@@ -2,14 +2,16 @@
import type { Snippet } from "svelte";
interface Props {
variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
variant?: "primary" | "secondary" | "tertiary" | "danger" | "success";
size?: "sm" | "md" | "lg";
disabled?: boolean;
loading?: boolean;
type?: "button" | "submit" | "reset";
fullWidth?: boolean;
icon?: string;
type?: "button" | "submit" | "reset";
onclick?: (e: MouseEvent) => void;
children: Snippet;
children?: Snippet;
class?: string;
}
let {
@@ -17,59 +19,100 @@
size = "md",
disabled = false,
loading = false,
type = "button",
fullWidth = false,
icon,
type = "button",
onclick,
children,
class: className,
}: Props = $props();
// Figma-matched base styles
const baseClasses =
"inline-flex items-center justify-center font-bold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-30 disabled:cursor-not-allowed rounded-[32px]";
"inline-flex items-center justify-center gap-2 font-heading rounded-[32px] overflow-clip transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed";
// Figma-matched variant styles
const variantClasses = {
primary:
"bg-primary text-night hover:brightness-110 active:brightness-90",
"btn-primary bg-primary text-night hover:btn-primary-hover active:btn-primary-active",
secondary:
"border-2 border-primary text-primary bg-transparent hover:bg-primary/10 active:bg-primary/20",
ghost: "bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30",
danger: "bg-error text-night hover:brightness-110 active:brightness-90",
"bg-transparent text-primary border-solid border-primary hover:bg-primary/10 active:bg-primary/20",
tertiary:
"bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30",
danger: "btn-primary bg-error text-white hover:btn-primary-hover active:btn-primary-active",
success:
"bg-success text-night hover:brightness-110 active:brightness-90",
"btn-primary bg-success text-night hover:btn-primary-hover active:btn-primary-active",
};
// Figma-matched size styles (px values from Figma)
const sizeClasses = {
sm: "px-3 py-1.5 text-sm gap-1.5 min-w-[96px]",
md: "px-4 py-2 text-base gap-2 min-w-[128px]",
lg: "px-5 py-3 text-xl gap-2.5 min-w-[128px]",
sm: "min-w-[36px] p-[10px] text-btn-sm",
md: "min-w-[48px] p-[12px] text-btn-md",
lg: "min-w-[56px] p-[16px] text-btn-lg",
};
const borderClasses = {
sm: "border-2",
md: "border-3",
lg: "border-4",
};
const secondaryBorder = $derived(
variant === "secondary" ? borderClasses[size] : "",
);
const iconSize = $derived(size === "sm" ? 16 : size === "lg" ? 20 : 18);
</script>
<button
{type}
class="{baseClasses} {variantClasses[variant]} {sizeClasses[size]}"
class="{baseClasses} {variantClasses[variant]} {sizeClasses[
size
]} {secondaryBorder} {className ?? ''}"
class:w-full={fullWidth}
disabled={disabled || loading}
{onclick}
>
{#if loading}
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span
class="material-symbols-rounded animate-spin"
style="font-size: {iconSize}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {iconSize};"
>
progress_activity
</span>
{:else if icon}
<span
class="material-symbols-rounded"
style="font-size: {iconSize}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {iconSize};"
>
{icon}
</span>
{/if}
{#if children}
{@render children()}
{/if}
{@render children()}
</button>
<style>
.btn-primary:hover:not(:disabled) {
background-image: linear-gradient(
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.2)
);
}
.btn-primary-hover:not(:disabled) {
background-image: linear-gradient(
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.2)
);
}
.btn-primary:active:not(:disabled) {
background-image: linear-gradient(
rgba(14, 15, 25, 0.2),
rgba(14, 15, 25, 0.2)
);
}
.btn-primary-active:not(:disabled) {
background-image: linear-gradient(
rgba(14, 15, 25, 0.2),
rgba(14, 15, 25, 0.2)
);
}
</style>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
day?: number | string;
isHeader?: boolean;
isPast?: boolean;
events?: Snippet;
}
let { day, isHeader = false, isPast = false, events }: Props = $props();
</script>
{#if isHeader}
<div
class="flex flex-col items-center justify-center px-2 pt-2 pb-4 w-full"
>
<span class="font-heading text-h4 text-white text-center truncate">
{day}
</span>
</div>
{:else}
<div
class="flex flex-col items-start gap-2 bg-night px-4 py-5 min-h-[82px] w-full {isPast
? 'opacity-50'
: ''}"
>
<span class="font-body text-body text-white truncate w-full">
{day}
</span>
{#if events}
{@render events()}
{/if}
</div>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
variant?: "primary" | "success" | "warning" | "error" | "default";
children: Snippet;
}
let { variant = "primary", children }: Props = $props();
const variantClasses = {
primary: "bg-primary text-background",
success: "bg-success text-background",
warning: "bg-warning text-background",
error: "bg-error text-background",
default: "bg-dark text-light",
};
</script>
<div
class="inline-flex items-center justify-center px-1 py-1 rounded-[4px] overflow-hidden font-bold font-body text-body-md {variantClasses[
variant
]}"
>
{@render children()}
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Button from "./Button.svelte";
interface Props {
title: string;
actionLabel?: string;
onAction?: () => void;
onMore?: () => void;
children?: Snippet;
}
let { title, actionLabel, onAction, onMore, children }: Props = $props();
</script>
<div class="flex flex-wrap items-center gap-2 p-1 rounded-[32px] w-full">
<div class="flex-1 min-w-0">
<h1 class="font-heading text-h1 text-white truncate">{title}</h1>
</div>
{#if children}
{@render children()}
{/if}
{#if actionLabel && onAction}
<Button variant="primary" onclick={onAction}>
{actionLabel}
</Button>
{/if}
{#if onMore}
<button
type="button"
class="p-1 flex items-center justify-center hover:bg-dark/50 rounded-full transition-colors"
onclick={onMore}
>
<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>
{/if}
</div>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
trigger: Snippet;
children: Snippet;
align?: "left" | "right";
width?: "auto" | "sm" | "md" | "lg";
}
let { trigger, children, align = "left", width = "auto" }: Props = $props();
let isOpen = $state(false);
const alignClasses = {
left: "left-0",
right: "right-0",
};
const widthClasses = {
auto: "min-w-[10rem]",
sm: "w-48",
md: "w-56",
lg: "w-64",
};
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest(".dropdown-container")) {
isOpen = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") isOpen = false;
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="relative dropdown-container">
<button
type="button"
class="w-full text-left"
onclick={() => (isOpen = !isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
>
{@render trigger()}
</button>
{#if isOpen}
<div
class="
absolute z-50 mt-2 py-1 bg-surface border border-light/10 rounded-xl shadow-xl
animate-in fade-in slide-in-from-top-2 duration-150
{alignClasses[align]}
{widthClasses[width]}
"
>
{@render children()}
</div>
{/if}
</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
onclick?: () => void;
icon?: Snippet;
danger?: boolean;
disabled?: boolean;
}
let { children, onclick, icon, danger = false, disabled = false }: Props = $props();
</script>
<button
type="button"
{onclick}
{disabled}
class="
w-full flex items-center gap-3 px-3 py-2 text-sm text-left transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
{danger ? 'text-error hover:bg-error/10' : 'text-light hover:bg-light/5'}
"
>
{#if icon}
<span class="w-4 h-4 shrink-0 opacity-60">
{@render icon()}
</span>
{/if}
<span class="flex-1">{@render children()}</span>
</button>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
icon?: Snippet;
title: string;
description?: string;
action?: Snippet;
}
let { icon, title, description, action }: Props = $props();
</script>
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
{#if icon}
<div class="w-12 h-12 sm:w-16 sm:h-16 text-light/30 mb-4">
{@render icon()}
</div>
{/if}
<h3 class="text-base sm:text-lg font-medium text-light mb-1">{title}</h3>
{#if description}
<p class="text-sm text-light/50 max-w-sm mb-4">{description}</p>
{/if}
{#if action}
<div class="mt-2">
{@render action()}
</div>
{/if}
</div>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
interface Props {
name: string;
size?: number;
class?: string;
}
let { name, size = 24, class: className = "" }: Props = $props();
</script>
<span
class="material-symbols-rounded {className}"
style="font-size: {size}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {size};"
>
{name}
</span>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
onclick?: () => void;
variant?: 'ghost' | 'subtle' | 'solid';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
title?: string;
class?: string;
}
let {
children,
onclick,
variant = 'ghost',
size = 'md',
disabled = false,
title,
class: className = '',
}: Props = $props();
const variantClasses = {
ghost: 'hover:bg-light/10 text-light/60 hover:text-light',
subtle: 'bg-light/5 hover:bg-light/10 text-light/60 hover:text-light',
solid: 'bg-primary/20 hover:bg-primary/30 text-primary',
};
const sizeClasses = {
sm: 'w-7 h-7',
md: 'w-9 h-9',
lg: 'w-11 h-11',
};
const iconSizeClasses = {
sm: '[&>svg]:w-4 [&>svg]:h-4',
md: '[&>svg]:w-5 [&>svg]:h-5',
lg: '[&>svg]:w-6 [&>svg]:h-6',
};
</script>
<button
type="button"
{onclick}
{disabled}
{title}
aria-label={title}
class="
inline-flex items-center justify-center rounded-lg transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
{variantClasses[variant]}
{sizeClasses[size]}
{iconSizeClasses[size]}
{className}
"
>
{@render children()}
</button>

View File

@@ -1,6 +1,15 @@
<script lang="ts">
interface Props {
type?: "text" | "password" | "email" | "url" | "search" | "number";
type?:
| "text"
| "password"
| "email"
| "url"
| "search"
| "number"
| "tel"
| "date"
| "datetime-local";
value?: string;
placeholder?: string;
label?: string;
@@ -9,7 +18,9 @@
disabled?: boolean;
required?: boolean;
autocomplete?: AutoFill;
icon?: string;
oninput?: (e: Event) => void;
onchange?: (e: Event) => void;
onkeydown?: (e: KeyboardEvent) => void;
}
@@ -23,7 +34,9 @@
disabled = false,
required = false,
autocomplete,
icon,
oninput,
onchange,
onkeydown,
}: Props = $props();
@@ -33,67 +46,72 @@
const inputType = $derived(isPassword && showPassword ? "text" : type);
</script>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-3 w-full">
{#if label}
<label for={inputId} class="px-3 font-heading text-xl text-white">
<label
for={inputId}
class="px-3 font-bold font-body text-body text-white"
>
{#if required}<span class="text-error">* </span>{/if}{label}
</label>
{/if}
<div class="relative">
<input
id={inputId}
type={inputType}
bind:value
{placeholder}
{disabled}
{required}
{autocomplete}
{oninput}
{onkeydown}
class="w-full px-3 py-3 bg-night text-white rounded-[32px] min-w-[192px]
placeholder:text-white/40
focus:outline-none focus:ring-2 focus:ring-primary
disabled:opacity-30 disabled:cursor-not-allowed
transition-colors"
class:ring-1={error}
class:ring-error={error}
/>
{#if isPassword}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/40 hover:text-white transition-colors"
onclick={() => (showPassword = !showPassword)}
<div class="flex items-center gap-3 w-full">
{#if icon}
<div
class="w-8 h-8 rounded-full bg-light flex items-center justify-center shrink-0"
>
{#if showPassword}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
/>
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
{:else}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
{/if}
</button>
<span
class="material-symbols-rounded text-background"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{icon}
</span>
</div>
{/if}
<div class="relative flex-1">
<input
id={inputId}
type={inputType}
bind:value
{placeholder}
{disabled}
{required}
{autocomplete}
{oninput}
{onchange}
{onkeydown}
class="
w-full p-3 bg-background text-white rounded-[32px] min-w-[192px]
font-medium font-input text-body
placeholder:text-white/40
focus:outline-none focus:ring-2 focus:ring-primary
disabled:opacity-30 disabled:cursor-not-allowed
transition-colors
"
class:ring-1={error}
class:ring-error={error}
class:pr-12={isPassword}
/>
{#if isPassword}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-colors"
onclick={() => (showPassword = !showPassword)}
aria-label={showPassword
? "Hide password"
: "Show password"}
>
<span
class="material-symbols-rounded"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{showPassword ? "visibility_off" : "visibility"}
</span>
</button>
{/if}
</div>
</div>
{#if error}

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Button from "./Button.svelte";
interface Props {
title: string;
count?: number;
onAddCard?: () => void;
onMore?: () => void;
children?: Snippet;
}
let { title, count = 0, onAddCard, onMore, children }: Props = $props();
</script>
<div
class="bg-background flex flex-col gap-4 items-start overflow-hidden px-4 py-5 rounded-[32px] w-64 h-[512px]"
>
<!-- Header -->
<div class="flex items-center gap-2 p-1 rounded-[32px] w-full">
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="font-heading text-h4 text-white truncate">{title}</span
>
<div
class="bg-dark flex items-center justify-center p-1 rounded-lg shrink-0"
>
<span class="font-heading text-h6 text-white">{count}</span>
</div>
</div>
{#if onMore}
<button
type="button"
class="p-1 flex items-center justify-center hover:bg-dark/50 rounded-full transition-colors"
onclick={onMore}
>
<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>
{/if}
</div>
<!-- Cards container -->
<div
class="flex-1 flex flex-col gap-2 items-start overflow-y-auto w-full min-h-0"
>
{#if children}
{@render children()}
{/if}
</div>
<!-- Add button -->
{#if onAddCard}
<Button variant="secondary" fullWidth onclick={onAddCard}>
Add card
</Button>
{/if}
</div>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
variant?: "default" | "hover" | "active";
icon?: string;
size?: "sm" | "md";
onclick?: () => void;
children: Snippet;
}
let {
variant = "default",
icon,
size = "md",
onclick,
children,
}: Props = $props();
const baseClasses =
"flex items-center gap-2 overflow-hidden rounded-[32px] transition-colors cursor-pointer";
const variantClasses = {
default: "bg-night hover:bg-dark",
hover: "bg-dark",
active: "bg-primary text-background",
};
const sizeClasses = {
sm: "h-10 pl-1 pr-2 py-1",
md: "h-10 pl-1 pr-2 py-1",
};
</script>
<button
type="button"
class="{baseClasses} {variantClasses[variant]} {sizeClasses[size]} w-full"
{onclick}
>
{#if icon}
<div class="w-8 h-8 flex items-center justify-center p-1 shrink-0">
<span
class="material-symbols-rounded {variant === 'active'
? 'text-background'
: 'text-light'}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{icon}
</span>
</div>
{/if}
<span
class="flex-1 text-left font-body text-body truncate {variant ===
'active'
? 'text-background'
: 'text-white'}"
>
{@render children()}
</span>
</button>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
interface Props {
size?: "sm" | "md";
}
let { size = "md" }: Props = $props();
const sizeClasses = {
sm: "w-10 h-10",
md: "w-12 h-12",
};
</script>
<div class="flex items-center justify-center {sizeClasses[size]}">
<svg
viewBox="0 0 38 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-full h-auto"
>
<!-- Root logo SVG paths matching Figma -->
<path
d="M0 0.5C0 0.224 0.224 0 0.5 0H37.5C37.776 0 38 0.224 38 0.5V12.203C38 12.479 37.776 12.703 37.5 12.703H0.5C0.224 12.703 0 12.479 0 12.203V0.5Z"
fill="#00A3E0"
fill-opacity="0.2"
/>
<!-- Left eye -->
<circle cx="11.5" cy="7.5" r="5" fill="#00A3E0" />
<!-- Right eye -->
<circle cx="23.5" cy="7.5" r="5" fill="#00A3E0" />
<!-- Mouth/smile -->
<path
d="M12.25 15.04C12.25 15.04 15 20.25 18.75 20.25C22.5 20.25 25.25 15.04 25.25 15.04"
stroke="#00A3E0"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>

View File

@@ -1,25 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { Snippet } from "svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
interface Props {
isOpen: boolean;
onClose: () => void;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
size?: "sm" | "md" | "lg" | "xl";
children: Snippet;
}
let { isOpen, onClose, title, size = 'md', children }: Props = $props();
let { isOpen, onClose, title, size = "md", children }: Props = $props();
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl'
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
};
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (e.key === "Escape") {
onClose();
}
}
@@ -39,23 +41,40 @@
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
aria-labelledby={title ? "modal-title" : undefined}
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<div
class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[size]} shadow-xl"
class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[
size
]} shadow-xl"
onclick={(e) => e.stopPropagation()}
role="document"
transition:fly={{ y: 10, duration: 200, easing: cubicOut }}
>
{#if title}
<div class="flex items-center justify-between px-6 py-4 border-b border-light/10">
<h2 id="modal-title" class="text-lg font-semibold text-light">{title}</h2>
<div
class="flex items-center justify-between px-6 py-4 border-b border-light/10"
>
<h2
id="modal-title"
class="text-lg font-semibold text-light"
>
{title}
</h2>
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={onClose}
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
interface Props {
name: string;
role?: string;
size?: "sm" | "md";
isHover?: boolean;
}
let { name, role, size = "md", isHover = false }: Props = $props();
</script>
<div
class="flex items-center gap-2 p-1 rounded-[32px] w-full transition-colors {isHover
? 'bg-dark'
: 'bg-night'}"
>
<Avatar {name} size={size === "sm" ? "sm" : "md"} />
{#if size !== "sm"}
<div class="flex-1 flex flex-col min-w-0">
<span class="font-heading text-h3 text-white truncate">{name}</span>
{#if role}
<span class="font-body text-body-sm text-white truncate"
>{role}</span
>
{/if}
</div>
{/if}
</div>

View File

@@ -10,8 +10,10 @@
label?: string;
placeholder?: string;
error?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
onchange?: (e: Event) => void;
}
let {
@@ -20,18 +22,22 @@
label,
placeholder = "Select...",
error,
hint,
disabled = false,
required = false,
onchange,
}: Props = $props();
const inputId = `select-${crypto.randomUUID().slice(0, 8)}`;
</script>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col gap-3 w-full">
{#if label}
<label for={inputId} class="text-sm font-medium text-light/80">
{label}
{#if required}<span class="text-primary">*</span>{/if}
<label
for={inputId}
class="px-3 font-bold font-body text-body text-white"
>
{#if required}<span class="text-error">* </span>{/if}{label}
</label>
{/if}
@@ -40,21 +46,27 @@
bind:value
{disabled}
{required}
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors appearance-none cursor-pointer"
class:border-error={error}
class:placeholder-shown={!value}
{onchange}
class="w-full p-3 bg-background text-white rounded-[32px] min-w-[192px]
font-medium font-input text-body
focus:outline-none focus:ring-2 focus:ring-primary
disabled:opacity-30 disabled:cursor-not-allowed
transition-colors appearance-none cursor-pointer"
class:ring-1={error}
class:ring-error={error}
>
<option value="" disabled>{placeholder}</option>
{#if placeholder}
<option value="" disabled>{placeholder}</option>
{/if}
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
{#if error}
<p class="text-sm text-error">{error}</p>
<p class="text-sm text-error px-3">{error}</p>
{:else if hint}
<p class="text-sm text-white/50 px-3">{hint}</p>
{/if}
</div>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
interface Props {
class?: string;
variant?: 'text' | 'circular' | 'rectangular' | 'card';
width?: string;
height?: string;
lines?: number;
}
let {
class: className = '',
variant = 'text',
width,
height,
lines = 1,
}: Props = $props();
const variantClasses: Record<string, string> = {
text: 'h-4 rounded',
circular: 'rounded-full',
rectangular: 'rounded-lg',
card: 'rounded-2xl',
};
const defaultSizes: Record<string, { w: string; h: string }> = {
text: { w: '100%', h: '1rem' },
circular: { w: '2.5rem', h: '2.5rem' },
rectangular: { w: '100%', h: '4rem' },
card: { w: '100%', h: '8rem' },
};
const finalWidth = width || defaultSizes[variant].w;
const finalHeight = height || defaultSizes[variant].h;
</script>
{#if variant === 'text' && lines > 1}
<div class="space-y-2 {className}">
{#each Array(lines) as _, i}
<div
class="skeleton {variantClasses[variant]}"
style="width: {i === lines - 1 ? '75%' : finalWidth}; height: {finalHeight}"
></div>
{/each}
</div>
{:else}
<div
class="skeleton {variantClasses[variant]} {className}"
style="width: {finalWidth}; height: {finalHeight}"
></div>
{/if}
<style>
.skeleton {
background: linear-gradient(
90deg,
rgb(var(--color-light) / 0.06) 0%,
rgb(var(--color-light) / 0.12) 50%,
rgb(var(--color-light) / 0.06) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View File

@@ -8,36 +8,38 @@
disabled?: boolean;
required?: boolean;
rows?: number;
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
resize?: "none" | "vertical" | "horizontal" | "both";
}
let {
value = $bindable(''),
placeholder = '',
value = $bindable(""),
placeholder = "",
label,
error,
hint,
disabled = false,
required = false,
rows = 3,
resize = 'vertical'
resize = "vertical",
}: Props = $props();
const inputId = `textarea-${crypto.randomUUID().slice(0, 8)}`;
const resizeClasses = {
none: 'resize-none',
vertical: 'resize-y',
horizontal: 'resize-x',
both: 'resize'
none: "resize-none",
vertical: "resize-y",
horizontal: "resize-x",
both: "resize",
};
</script>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col gap-3 w-full">
{#if label}
<label for={inputId} class="text-sm font-medium text-light/80">
{label}
{#if required}<span class="text-primary">*</span>{/if}
<label
for={inputId}
class="px-3 font-bold font-body text-body text-white"
>
{#if required}<span class="text-error">* </span>{/if}{label}
</label>
{/if}
@@ -48,19 +50,19 @@
{disabled}
{required}
{rows}
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
placeholder:text-light/40
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors {resizeClasses[resize]}"
class:border-error={error}
class:focus:border-error={error}
class:focus:ring-error={error}
class="w-full p-3 bg-background text-white rounded-2xl min-w-[192px]
font-medium font-input text-body
placeholder:text-white/40
focus:outline-none focus:ring-2 focus:ring-primary
disabled:opacity-30 disabled:cursor-not-allowed
transition-colors {resizeClasses[resize]}"
class:ring-1={error}
class:ring-error={error}
></textarea>
{#if error}
<p class="text-sm text-error">{error}</p>
<p class="text-sm text-error px-3">{error}</p>
{:else if hint}
<p class="text-sm text-light/50">{hint}</p>
<p class="text-sm text-white/50 px-3">{hint}</p>
{/if}
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { toasts } from '$lib/stores/toast';
import Toast from './Toast.svelte';
import { toasts } from "$lib/stores/toast.svelte";
import Toast from "./Toast.svelte";
</script>
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">

View File

@@ -10,3 +10,17 @@ export { default as Spinner } from './Spinner.svelte';
export { default as Toggle } from './Toggle.svelte';
export { default as Toast } from './Toast.svelte';
export { default as ToastContainer } from './ToastContainer.svelte';
export { default as Skeleton } from './Skeleton.svelte';
export { default as EmptyState } from './EmptyState.svelte';
export { default as IconButton } from './IconButton.svelte';
export { default as Dropdown } from './Dropdown.svelte';
export { default as DropdownItem } from './DropdownItem.svelte';
export { default as Chip } from './Chip.svelte';
export { default as ListItem } from './ListItem.svelte';
export { default as CalendarDay } from './CalendarDay.svelte';
export { default as OrgHeader } from './OrgHeader.svelte';
export { default as KanbanColumn } from './KanbanColumn.svelte';
export { default as Logo } from './Logo.svelte';
export { default as ContentHeader } from './ContentHeader.svelte';
export { default as Icon } from './Icon.svelte';
export { default as AssigneePicker } from './AssigneePicker.svelte';

View File

@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -1,27 +0,0 @@
import type { Session, User } from '@supabase/supabase-js';
class AuthStore {
session = $state<Session | null>(null);
user = $state<User | null>(null);
isLoading = $state(true);
setSession(session: Session | null, user: User | null) {
this.session = session;
this.user = user;
this.isLoading = false;
}
get isAuthenticated() {
return !!this.session && !!this.user;
}
get userId() {
return this.user?.id ?? null;
}
get email() {
return this.user?.email ?? null;
}
}
export const auth = new AuthStore();

View File

@@ -1,52 +0,0 @@
import type { Document } from '$lib/supabase/types';
import type { DocumentWithChildren } from '$lib/api/documents';
import { buildDocumentTree } from '$lib/api/documents';
class DocumentsStore {
documents = $state<Document[]>([]);
currentDocument = $state<Document | null>(null);
isLoading = $state(false);
isSaving = $state(false);
setDocuments(docs: Document[]) {
this.documents = docs;
}
setCurrentDocument(doc: Document | null) {
this.currentDocument = doc;
}
addDocument(doc: Document) {
this.documents = [...this.documents, doc];
}
updateDocument(id: string, updates: Partial<Document>) {
this.documents = this.documents.map((doc) =>
doc.id === id ? { ...doc, ...updates } : doc
);
if (this.currentDocument?.id === id) {
this.currentDocument = { ...this.currentDocument, ...updates };
}
}
removeDocument(id: string) {
this.documents = this.documents.filter((doc) => doc.id !== id);
if (this.currentDocument?.id === id) {
this.currentDocument = null;
}
}
get tree(): DocumentWithChildren[] {
return buildDocumentTree(this.documents);
}
get folders() {
return this.documents.filter((doc) => doc.type === 'folder');
}
get files() {
return this.documents.filter((doc) => doc.type === 'document');
}
}
export const docs = new DocumentsStore();

View File

@@ -1,2 +0,0 @@
export { auth } from './auth.svelte';
export { orgs, type OrgWithRole } from './organizations.svelte';

View File

@@ -1,59 +0,0 @@
import type { Organization, OrgMember, MemberRole } from '$lib/supabase/types';
export interface OrgWithRole extends Organization {
role: MemberRole;
memberCount?: number;
}
class OrganizationsStore {
organizations = $state<OrgWithRole[]>([]);
currentOrg = $state<OrgWithRole | null>(null);
members = $state<(OrgMember & { profile?: { email: string; full_name: string | null; avatar_url: string | null } })[]>([]);
isLoading = $state(false);
setOrganizations(orgs: OrgWithRole[]) {
this.organizations = orgs;
}
setCurrentOrg(org: OrgWithRole | null) {
this.currentOrg = org;
}
setMembers(members: typeof this.members) {
this.members = members;
}
addOrganization(org: OrgWithRole) {
this.organizations = [...this.organizations, org];
}
updateOrganization(id: string, updates: Partial<Organization>) {
this.organizations = this.organizations.map((org) =>
org.id === id ? { ...org, ...updates } : org
);
if (this.currentOrg?.id === id) {
this.currentOrg = { ...this.currentOrg, ...updates };
}
}
removeOrganization(id: string) {
this.organizations = this.organizations.filter((org) => org.id !== id);
if (this.currentOrg?.id === id) {
this.currentOrg = null;
}
}
get hasOrganizations() {
return this.organizations.length > 0;
}
get isOwnerOrAdmin() {
return this.currentOrg?.role === 'owner' || this.currentOrg?.role === 'admin';
}
get canEdit() {
return ['owner', 'admin', 'editor'].includes(this.currentOrg?.role ?? '');
}
}
export const orgs = new OrganizationsStore();

View File

@@ -1,222 +0,0 @@
/**
* Theme Store - Manages app theme (dark/light mode and accent colors)
* Inspired by root-v2
*/
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
export type ThemeMode = 'dark' | 'light' | 'system';
export interface ThemeColors {
primary: string;
name: string;
}
export const PRESET_COLORS: ThemeColors[] = [
{ name: 'Cyan', primary: '#00A3E0' },
{ name: 'Purple', primary: '#8B5CF6' },
{ name: 'Pink', primary: '#EC4899' },
{ name: 'Green', primary: '#10B981' },
{ name: 'Orange', primary: '#F97316' },
{ name: 'Red', primary: '#EF4444' },
{ name: 'Blue', primary: '#3B82F6' },
{ name: 'Indigo', primary: '#6366F1' },
];
const THEME_STORAGE_KEY = 'root_theme';
interface ThemeState {
mode: ThemeMode;
primaryColor: string;
}
const defaultTheme: ThemeState = {
mode: 'dark',
primaryColor: '#00A3E0',
};
// Convert hex to HSL
function hexToHSL(hex: string): { h: number; s: number; l: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return { h: 0, s: 0, l: 0 };
const r = parseInt(result[1], 16) / 255;
const g = parseInt(result[2], 16) / 255;
const b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
// Convert HSL to hex
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
// Generate derived colors from primary
function generateDerivedColors(primary: string, isDark: boolean) {
const { h, s } = hexToHSL(primary);
if (isDark) {
return {
night: hslToHex(h, Math.min(s, 40), 6),
dark: hslToHex(h, Math.min(s, 35), 10),
surface: hslToHex(h, Math.min(s, 30), 12),
background: hslToHex(h, Math.min(s, 30), 3),
light: '#e5e6f0',
text: '#ffffff',
};
} else {
const lightSat = Math.min(s, 30);
return {
night: hslToHex(h, lightSat, 95),
dark: hslToHex(h, lightSat, 90),
surface: hslToHex(h, lightSat, 98),
background: hslToHex(h, lightSat, 100),
light: '#1a1a2e',
text: '#0a121f',
};
}
}
function getEffectiveMode(mode: ThemeMode): 'dark' | 'light' {
if (mode === 'system') {
if (!browser) return 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return mode;
}
function loadTheme(): ThemeState {
if (!browser) return defaultTheme;
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored) {
return { ...defaultTheme, ...JSON.parse(stored) };
}
} catch (e) {
console.warn('Failed to load theme:', e);
}
return defaultTheme;
}
function saveTheme(theme: ThemeState): void {
if (!browser) return;
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(theme));
}
export function applyTheme(state: ThemeState): void {
if (!browser) return;
const root = document.documentElement;
const effectiveMode = getEffectiveMode(state.mode);
// Set mode class
root.classList.remove('dark', 'light');
root.classList.add(effectiveMode);
// Set CSS custom properties
root.style.setProperty('--color-primary', state.primaryColor);
// Calculate hover variant
const { h, s, l } = hexToHSL(state.primaryColor);
root.style.setProperty('--color-primary-hover', hslToHex(h, s, Math.min(100, l + 10)));
// Generate and apply derived colors
const derived = generateDerivedColors(state.primaryColor, effectiveMode === 'dark');
root.style.setProperty('--color-night', derived.night);
root.style.setProperty('--color-dark', derived.dark);
root.style.setProperty('--color-surface', derived.surface);
root.style.setProperty('--color-background', derived.background);
root.style.setProperty('--color-light', derived.light);
root.style.setProperty('--color-text', derived.text);
}
function createThemeStore() {
const { subscribe, set, update } = writable<ThemeState>(loadTheme());
return {
subscribe,
setMode: (mode: ThemeMode) => {
update(state => {
const newState = { ...state, mode };
saveTheme(newState);
applyTheme(newState);
return newState;
});
},
setPrimaryColor: (color: string) => {
update(state => {
const newState = { ...state, primaryColor: color };
saveTheme(newState);
applyTheme(newState);
return newState;
});
},
toggleMode: () => {
update(state => {
const modes: ThemeMode[] = ['dark', 'light', 'system'];
const currentIndex = modes.indexOf(state.mode);
const newMode = modes[(currentIndex + 1) % modes.length];
const newState: ThemeState = { ...state, mode: newMode };
saveTheme(newState);
applyTheme(newState);
return newState;
});
},
reset: () => {
set(defaultTheme);
saveTheme(defaultTheme);
applyTheme(defaultTheme);
},
init: () => {
const state = loadTheme();
applyTheme(state);
set(state);
}
};
}
export const theme = createThemeStore();
// Derived stores for convenience
export const isDarkMode = derived(theme, $t => getEffectiveMode($t.mode) === 'dark');
export const primaryColor = derived(theme, $t => $t.primaryColor);
export const themeMode = derived(theme, $t => $t.mode);
// Initialize theme on load
if (browser) {
applyTheme(loadTheme());
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const state = loadTheme();
if (state.mode === 'system') {
applyTheme(state);
}
});
}

View File

@@ -0,0 +1,83 @@
/**
* Toast Store - Svelte 5 class-based $state store
* Manages toast notifications with auto-dismiss
*/
export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
message: string;
variant: ToastVariant;
duration?: number;
}
class ToastStore {
items = $state<Toast[]>([]);
private timeouts = new Map<string, ReturnType<typeof setTimeout>>();
private subscribers = new Set<(value: Toast[]) => void>();
// Subscribe method for $store syntax compatibility
subscribe(fn: (value: Toast[]) => void): () => void {
this.subscribers.add(fn);
fn(this.items);
return () => this.subscribers.delete(fn);
}
private notify() {
this.subscribers.forEach(fn => fn(this.items));
}
add(message: string, variant: ToastVariant = 'info', duration = 5000): string {
const id = crypto.randomUUID();
const toast: Toast = { id, message, variant, duration };
this.items = [...this.items, toast];
this.notify();
if (duration > 0) {
const timeout = setTimeout(() => this.remove(id), duration);
this.timeouts.set(id, timeout);
}
return id;
}
remove(id: string) {
// Clear any pending timeout
const timeout = this.timeouts.get(id);
if (timeout) {
clearTimeout(timeout);
this.timeouts.delete(id);
}
this.items = this.items.filter((t) => t.id !== id);
this.notify();
}
clear() {
// Clear all pending timeouts
this.timeouts.forEach((timeout) => clearTimeout(timeout));
this.timeouts.clear();
this.items = [];
this.notify();
}
// Convenience methods
success(message: string, duration?: number) {
return this.add(message, 'success', duration);
}
error(message: string, duration?: number) {
return this.add(message, 'error', duration);
}
warning(message: string, duration?: number) {
return this.add(message, 'warning', duration);
}
info(message: string, duration?: number) {
return this.add(message, 'info', duration);
}
}
export const toasts = new ToastStore();

View File

@@ -1,48 +0,0 @@
import { writable } from 'svelte/store';
export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
message: string;
variant: ToastVariant;
duration?: number;
}
function createToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
function add(message: string, variant: ToastVariant = 'info', duration = 5000) {
const id = crypto.randomUUID();
const toast: Toast = { id, message, variant, duration };
update((toasts) => [...toasts, toast]);
if (duration > 0) {
setTimeout(() => remove(id), duration);
}
return id;
}
function remove(id: string) {
update((toasts) => toasts.filter((t) => t.id !== id));
}
function clear() {
update(() => []);
}
return {
subscribe,
add,
remove,
clear,
success: (message: string, duration?: number) => add(message, 'success', duration),
error: (message: string, duration?: number) => add(message, 'error', duration),
warning: (message: string, duration?: number) => add(message, 'warning', duration),
info: (message: string, duration?: number) => add(message, 'info', duration)
};
}
export const toasts = createToastStore();

View File

@@ -1,3 +1,2 @@
export { createClient } from './client';
export { createClient as createServerClient } from './server';
export type * from './types';

View File

@@ -1,19 +0,0 @@
import { createServerClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import type { Database } from './types';
import type { Cookies } from '@sveltejs/kit';
export function createClient(cookies: Cookies) {
return createServerClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
getAll() {
return cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookies.set(name, value, { ...options, path: '/' });
});
}
}
});
}

File diff suppressed because it is too large Load Diff

207
src/lib/utils/logger.ts Normal file
View File

@@ -0,0 +1,207 @@
/**
* Centralized Logger for Root Org
*
* Works on both client and server. Outputs structured logs with:
* - Timestamp
* - Level (debug/info/warn/error)
* - Context (which module/function)
* - Structured data
*
* On the server (dev terminal), logs are colorized and always visible.
* On the client, logs go to console and can optionally trigger toasts.
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface LogEntry {
level: LogLevel;
context: string;
message: string;
data?: unknown;
error?: unknown;
timestamp: string;
}
const LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const LEVEL_COLORS: Record<LogLevel, string> = {
debug: '\x1b[36m', // cyan
info: '\x1b[32m', // green
warn: '\x1b[33m', // yellow
error: '\x1b[31m', // red
};
const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
const DIM = '\x1b[2m';
// Minimum level to output — can be overridden
let minLevel: LogLevel = 'debug';
function shouldLog(level: LogLevel): boolean {
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[minLevel];
}
function isServer(): boolean {
return typeof window === 'undefined';
}
function formatError(err: unknown): string {
if (err instanceof Error) {
const stack = err.stack ? `\n${err.stack}` : '';
return `${err.name}: ${err.message}${stack}`;
}
if (typeof err === 'object' && err !== null) {
try {
return JSON.stringify(err, null, 2);
} catch {
return String(err);
}
}
return String(err);
}
function formatData(data: unknown): string {
if (data === undefined || data === null) return '';
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
}
function serverLog(entry: LogEntry) {
const color = LEVEL_COLORS[entry.level];
const levelTag = `${color}${BOLD}[${entry.level.toUpperCase()}]${RESET}`;
const time = `${DIM}${entry.timestamp}${RESET}`;
const ctx = `${color}[${entry.context}]${RESET}`;
let line = `${levelTag} ${time} ${ctx} ${entry.message}`;
if (entry.data !== undefined) {
line += `\n ${DIM}data:${RESET} ${formatData(entry.data)}`;
}
if (entry.error !== undefined) {
line += `\n ${color}error:${RESET} ${formatError(entry.error)}`;
}
// Use stderr for errors/warnings so they stand out in terminal
if (entry.level === 'error') {
console.error(line);
} else if (entry.level === 'warn') {
console.warn(line);
} else {
console.log(line);
}
}
function clientLog(entry: LogEntry) {
const prefix = `[${entry.level.toUpperCase()}] [${entry.context}]`;
const args: unknown[] = [prefix, entry.message];
if (entry.data !== undefined) args.push(entry.data);
if (entry.error !== undefined) args.push(entry.error);
switch (entry.level) {
case 'error':
console.error(...args);
break;
case 'warn':
console.warn(...args);
break;
case 'debug':
console.debug(...args);
break;
default:
console.log(...args);
}
}
function log(level: LogLevel, context: string, message: string, extra?: { data?: unknown; error?: unknown }) {
if (!shouldLog(level)) return;
const entry: LogEntry = {
level,
context,
message,
data: extra?.data,
error: extra?.error,
timestamp: new Date().toISOString(),
};
if (isServer()) {
serverLog(entry);
} else {
clientLog(entry);
}
// Store in recent logs buffer for debugging
recentLogs.push(entry);
if (recentLogs.length > MAX_RECENT_LOGS) {
recentLogs.shift();
}
return entry;
}
// Ring buffer of recent logs — useful for dumping context on crash
const MAX_RECENT_LOGS = 100;
const recentLogs: LogEntry[] = [];
/**
* Create a scoped logger for a specific module/context.
*
* Usage:
* ```ts
* const log = createLogger('kanban.api');
* log.info('Loading board', { data: { boardId } });
* log.error('Failed to load board', { error: err, data: { boardId } });
* ```
*/
export function createLogger(context: string) {
return {
debug: (message: string, extra?: { data?: unknown; error?: unknown }) =>
log('debug', context, message, extra),
info: (message: string, extra?: { data?: unknown; error?: unknown }) =>
log('info', context, message, extra),
warn: (message: string, extra?: { data?: unknown; error?: unknown }) =>
log('warn', context, message, extra),
error: (message: string, extra?: { data?: unknown; error?: unknown }) =>
log('error', context, message, extra),
};
}
/** Set the minimum log level */
export function setLogLevel(level: LogLevel) {
minLevel = level;
}
/** Get recent log entries (for error reports / debugging) */
export function getRecentLogs(): LogEntry[] {
return [...recentLogs];
}
/** Clear recent logs */
export function clearRecentLogs() {
recentLogs.length = 0;
}
/**
* Format recent logs as a copyable string for bug reports.
* User can paste this to you for debugging.
*/
export function dumpLogs(): string {
return recentLogs
.map((e) => {
let line = `[${e.level.toUpperCase()}] ${e.timestamp} [${e.context}] ${e.message}`;
if (e.data !== undefined) line += ` | data: ${formatData(e.data)}`;
if (e.error !== undefined) line += ` | error: ${formatError(e.error)}`;
return line;
})
.join('\n');
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,6 @@
<script lang="ts">
import { getContext } from "svelte";
import { Button, Modal, Input } from "$lib/components/ui";
import { FileTree, Editor } from "$lib/components/documents";
import { buildDocumentTree } from "$lib/api/documents";
import { FileBrowser } from "$lib/components/documents";
import type { Document } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Props {
data: {
@@ -17,326 +12,21 @@
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let documents = $state(data.documents);
let selectedDoc = $state<Document | null>(null);
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editingDoc = $state<Document | null>(null);
let newDocName = $state("");
let newDocType = $state<"folder" | "document">("document");
let parentFolderId = $state<string | null>(null);
let isEditing = $state(false);
const documentTree = $derived(buildDocumentTree(documents));
function handleSelect(doc: Document) {
if (doc.type === "document") {
selectedDoc = doc;
}
}
function handleDoubleClick(doc: Document) {
if (doc.type === "document") {
// Open document in new window
const url = `/${data.org.slug}/documents/${doc.id}`;
window.open(url, "_blank", "width=900,height=700");
}
}
function handleAdd(folderId: string | null) {
parentFolderId = folderId;
showCreateModal = true;
}
async function handleMove(docId: string, newParentId: string | null) {
const { error } = await supabase
.from("documents")
.update({
parent_id: newParentId,
updated_at: new Date().toISOString(),
})
.eq("id", docId);
if (!error) {
documents = documents.map((d) =>
d.id === docId ? { ...d, parent_id: newParentId } : d,
);
}
}
async function handleCreate() {
if (!newDocName.trim() || !data.user) return;
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: data.org.id,
name: newDocName,
type: newDocType,
parent_id: parentFolderId,
created_by: data.user.id,
content:
newDocType === "document"
? { type: "doc", content: [] }
: null,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc];
if (newDocType === "document") {
selectedDoc = newDoc;
}
}
showCreateModal = false;
newDocName = "";
newDocType = "document";
parentFolderId = null;
}
async function handleSave(content: unknown) {
if (!selectedDoc) return;
await supabase
.from("documents")
.update({ content, updated_at: new Date().toISOString() })
.eq("id", selectedDoc.id);
documents = documents.map((d) =>
d.id === selectedDoc!.id ? { ...d, content } : d,
);
}
function handleEdit(doc: Document) {
editingDoc = doc;
newDocName = doc.name;
showEditModal = true;
}
async function handleRename() {
if (!editingDoc || !newDocName.trim()) return;
const { error } = await supabase
.from("documents")
.update({ name: newDocName, updated_at: new Date().toISOString() })
.eq("id", editingDoc.id);
if (!error) {
documents = documents.map((d) =>
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
);
if (selectedDoc?.id === editingDoc.id) {
selectedDoc = { ...selectedDoc, name: newDocName };
}
}
showEditModal = false;
editingDoc = null;
newDocName = "";
}
async function handleDelete(doc: Document) {
const itemType =
doc.type === "folder" ? "folder and all its contents" : "document";
if (!confirm(`Delete this ${itemType}?`)) return;
// If deleting a folder, delete all children first
if (doc.type === "folder") {
const childIds = documents
.filter((d) => d.parent_id === doc.id)
.map((d) => d.id);
if (childIds.length > 0) {
await supabase.from("documents").delete().in("id", childIds);
}
}
const { error } = await supabase
.from("documents")
.delete()
.eq("id", doc.id);
if (!error) {
documents = documents.filter(
(d) => d.id !== doc.id && d.parent_id !== doc.id,
);
if (selectedDoc?.id === doc.id) {
selectedDoc = null;
}
}
}
$effect(() => {
documents = data.documents;
});
</script>
<svelte:head>
<title
>{selectedDoc ? `${selectedDoc.name} - ` : ""}Documents - {data.org
.name} | Root</title
>
<title>Files - {data.org.name} | Root</title>
</svelte:head>
<div class="flex h-full">
<aside class="w-72 border-r border-light/10 flex flex-col">
<div
class="p-4 border-b border-light/10 flex items-center justify-between"
>
<h2 class="font-semibold text-light">Documents</h2>
<Button size="sm" onclick={() => (showCreateModal = true)}>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</Button>
</div>
<div class="flex-1 overflow-y-auto p-2">
{#if documentTree.length === 0}
<div class="text-center text-light/40 py-8 text-sm">
<p>No documents yet</p>
<p class="mt-1">Create your first document</p>
</div>
{:else}
<FileTree
items={documentTree}
selectedId={selectedDoc?.id ?? null}
onSelect={handleSelect}
onDoubleClick={handleDoubleClick}
onAdd={handleAdd}
onMove={handleMove}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{/if}
</div>
</aside>
<main class="flex-1 overflow-hidden flex flex-col">
{#if selectedDoc}
<div
class="flex items-center justify-between p-4 border-b border-light/10"
>
<h2 class="text-lg font-semibold text-light">
{selectedDoc.name}
</h2>
<button
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {isEditing
? 'bg-primary text-white'
: 'bg-light/10 text-light hover:bg-light/20'}"
onclick={() => (isEditing = !isEditing)}
>
{isEditing ? "Preview" : "Edit"}
</button>
</div>
<div class="flex-1 overflow-auto">
<Editor
document={selectedDoc}
onSave={handleSave}
editable={isEditing}
/>
</div>
{:else}
<div class="h-full flex items-center justify-center text-light/40">
<div class="text-center">
<svg
class="w-16 h-16 mx-auto mb-4 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14,2 14,8 20,8" />
</svg>
<p>Select a document to edit</p>
</div>
</div>
{/if}
</main>
<div class="h-full p-4 lg:p-5">
<FileBrowser
org={data.org}
bind:documents
currentFolderId={null}
user={data.user}
/>
</div>
<Modal
isOpen={showCreateModal}
onClose={() => (showCreateModal = false)}
title="Create New"
>
<div class="space-y-4">
<div class="flex gap-2">
<button
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
'document'
? 'border-primary bg-primary/10'
: 'border-light/20'}"
onclick={() => (newDocType = "document")}
>
Document
</button>
<button
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
'folder'
? 'border-primary bg-primary/10'
: 'border-light/20'}"
onclick={() => (newDocType = "folder")}
>
Folder
</button>
</div>
<Input
label="Name"
bind:value={newDocName}
placeholder={newDocType === "folder"
? "Folder name"
: "Document name"}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showCreateModal = false)}
>Cancel</Button
>
<Button onclick={handleCreate} disabled={!newDocName.trim()}
>Create</Button
>
</div>
</div>
</Modal>
<Modal
isOpen={showEditModal}
onClose={() => {
showEditModal = false;
editingDoc = null;
newDocName = "";
}}
title="Rename"
>
<div class="space-y-4">
<Input
label="Name"
bind:value={newDocName}
placeholder="Enter new name"
/>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onclick={() => {
showEditModal = false;
editingDoc = null;
newDocName = "";
}}>Cancel</Button
>
<Button onclick={handleRename} disabled={!newDocName.trim()}
>Save</Button
>
</div>
</div>
</Modal>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,17 +4,29 @@
import { goto } from "$app/navigation";
import { page } from "$app/stores";
let email = $state("");
let email = $state($page.url.searchParams.get("email") || "");
let password = $state("");
let isLoading = $state(false);
let error = $state("");
let mode = $state<"login" | "signup">("login");
let signupSuccess = $state(false);
let mode = $state<"login" | "signup">(
($page.url.searchParams.get("tab") as "login" | "signup") || "login",
);
const supabase = createClient();
// Get redirect URL from query params (for invite flow)
const redirectUrl = $derived($page.url.searchParams.get("redirect") || "/");
// Show error from callback (e.g. OAuth failure)
const callbackError = $page.url.searchParams.get("error");
if (callbackError) {
error =
callbackError === "auth_callback_error"
? "Authentication failed. Please try again."
: callbackError;
}
async function handleSubmit() {
if (!email || !password) {
error = "Please fill in all fields";
@@ -32,17 +44,24 @@
password,
});
if (authError) throw authError;
goto(redirectUrl);
} else {
const { error: authError } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
const { data: signUpData, error: authError } =
await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirectUrl)}`,
},
});
if (authError) throw authError;
// If email confirmation is required, session will be null
if (signUpData.session) {
goto(redirectUrl);
} else {
signupSuccess = true;
}
}
goto(redirectUrl);
} catch (e: unknown) {
error = e instanceof Error ? e.message : "An error occurred";
} finally {
@@ -79,97 +98,129 @@
</div>
<Card variant="elevated" padding="lg">
<h2 class="text-xl font-semibold text-light mb-6">
{mode === "login" ? "Welcome back" : "Create your account"}
</h2>
{#if error}
<div
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm"
>
{error}
{#if signupSuccess}
<div class="text-center py-4">
<div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center"
>
<span
class="material-symbols-rounded text-success"
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 32;"
>
mark_email_read
</span>
</div>
<h2 class="text-xl font-semibold text-light mb-2">
Check your email
</h2>
<p class="text-light/60 text-sm mb-4">
We've sent a confirmation link to <strong
class="text-light">{email}</strong
>. Click the link to activate your account.
</p>
<Button
variant="tertiary"
onclick={() => {
signupSuccess = false;
mode = "login";
}}
>
Back to Login
</Button>
</div>
{/if}
{:else}
<h2 class="text-xl font-semibold text-light mb-6">
{mode === "login" ? "Welcome back" : "Create your account"}
</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
<Input
type="email"
label="Email"
placeholder="you@example.com"
bind:value={email}
required
/>
<Input
type="password"
label="Password"
placeholder="••••••••"
bind:value={password}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{mode === "login" ? "Log In" : "Sign Up"}
</Button>
</form>
<div class="my-6 flex items-center gap-3">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-light/40 text-sm">or continue with</span>
<div class="flex-1 h-px bg-light/10"></div>
</div>
<Button
variant="secondary"
fullWidth
onclick={() => handleOAuth("google")}
>
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<p class="mt-6 text-center text-light/60 text-sm">
{#if mode === "login"}
Don't have an account?
<button
class="text-primary hover:underline"
onclick={() => (mode = "signup")}
{#if error}
<div
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm"
>
Sign up
</button>
{:else}
Already have an account?
<button
class="text-primary hover:underline"
onclick={() => (mode = "login")}
>
Log in
</button>
{error}
</div>
{/if}
</p>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
<Input
type="email"
label="Email"
placeholder="you@example.com"
bind:value={email}
required
/>
<Input
type="password"
label="Password"
placeholder="••••••••"
bind:value={password}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{mode === "login" ? "Log In" : "Sign Up"}
</Button>
</form>
<div class="my-6 flex items-center gap-3">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-light/40 text-sm">or continue with</span>
<div class="flex-1 h-px bg-light/10"></div>
</div>
<Button
variant="secondary"
fullWidth
onclick={() => handleOAuth("google")}
>
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<p class="mt-6 text-center text-light/60 text-sm">
{#if mode === "login"}
Don't have an account?
<button
class="text-primary hover:underline"
onclick={() => (mode = "signup")}
>
Sign up
</button>
{:else}
Already have an account?
<button
class="text-primary hover:underline"
onclick={() => (mode = "login")}
>
Log in
</button>
{/if}
</p>
{/if}
</Card>
</div>
</div>

View File

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

View File

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