Mega push vol 4
This commit is contained in:
7
src/app.d.ts
vendored
7
src/app.d.ts
vendored
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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
19
src/hooks.client.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
152
src/lib/api/document-locks.ts
Normal file
152
src/lib/api/document-locks.ts
Normal 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);
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
105
src/lib/components/documents/DocumentViewer.svelte
Normal file
105
src/lib/components/documents/DocumentViewer.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
874
src/lib/components/documents/FileBrowser.svelte
Normal file
874
src/lib/components/documents/FileBrowser.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
170
src/lib/components/kanban/CardChecklist.svelte
Normal file
170
src/lib/components/kanban/CardChecklist.svelte
Normal 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>
|
||||
159
src/lib/components/kanban/CardComments.svelte
Normal file
159
src/lib/components/kanban/CardComments.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
90
src/lib/components/kanban/CardMetadata.svelte
Normal file
90
src/lib/components/kanban/CardMetadata.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
216
src/lib/components/settings/SettingsGeneral.svelte
Normal file
216
src/lib/components/settings/SettingsGeneral.svelte
Normal 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>
|
||||
1
src/lib/components/settings/index.ts
Normal file
1
src/lib/components/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SettingsGeneral } from './SettingsGeneral.svelte';
|
||||
108
src/lib/components/ui/AssigneePicker.svelte
Normal file
108
src/lib/components/ui/AssigneePicker.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
35
src/lib/components/ui/CalendarDay.svelte
Normal file
35
src/lib/components/ui/CalendarDay.svelte
Normal 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}
|
||||
26
src/lib/components/ui/Chip.svelte
Normal file
26
src/lib/components/ui/Chip.svelte
Normal 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>
|
||||
42
src/lib/components/ui/ContentHeader.svelte
Normal file
42
src/lib/components/ui/ContentHeader.svelte
Normal 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>
|
||||
64
src/lib/components/ui/Dropdown.svelte
Normal file
64
src/lib/components/ui/Dropdown.svelte
Normal 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>
|
||||
31
src/lib/components/ui/DropdownItem.svelte
Normal file
31
src/lib/components/ui/DropdownItem.svelte
Normal 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>
|
||||
29
src/lib/components/ui/EmptyState.svelte
Normal file
29
src/lib/components/ui/EmptyState.svelte
Normal 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>
|
||||
16
src/lib/components/ui/Icon.svelte
Normal file
16
src/lib/components/ui/Icon.svelte
Normal 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>
|
||||
59
src/lib/components/ui/IconButton.svelte
Normal file
59
src/lib/components/ui/IconButton.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
61
src/lib/components/ui/KanbanColumn.svelte
Normal file
61
src/lib/components/ui/KanbanColumn.svelte
Normal 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>
|
||||
60
src/lib/components/ui/ListItem.svelte
Normal file
60
src/lib/components/ui/ListItem.svelte
Normal 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>
|
||||
39
src/lib/components/ui/Logo.svelte
Normal file
39
src/lib/components/ui/Logo.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
30
src/lib/components/ui/OrgHeader.svelte
Normal file
30
src/lib/components/ui/OrgHeader.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
72
src/lib/components/ui/Skeleton.svelte
Normal file
72
src/lib/components/ui/Skeleton.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -1,2 +0,0 @@
|
||||
export { auth } from './auth.svelte';
|
||||
export { orgs, type OrgWithRole } from './organizations.svelte';
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
83
src/lib/stores/toast.svelte.ts
Normal file
83
src/lib/stores/toast.svelte.ts
Normal 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();
|
||||
@@ -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();
|
||||
@@ -1,3 +1,2 @@
|
||||
export { createClient } from './client';
|
||||
export { createClient as createServerClient } from './server';
|
||||
export type * from './types';
|
||||
|
||||
@@ -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
207
src/lib/utils/logger.ts
Normal 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
82
src/routes/+error.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { Button } from "$lib/components/ui";
|
||||
import { dumpLogs } from "$lib/utils/logger";
|
||||
|
||||
let showLogs = $state(false);
|
||||
let logDump = $state("");
|
||||
let copied = $state(false);
|
||||
|
||||
function handleShowLogs() {
|
||||
logDump = dumpLogs();
|
||||
showLogs = !showLogs;
|
||||
}
|
||||
|
||||
async function handleCopyLogs() {
|
||||
const dump = dumpLogs();
|
||||
const errorInfo = `--- Error Report ---
|
||||
URL: ${$page.url.pathname}
|
||||
Status: ${$page.status}
|
||||
Message: ${$page.error?.message || "Unknown"}
|
||||
Error ID: ${$page.error?.errorId || "N/A"}
|
||||
Context: ${$page.error?.context || "N/A"}
|
||||
Time: ${new Date().toISOString()}
|
||||
|
||||
--- Recent Logs ---
|
||||
${dump}
|
||||
`;
|
||||
await navigator.clipboard.writeText(errorInfo);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-night flex items-center justify-center p-4">
|
||||
<div class="max-w-lg w-full text-center space-y-6">
|
||||
<div class="space-y-2">
|
||||
<p class="text-[80px] font-heading text-primary">{$page.status}</p>
|
||||
<h1 class="text-2xl font-heading text-white">
|
||||
{$page.status === 404 ? "Page not found" : "Something went wrong"}
|
||||
</h1>
|
||||
<p class="text-light/60 text-base">
|
||||
{$page.error?.message || "An unexpected error occurred."}
|
||||
</p>
|
||||
{#if $page.error?.errorId}
|
||||
<p class="text-light/40 text-sm font-mono">
|
||||
Error ID: {$page.error.errorId}
|
||||
</p>
|
||||
{/if}
|
||||
{#if $page.error?.context}
|
||||
<p class="text-light/40 text-sm font-mono">
|
||||
{$page.error.context}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-center flex-wrap">
|
||||
<Button onclick={() => window.location.href = "/"}>
|
||||
Go Home
|
||||
</Button>
|
||||
<Button variant="tertiary" onclick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={handleCopyLogs}>
|
||||
{copied ? "Copied!" : "Copy Error Report"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-light/40 hover:text-light/60 transition-colors underline"
|
||||
onclick={handleShowLogs}
|
||||
>
|
||||
{showLogs ? "Hide" : "Show"} debug logs
|
||||
</button>
|
||||
|
||||
{#if showLogs}
|
||||
<pre class="mt-4 p-4 bg-dark rounded-[16px] text-left text-xs text-light/70 overflow-auto max-h-[300px] font-mono whitespace-pre-wrap">{logDump || "No recent logs."}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getContext } from "svelte";
|
||||
import { Button, Card, Modal, Input } from "$lib/components/ui";
|
||||
import { createOrganization, generateSlug } from "$lib/api/organizations";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
|
||||
@@ -24,6 +25,9 @@
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let organizations = $state(data.organizations);
|
||||
$effect(() => {
|
||||
organizations = data.organizations;
|
||||
});
|
||||
let showCreateModal = $state(false);
|
||||
let newOrgName = $state("");
|
||||
let creating = $state(false);
|
||||
@@ -41,7 +45,9 @@
|
||||
showCreateModal = false;
|
||||
newOrgName = "";
|
||||
} catch (error) {
|
||||
console.error("Failed to create organization:", error);
|
||||
toasts.error(
|
||||
"Failed to create organization. The name may already be taken.",
|
||||
);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
@@ -63,7 +69,7 @@
|
||||
>Style Guide</a
|
||||
>
|
||||
<form method="POST" action="/auth/logout">
|
||||
<Button variant="ghost" size="sm" type="submit"
|
||||
<Button variant="tertiary" size="sm" type="submit"
|
||||
>Sign Out</Button
|
||||
>
|
||||
</form>
|
||||
@@ -180,7 +186,7 @@
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onclick={() => (showCreateModal = false)}
|
||||
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -8,6 +8,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
||||
error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Fetch org first (need org.id for subsequent queries)
|
||||
const { data: org, error: orgError } = await locals.supabase
|
||||
.from('organizations')
|
||||
.select('*')
|
||||
@@ -18,58 +19,62 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
||||
error(404, 'Organization not found');
|
||||
}
|
||||
|
||||
const { data: membership } = await locals.supabase
|
||||
.from('org_members')
|
||||
.select('role')
|
||||
.eq('org_id', org.id)
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
// Now fetch membership, members, and activity in parallel (all depend on org.id)
|
||||
const [membershipResult, membersResult, activityResult] = await Promise.all([
|
||||
locals.supabase
|
||||
.from('org_members')
|
||||
.select('role')
|
||||
.eq('org_id', org.id)
|
||||
.eq('user_id', user.id)
|
||||
.single(),
|
||||
locals.supabase
|
||||
.from('org_members')
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
role,
|
||||
profiles:user_id (
|
||||
id,
|
||||
email,
|
||||
full_name,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('org_id', org.id)
|
||||
.limit(10),
|
||||
locals.supabase
|
||||
.from('activity_log')
|
||||
.select(`
|
||||
id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
entity_name,
|
||||
created_at,
|
||||
profiles:user_id (
|
||||
full_name,
|
||||
email
|
||||
)
|
||||
`)
|
||||
.eq('org_id', org.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
]);
|
||||
|
||||
const { data: membership } = membershipResult;
|
||||
const { data: members } = membersResult;
|
||||
const { data: recentActivity } = activityResult;
|
||||
|
||||
if (!membership) {
|
||||
error(403, 'You are not a member of this organization');
|
||||
}
|
||||
|
||||
// Fetch team members for sidebar
|
||||
const { data: members } = await locals.supabase
|
||||
.from('org_members')
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
role,
|
||||
profiles:user_id (
|
||||
id,
|
||||
email,
|
||||
full_name,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('org_id', org.id)
|
||||
.limit(10);
|
||||
|
||||
// Fetch recent activity
|
||||
const { data: recentActivity } = await locals.supabase
|
||||
.from('activity_log')
|
||||
.select(`
|
||||
id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
entity_name,
|
||||
created_at,
|
||||
profiles:user_id (
|
||||
full_name,
|
||||
email
|
||||
)
|
||||
`)
|
||||
.eq('org_id', org.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
return {
|
||||
org,
|
||||
role: membership.role,
|
||||
userRole: membership.role,
|
||||
userRole: membership.role, // kept for backwards compat — same as role
|
||||
members: members ?? [],
|
||||
recentActivity: recentActivity ?? []
|
||||
recentActivity: recentActivity ?? [],
|
||||
user
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { page, navigating } from "$app/stores";
|
||||
import type { Snippet } from "svelte";
|
||||
import { Avatar, Logo } from "$lib/components/ui";
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
@@ -16,7 +17,12 @@
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
org: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
role: string;
|
||||
userRole: string;
|
||||
members: Member[];
|
||||
@@ -26,24 +32,25 @@
|
||||
|
||||
let { data, children }: Props = $props();
|
||||
|
||||
let sidebarCollapsed = $state(false);
|
||||
|
||||
const isAdmin = $derived(
|
||||
data.userRole === "owner" || data.userRole === "admin",
|
||||
);
|
||||
|
||||
// Sidebar collapses on all pages except org overview
|
||||
const isOrgOverview = $derived($page.url.pathname === `/${data.org.slug}`);
|
||||
let sidebarHovered = $state(false);
|
||||
const sidebarCollapsed = $derived(!isOrgOverview && !sidebarHovered);
|
||||
|
||||
const navItems = $derived([
|
||||
{ href: `/${data.org.slug}`, label: "Overview", icon: "home" },
|
||||
{
|
||||
href: `/${data.org.slug}/documents`,
|
||||
label: "Documents",
|
||||
icon: "file",
|
||||
label: "Files",
|
||||
icon: "cloud",
|
||||
},
|
||||
{ href: `/${data.org.slug}/kanban`, label: "Kanban", icon: "kanban" },
|
||||
{
|
||||
href: `/${data.org.slug}/calendar`,
|
||||
label: "Calendar",
|
||||
icon: "calendar",
|
||||
icon: "calendar_today",
|
||||
},
|
||||
// Only show settings for admins
|
||||
...(isAdmin
|
||||
@@ -58,7 +65,7 @@
|
||||
]);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return $page.url.pathname === href;
|
||||
return $page.url.pathname.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -66,206 +73,107 @@
|
||||
<div class="flex h-screen bg-background p-4 gap-4">
|
||||
<!-- Organization Module -->
|
||||
<aside
|
||||
class="{sidebarCollapsed
|
||||
? 'w-20'
|
||||
: 'w-56'} bg-night rounded-[32px] flex flex-col px-3 py-5 transition-all duration-200 overflow-hidden"
|
||||
class="
|
||||
{sidebarCollapsed ? 'w-[72px]' : 'w-64'}
|
||||
transition-all duration-300
|
||||
bg-night rounded-[32px] flex flex-col px-4 py-5 gap-4 overflow-hidden shrink-0
|
||||
"
|
||||
onmouseenter={() => (sidebarHovered = true)}
|
||||
onmouseleave={() => (sidebarHovered = false)}
|
||||
>
|
||||
<!-- Org Header -->
|
||||
<div class="flex items-start gap-2 px-1 mb-2">
|
||||
<a
|
||||
href="/{data.org.slug}"
|
||||
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xl font-heading shrink-0"
|
||||
class="shrink-0 transition-all duration-300 {sidebarCollapsed
|
||||
? 'w-8 h-8'
|
||||
: 'w-12 h-12'}"
|
||||
>
|
||||
{data.org.name[0].toUpperCase()}
|
||||
<Avatar
|
||||
name={data.org.name}
|
||||
src={data.org.avatar_url}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
{#if !sidebarCollapsed}
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="font-heading text-xl text-light truncate">
|
||||
{data.org.name}
|
||||
</h1>
|
||||
<p class="text-xs text-white capitalize">{data.role}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-hidden transition-all duration-300 {sidebarCollapsed
|
||||
? 'opacity-0 max-w-0'
|
||||
: 'opacity-100 max-w-[200px]'}"
|
||||
>
|
||||
<h1
|
||||
class="font-heading text-h3 text-white truncate whitespace-nowrap"
|
||||
>
|
||||
{data.org.name}
|
||||
</h1>
|
||||
<p
|
||||
class="text-body-sm text-white font-body capitalize whitespace-nowrap"
|
||||
>
|
||||
{data.role}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Nav Items -->
|
||||
<nav class="flex-1 space-y-0.5">
|
||||
<nav class="flex-1 flex flex-col gap-1">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors {isActive(
|
||||
class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] transition-colors {isActive(
|
||||
item.href,
|
||||
)
|
||||
? 'bg-primary/20'
|
||||
: 'hover:bg-light/5'}"
|
||||
? 'bg-primary'
|
||||
: 'hover:bg-dark'}"
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
<!-- Icon circle -->
|
||||
<div
|
||||
class="w-8 h-8 rounded-full {isActive(item.href)
|
||||
? 'bg-primary'
|
||||
: 'bg-light'} flex items-center justify-center shrink-0"
|
||||
class="w-8 h-8 flex items-center justify-center p-1 shrink-0"
|
||||
>
|
||||
{#if item.icon === "home"}
|
||||
<svg
|
||||
class="w-4 h-4 {isActive(item.href)
|
||||
? 'text-white'
|
||||
: 'text-night'}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
<polyline points="9,22 9,12 15,12 15,22" />
|
||||
</svg>
|
||||
{:else if item.icon === "file"}
|
||||
<svg
|
||||
class="w-4 h-4 {isActive(item.href)
|
||||
? 'text-white'
|
||||
: 'text-night'}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
</svg>
|
||||
{:else if item.icon === "kanban"}
|
||||
<svg
|
||||
class="w-4 h-4 {isActive(item.href)
|
||||
? 'text-white'
|
||||
: 'text-night'}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/>
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
{:else if item.icon === "calendar"}
|
||||
<svg
|
||||
class="w-4 h-4 {isActive(item.href)
|
||||
? 'text-white'
|
||||
: 'text-night'}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/>
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{:else if item.icon === "settings"}
|
||||
<svg
|
||||
class="w-4 h-4 {isActive(item.href)
|
||||
? 'text-white'
|
||||
: 'text-night'}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M12 1v2m0 18v2M4.2 4.2l1.4 1.4m12.8 12.8l1.4 1.4M1 12h2m18 0h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !sidebarCollapsed}
|
||||
<span class="font-bold text-light truncate"
|
||||
>{item.label}</span
|
||||
<span
|
||||
class="material-symbols-rounded {isActive(item.href)
|
||||
? 'text-background'
|
||||
: 'text-light'}"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>
|
||||
{/if}
|
||||
{item.icon}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="font-body text-body truncate whitespace-nowrap transition-all duration-300 {isActive(
|
||||
item.href,
|
||||
)
|
||||
? 'text-background'
|
||||
: 'text-white'} {sidebarCollapsed
|
||||
? 'opacity-0 max-w-0 overflow-hidden'
|
||||
: 'opacity-100 max-w-[200px]'}">{item.label}</span
|
||||
>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Team Members -->
|
||||
{#if !sidebarCollapsed}
|
||||
<div class="mt-4 pt-4 border-t border-light/10">
|
||||
<p class="font-heading text-base text-light mb-2 px-1">Team</p>
|
||||
{#if data.members && data.members.length > 0}
|
||||
<div class="space-y-0.5">
|
||||
{#each data.members.slice(0, 5) as member}
|
||||
<div
|
||||
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] hover:bg-light/5 transition-colors"
|
||||
>
|
||||
<div
|
||||
class="w-5 h-5 rounded-full bg-gradient-to-br from-primary to-primary/50 flex items-center justify-center text-white text-xs font-medium"
|
||||
>
|
||||
{(member.profiles?.full_name ||
|
||||
member.profiles?.email ||
|
||||
"?")[0].toUpperCase()}
|
||||
</div>
|
||||
<span
|
||||
class="text-sm font-bold text-light truncate flex-1"
|
||||
>
|
||||
{member.profiles?.full_name ||
|
||||
member.profiles?.email?.split("@")[0] ||
|
||||
"User"}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-light/40 px-1">
|
||||
No team members found
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Back link -->
|
||||
<div class="mt-auto pt-4">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] text-light/50 hover:text-light hover:bg-light/5 transition-colors"
|
||||
title={sidebarCollapsed ? "All Organizations" : undefined}
|
||||
>
|
||||
<div
|
||||
class="w-5 h-5 rounded-full bg-light/20 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
{#if !sidebarCollapsed}
|
||||
<span class="text-sm">All Organizations</span>
|
||||
{/if}
|
||||
<!-- Logo at bottom -->
|
||||
<div class="mt-auto">
|
||||
<a href="/" title="Back to organizations">
|
||||
<Logo size={sidebarCollapsed ? "sm" : "md"} />
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1 bg-night rounded-[32px] overflow-auto">
|
||||
<main class="flex-1 bg-night rounded-[32px] overflow-auto relative">
|
||||
{#if $navigating}
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-night/80 backdrop-blur-sm"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-primary animate-spin"
|
||||
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 40;"
|
||||
>
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,329 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Card } from "$lib/components/ui";
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_name: string | null;
|
||||
created_at: string;
|
||||
profiles: { full_name: string | null; email: string } | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
role: string;
|
||||
members?: Array<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
profiles: { full_name: string | null; email: string };
|
||||
}>;
|
||||
recentActivity?: ActivityItem[];
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
href: `/${data.org.slug}/documents`,
|
||||
label: "Documents",
|
||||
description: "Collaborative docs and files",
|
||||
icon: "file",
|
||||
},
|
||||
{
|
||||
href: `/${data.org.slug}/kanban`,
|
||||
label: "Kanban",
|
||||
description: "Track tasks and projects",
|
||||
icon: "kanban",
|
||||
},
|
||||
{
|
||||
href: `/${data.org.slug}/calendar`,
|
||||
label: "Calendar",
|
||||
description: "Schedule events and meetings",
|
||||
icon: "calendar",
|
||||
},
|
||||
];
|
||||
|
||||
// Get icon based on entity type
|
||||
function getActivityIcon(entityType: string): string {
|
||||
switch (entityType) {
|
||||
case "document":
|
||||
return "file";
|
||||
case "kanban_card":
|
||||
case "kanban_board":
|
||||
return "kanban";
|
||||
case "calendar_event":
|
||||
return "calendar";
|
||||
case "member":
|
||||
return "user";
|
||||
default:
|
||||
return "activity";
|
||||
}
|
||||
}
|
||||
|
||||
// Format relative time
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Format action text
|
||||
function formatAction(action: string, entityType: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
document: "document",
|
||||
kanban_card: "task",
|
||||
kanban_board: "board",
|
||||
calendar_event: "event",
|
||||
member: "member",
|
||||
};
|
||||
const type = typeMap[entityType] || entityType;
|
||||
return `${action.charAt(0).toUpperCase() + action.slice(1)} ${type}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.org.name} - Overview | Root</title>
|
||||
<title>{data.org.name} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-8">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-heading text-light">{data.org.name}</h1>
|
||||
<p class="text-light/50 mt-1">Organization Overview</p>
|
||||
<div class="p-4 lg:p-6">
|
||||
<header>
|
||||
<h1 class="text-h1 font-heading text-white">{data.org.name}</h1>
|
||||
<p class="text-body text-light/60 font-body">Organization Overview</p>
|
||||
</header>
|
||||
|
||||
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
{#each quickLinks as link}
|
||||
<a href={link.href} class="block group">
|
||||
<Card
|
||||
class="h-full hover:ring-1 hover:ring-primary/50 transition-all"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div
|
||||
class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
{#if link.icon === "file"}
|
||||
<svg
|
||||
class="w-6 h-6 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
</svg>
|
||||
{:else if link.icon === "kanban"}
|
||||
<svg
|
||||
class="w-6 h-6 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/>
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
{:else if link.icon === "calendar"}
|
||||
<svg
|
||||
class="w-6 h-6 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/>
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-light mb-1">
|
||||
{link.label}
|
||||
</h3>
|
||||
<p class="text-sm text-light/50">{link.description}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-heading text-light mb-4">Recent Activity</h2>
|
||||
<Card>
|
||||
{#if data.recentActivity && data.recentActivity.length > 0}
|
||||
<div class="divide-y divide-light/10">
|
||||
{#each data.recentActivity as activity}
|
||||
{@const icon = getActivityIcon(activity.entity_type)}
|
||||
<div
|
||||
class="flex items-center gap-4 p-4 hover:bg-light/5 transition-colors"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0"
|
||||
>
|
||||
{#if icon === "file"}
|
||||
<svg
|
||||
class="w-5 h-5 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
</svg>
|
||||
{:else if icon === "kanban"}
|
||||
<svg
|
||||
class="w-5 h-5 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/>
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
{:else if icon === "calendar"}
|
||||
<svg
|
||||
class="w-5 h-5 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/>
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{:else if icon === "user"}
|
||||
<svg
|
||||
class="w-5 h-5 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
|
||||
/>
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="w-5 h-5 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12,6 12,12 16,14" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-light font-medium">
|
||||
{formatAction(
|
||||
activity.action,
|
||||
activity.entity_type,
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm text-light/50 truncate">
|
||||
{activity.entity_name || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs text-light/40 shrink-0"
|
||||
>{formatRelativeTime(activity.created_at)}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-6 text-center text-light/50">
|
||||
<p>No recent activity</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Team Stats -->
|
||||
{#if data.members && data.members.length > 0}
|
||||
<section class="mt-8">
|
||||
<h2 class="text-xl font-heading text-light mb-4">Team</h2>
|
||||
<Card>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#each data.members.slice(0, 8) as member}
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-primary/50 flex items-center justify-center text-white font-medium"
|
||||
title={member.profiles?.full_name ||
|
||||
member.profiles?.email}
|
||||
>
|
||||
{(member.profiles?.full_name ||
|
||||
member.profiles?.email ||
|
||||
"?")[0].toUpperCase()}
|
||||
</div>
|
||||
{/each}
|
||||
{#if data.members.length > 8}
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-light/10 flex items-center justify-center text-light/50 text-sm"
|
||||
>
|
||||
+{data.members.length - 8}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-light/50 mt-3">
|
||||
{data.members.length} team member{data.members
|
||||
.length !== 1
|
||||
? "s"
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.calendar');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { org, userRole } = await parent();
|
||||
@@ -9,7 +12,7 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 2, 0);
|
||||
|
||||
const { data: events } = await supabase
|
||||
const { data: events, error } = await supabase
|
||||
.from('calendar_events')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
@@ -17,6 +20,10 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
.lte('end_time', endDate.toISOString())
|
||||
.order('start_time');
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to load calendar events', { error, data: { orgId: org.id } });
|
||||
}
|
||||
|
||||
return {
|
||||
events: events ?? [],
|
||||
userRole
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { Button, Modal } from "$lib/components/ui";
|
||||
import { Button, Modal, Avatar } from "$lib/components/ui";
|
||||
import { Calendar } from "$lib/components/calendar";
|
||||
import {
|
||||
getCalendarSubscribeUrl,
|
||||
@@ -24,6 +24,9 @@
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let events = $state(data.events);
|
||||
$effect(() => {
|
||||
events = data.events;
|
||||
});
|
||||
let googleEvents = $state<CalendarEvent[]>([]);
|
||||
let isOrgCalendarConnected = $state(false);
|
||||
let isLoadingGoogle = $state(false);
|
||||
@@ -133,56 +136,49 @@
|
||||
<title>Calendar - {data.org.name} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 h-full overflow-auto">
|
||||
<header class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-2xl font-bold text-light">Calendar</h1>
|
||||
{#if isOrgCalendarConnected}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-500/10 text-blue-400 rounded-lg"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
</svg>
|
||||
{orgCalendarName ?? "Google Calendar"}
|
||||
{#if isLoadingGoogle}
|
||||
<span class="animate-spin">⟳</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-green-500/10 text-green-400 rounded-lg hover:bg-green-500/20 transition-colors"
|
||||
onclick={subscribeToCalendar}
|
||||
title="Add to your Google Calendar"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Subscribe
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
||||
<!-- Header -->
|
||||
<header class="flex items-center gap-2 p-1">
|
||||
<Avatar name="Calendar" size="md" />
|
||||
<h1 class="flex-1 font-heading text-h1 text-white">Calendar</h1>
|
||||
{#if isOrgCalendarConnected}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-[32px] hover:bg-primary/20 transition-colors"
|
||||
onclick={subscribeToCalendar}
|
||||
title="Add to your Google Calendar"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>
|
||||
add
|
||||
</span>
|
||||
Subscribe
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 hover:bg-dark rounded-lg transition-colors"
|
||||
aria-label="More options"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>
|
||||
more_horiz
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="text-light/50 text-sm mb-4">
|
||||
View events from connected Google Calendar. Event creation coming soon.
|
||||
</p>
|
||||
|
||||
<Calendar
|
||||
events={allEvents}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
<!-- Calendar Grid -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<Calendar
|
||||
events={allEvents}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.documents');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { org } = await parent();
|
||||
const { supabase } = locals;
|
||||
|
||||
const { data: documents } = await supabase
|
||||
const { data: documents, error } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.order('name');
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to load documents', { error, data: { orgId: org.id } });
|
||||
}
|
||||
|
||||
log.debug('Documents loaded', { data: { count: documents?.length ?? 0 } });
|
||||
|
||||
return {
|
||||
documents: documents ?? []
|
||||
};
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { Button, Modal, Input } from "$lib/components/ui";
|
||||
import { FileTree, Editor } from "$lib/components/documents";
|
||||
import { buildDocumentTree } from "$lib/api/documents";
|
||||
import { FileBrowser } from "$lib/components/documents";
|
||||
import type { Document } from "$lib/supabase/types";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
@@ -17,326 +12,21 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let documents = $state(data.documents);
|
||||
let selectedDoc = $state<Document | null>(null);
|
||||
let showCreateModal = $state(false);
|
||||
let showEditModal = $state(false);
|
||||
let editingDoc = $state<Document | null>(null);
|
||||
let newDocName = $state("");
|
||||
let newDocType = $state<"folder" | "document">("document");
|
||||
let parentFolderId = $state<string | null>(null);
|
||||
let isEditing = $state(false);
|
||||
|
||||
const documentTree = $derived(buildDocumentTree(documents));
|
||||
|
||||
function handleSelect(doc: Document) {
|
||||
if (doc.type === "document") {
|
||||
selectedDoc = doc;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDoubleClick(doc: Document) {
|
||||
if (doc.type === "document") {
|
||||
// Open document in new window
|
||||
const url = `/${data.org.slug}/documents/${doc.id}`;
|
||||
window.open(url, "_blank", "width=900,height=700");
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd(folderId: string | null) {
|
||||
parentFolderId = folderId;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
async function handleMove(docId: string, newParentId: string | null) {
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
parent_id: newParentId,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", docId);
|
||||
|
||||
if (!error) {
|
||||
documents = documents.map((d) =>
|
||||
d.id === docId ? { ...d, parent_id: newParentId } : d,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newDocName.trim() || !data.user) return;
|
||||
|
||||
const { data: newDoc, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
org_id: data.org.id,
|
||||
name: newDocName,
|
||||
type: newDocType,
|
||||
parent_id: parentFolderId,
|
||||
created_by: data.user.id,
|
||||
content:
|
||||
newDocType === "document"
|
||||
? { type: "doc", content: [] }
|
||||
: null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && newDoc) {
|
||||
documents = [...documents, newDoc];
|
||||
if (newDocType === "document") {
|
||||
selectedDoc = newDoc;
|
||||
}
|
||||
}
|
||||
|
||||
showCreateModal = false;
|
||||
newDocName = "";
|
||||
newDocType = "document";
|
||||
parentFolderId = null;
|
||||
}
|
||||
|
||||
async function handleSave(content: unknown) {
|
||||
if (!selectedDoc) return;
|
||||
|
||||
await supabase
|
||||
.from("documents")
|
||||
.update({ content, updated_at: new Date().toISOString() })
|
||||
.eq("id", selectedDoc.id);
|
||||
|
||||
documents = documents.map((d) =>
|
||||
d.id === selectedDoc!.id ? { ...d, content } : d,
|
||||
);
|
||||
}
|
||||
|
||||
function handleEdit(doc: Document) {
|
||||
editingDoc = doc;
|
||||
newDocName = doc.name;
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (!editingDoc || !newDocName.trim()) return;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({ name: newDocName, updated_at: new Date().toISOString() })
|
||||
.eq("id", editingDoc.id);
|
||||
|
||||
if (!error) {
|
||||
documents = documents.map((d) =>
|
||||
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
|
||||
);
|
||||
if (selectedDoc?.id === editingDoc.id) {
|
||||
selectedDoc = { ...selectedDoc, name: newDocName };
|
||||
}
|
||||
}
|
||||
showEditModal = false;
|
||||
editingDoc = null;
|
||||
newDocName = "";
|
||||
}
|
||||
|
||||
async function handleDelete(doc: Document) {
|
||||
const itemType =
|
||||
doc.type === "folder" ? "folder and all its contents" : "document";
|
||||
if (!confirm(`Delete this ${itemType}?`)) return;
|
||||
|
||||
// If deleting a folder, delete all children first
|
||||
if (doc.type === "folder") {
|
||||
const childIds = documents
|
||||
.filter((d) => d.parent_id === doc.id)
|
||||
.map((d) => d.id);
|
||||
if (childIds.length > 0) {
|
||||
await supabase.from("documents").delete().in("id", childIds);
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("id", doc.id);
|
||||
|
||||
if (!error) {
|
||||
documents = documents.filter(
|
||||
(d) => d.id !== doc.id && d.parent_id !== doc.id,
|
||||
);
|
||||
if (selectedDoc?.id === doc.id) {
|
||||
selectedDoc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
documents = data.documents;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title
|
||||
>{selectedDoc ? `${selectedDoc.name} - ` : ""}Documents - {data.org
|
||||
.name} | Root</title
|
||||
>
|
||||
<title>Files - {data.org.name} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex h-full">
|
||||
<aside class="w-72 border-r border-light/10 flex flex-col">
|
||||
<div
|
||||
class="p-4 border-b border-light/10 flex items-center justify-between"
|
||||
>
|
||||
<h2 class="font-semibold text-light">Documents</h2>
|
||||
<Button size="sm" onclick={() => (showCreateModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
{#if documentTree.length === 0}
|
||||
<div class="text-center text-light/40 py-8 text-sm">
|
||||
<p>No documents yet</p>
|
||||
<p class="mt-1">Create your first document</p>
|
||||
</div>
|
||||
{:else}
|
||||
<FileTree
|
||||
items={documentTree}
|
||||
selectedId={selectedDoc?.id ?? null}
|
||||
onSelect={handleSelect}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onAdd={handleAdd}
|
||||
onMove={handleMove}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 overflow-hidden flex flex-col">
|
||||
{#if selectedDoc}
|
||||
<div
|
||||
class="flex items-center justify-between p-4 border-b border-light/10"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-light">
|
||||
{selectedDoc.name}
|
||||
</h2>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {isEditing
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-light/10 text-light hover:bg-light/20'}"
|
||||
onclick={() => (isEditing = !isEditing)}
|
||||
>
|
||||
{isEditing ? "Preview" : "Edit"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<Editor
|
||||
document={selectedDoc}
|
||||
onSave={handleSave}
|
||||
editable={isEditing}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-light/40">
|
||||
<div class="text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto mb-4 opacity-50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
</svg>
|
||||
<p>Select a document to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
<div class="h-full p-4 lg:p-5">
|
||||
<FileBrowser
|
||||
org={data.org}
|
||||
bind:documents
|
||||
currentFolderId={null}
|
||||
user={data.user}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => (showCreateModal = false)}
|
||||
title="Create New"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
|
||||
'document'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-light/20'}"
|
||||
onclick={() => (newDocType = "document")}
|
||||
>
|
||||
Document
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
|
||||
'folder'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-light/20'}"
|
||||
onclick={() => (newDocType = "folder")}
|
||||
>
|
||||
Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={newDocName}
|
||||
placeholder={newDocType === "folder"
|
||||
? "Folder name"
|
||||
: "Document name"}
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onclick={() => (showCreateModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleCreate} disabled={!newDocName.trim()}
|
||||
>Create</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => {
|
||||
showEditModal = false;
|
||||
editingDoc = null;
|
||||
newDocName = "";
|
||||
}}
|
||||
title="Rename"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={newDocName}
|
||||
placeholder="Enter new name"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
showEditModal = false;
|
||||
editingDoc = null;
|
||||
newDocName = "";
|
||||
}}>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleRename} disabled={!newDocName.trim()}
|
||||
>Save</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
31
src/routes/[orgSlug]/documents/[id]/+page.server.ts
Normal file
31
src/routes/[orgSlug]/documents/[id]/+page.server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.document');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals, params }) => {
|
||||
const { org } = await parent() as { org: { id: string; slug: string } };
|
||||
const { supabase } = locals;
|
||||
const { id } = params;
|
||||
|
||||
log.debug('Redirecting document by ID', { data: { id, orgId: org.id } });
|
||||
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('documents')
|
||||
.select('type')
|
||||
.eq('org_id', org.id)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (docError || !document) {
|
||||
log.error('Document not found', { error: docError, data: { id, orgId: org.id } });
|
||||
throw error(404, 'Document not found');
|
||||
}
|
||||
|
||||
if (document.type === 'folder') {
|
||||
throw redirect(302, `/${org.slug}/documents/folder/${id}`);
|
||||
}
|
||||
|
||||
throw redirect(302, `/${org.slug}/documents/file/${id}`);
|
||||
};
|
||||
9
src/routes/[orgSlug]/documents/[id]/+page.svelte
Normal file
9
src/routes/[orgSlug]/documents/[id]/+page.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- This route redirects to /folder/[id] or /file/[id] via +page.server.ts -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span
|
||||
class="material-symbols-rounded text-primary animate-spin"
|
||||
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 40;"
|
||||
>
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
39
src/routes/[orgSlug]/documents/file/[id]/+page.server.ts
Normal file
39
src/routes/[orgSlug]/documents/file/[id]/+page.server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.file');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals, params }) => {
|
||||
const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null };
|
||||
const { supabase } = locals;
|
||||
const { id } = params;
|
||||
|
||||
log.debug('Loading file by ID', { data: { id, orgId: org.id } });
|
||||
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (docError || !document) {
|
||||
log.error('File not found', { error: docError, data: { id, orgId: org.id } });
|
||||
throw error(404, 'File not found');
|
||||
}
|
||||
|
||||
if (document.type === 'folder') {
|
||||
throw redirect(302, `/${org.slug}/documents/folder/${id}`);
|
||||
}
|
||||
|
||||
const isKanban = document.type === 'kanban';
|
||||
|
||||
return {
|
||||
document,
|
||||
isKanban,
|
||||
isFolder: false,
|
||||
children: [],
|
||||
user
|
||||
};
|
||||
};
|
||||
572
src/routes/[orgSlug]/documents/file/[id]/+page.svelte
Normal file
572
src/routes/[orgSlug]/documents/file/[id]/+page.svelte
Normal file
@@ -0,0 +1,572 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy, onMount } from "svelte";
|
||||
import { Button, Modal, Input } from "$lib/components/ui";
|
||||
import { DocumentViewer } from "$lib/components/documents";
|
||||
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
|
||||
import {
|
||||
fetchBoardWithColumns,
|
||||
createColumn,
|
||||
moveCard,
|
||||
deleteCard,
|
||||
deleteColumn,
|
||||
subscribeToBoard,
|
||||
} from "$lib/api/kanban";
|
||||
import {
|
||||
getLockInfo,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
startHeartbeat,
|
||||
type LockInfo,
|
||||
} from "$lib/api/document-locks";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import type {
|
||||
RealtimeChannel,
|
||||
SupabaseClient,
|
||||
} from "@supabase/supabase-js";
|
||||
import type { Database, KanbanCard, Document } from "$lib/supabase/types";
|
||||
import type { BoardWithColumns } from "$lib/api/kanban";
|
||||
|
||||
const log = createLogger("page.file-viewer");
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
document: Document;
|
||||
isKanban: boolean;
|
||||
isFolder: boolean;
|
||||
children: any[];
|
||||
user: { id: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let isSaving = $state(false);
|
||||
|
||||
// Document lock state
|
||||
let lockInfo = $state<LockInfo>({
|
||||
isLocked: false,
|
||||
lockedBy: null,
|
||||
lockedByName: null,
|
||||
isOwnLock: false,
|
||||
});
|
||||
let hasLock = $state(false);
|
||||
let stopHeartbeat: (() => void) | null = null;
|
||||
|
||||
// Acquire lock for document editing (not for kanban)
|
||||
onMount(async () => {
|
||||
if (data.isKanban || !data.user) return;
|
||||
|
||||
// Check current lock status
|
||||
lockInfo = await getLockInfo(supabase, data.document.id, data.user.id);
|
||||
|
||||
if (lockInfo.isLocked && !lockInfo.isOwnLock) {
|
||||
// Someone else is editing
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to acquire lock
|
||||
const acquired = await acquireLock(
|
||||
supabase,
|
||||
data.document.id,
|
||||
data.user.id,
|
||||
);
|
||||
if (acquired) {
|
||||
hasLock = true;
|
||||
stopHeartbeat = startHeartbeat(
|
||||
supabase,
|
||||
data.document.id,
|
||||
data.user.id,
|
||||
);
|
||||
} else {
|
||||
// Refresh lock info to get who holds it
|
||||
lockInfo = await getLockInfo(
|
||||
supabase,
|
||||
data.document.id,
|
||||
data.user.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Kanban state
|
||||
let kanbanBoard = $state<BoardWithColumns | null>(null);
|
||||
let realtimeChannel = $state<RealtimeChannel | null>(null);
|
||||
let showCardModal = $state(false);
|
||||
let selectedCard = $state<KanbanCard | null>(null);
|
||||
let targetColumnId = $state<string | null>(null);
|
||||
let cardModalMode = $state<"edit" | "create">("edit");
|
||||
let showAddColumnModal = $state(false);
|
||||
let newColumnName = $state("");
|
||||
|
||||
async function handleSave(content: import("$lib/supabase/types").Json) {
|
||||
isSaving = true;
|
||||
try {
|
||||
await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
content,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", data.document.id);
|
||||
} catch (err) {
|
||||
log.error("Failed to save document", { error: err });
|
||||
toasts.error("Failed to save document");
|
||||
}
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
// Kanban functions
|
||||
async function loadKanbanBoard() {
|
||||
if (!data.isKanban) return;
|
||||
try {
|
||||
const content = data.document.content as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
const boardId = (content?.board_id as string) || data.document.id;
|
||||
|
||||
let board = await fetchBoardWithColumns(supabase, boardId).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (!board) {
|
||||
log.info("Auto-creating kanban_boards entry for document", {
|
||||
data: { boardId, docId: data.document.id },
|
||||
});
|
||||
|
||||
const { data: newBoard, error: createErr } = await supabase
|
||||
.from("kanban_boards")
|
||||
.insert({
|
||||
id: data.document.id,
|
||||
org_id: data.org.id,
|
||||
name: data.document.name,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createErr) {
|
||||
log.error("Failed to auto-create kanban board", {
|
||||
error: createErr,
|
||||
});
|
||||
toasts.error("Failed to load kanban board");
|
||||
return;
|
||||
}
|
||||
|
||||
await supabase.from("kanban_columns").insert([
|
||||
{ board_id: data.document.id, name: "To Do", position: 0 },
|
||||
{
|
||||
board_id: data.document.id,
|
||||
name: "In Progress",
|
||||
position: 1,
|
||||
},
|
||||
{ board_id: data.document.id, name: "Done", position: 2 },
|
||||
]);
|
||||
|
||||
await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
content: {
|
||||
type: "kanban",
|
||||
board_id: data.document.id,
|
||||
} as import("$lib/supabase/types").Json,
|
||||
})
|
||||
.eq("id", data.document.id);
|
||||
|
||||
board = await fetchBoardWithColumns(
|
||||
supabase,
|
||||
data.document.id,
|
||||
).catch(() => null);
|
||||
}
|
||||
|
||||
kanbanBoard = board;
|
||||
} catch (err) {
|
||||
log.error("Failed to load kanban board", { error: err });
|
||||
toasts.error("Failed to load kanban board");
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (data.isKanban) {
|
||||
loadKanbanBoard();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!kanbanBoard) return;
|
||||
|
||||
const channel = subscribeToBoard(
|
||||
supabase,
|
||||
kanbanBoard.id,
|
||||
() => loadKanbanBoard(),
|
||||
() => loadKanbanBoard(),
|
||||
);
|
||||
realtimeChannel = channel;
|
||||
|
||||
return () => {
|
||||
if (channel) {
|
||||
supabase.removeChannel(channel);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (realtimeChannel) {
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
}
|
||||
// Release document lock
|
||||
if (hasLock && data.user) {
|
||||
stopHeartbeat?.();
|
||||
releaseLock(supabase, data.document.id, data.user.id);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCardMove(
|
||||
cardId: string,
|
||||
toColumnId: string,
|
||||
toPosition: number,
|
||||
) {
|
||||
try {
|
||||
await moveCard(supabase, cardId, toColumnId, toPosition);
|
||||
} catch (err) {
|
||||
log.error("Failed to move card", { error: err });
|
||||
toasts.error("Failed to move card");
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(card: KanbanCard) {
|
||||
selectedCard = card;
|
||||
cardModalMode = "edit";
|
||||
showCardModal = true;
|
||||
}
|
||||
|
||||
function handleAddCard(columnId: string) {
|
||||
targetColumnId = columnId;
|
||||
selectedCard = null;
|
||||
cardModalMode = "create";
|
||||
showCardModal = true;
|
||||
}
|
||||
|
||||
async function handleAddColumn() {
|
||||
if (!kanbanBoard || !newColumnName.trim()) return;
|
||||
try {
|
||||
await createColumn(
|
||||
supabase,
|
||||
kanbanBoard.id,
|
||||
newColumnName,
|
||||
kanbanBoard.columns.length,
|
||||
);
|
||||
newColumnName = "";
|
||||
showAddColumnModal = false;
|
||||
await loadKanbanBoard();
|
||||
} catch (err) {
|
||||
log.error("Failed to add column", { error: err });
|
||||
toasts.error("Failed to add column");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteColumn(columnId: string) {
|
||||
if (!confirm("Delete this column and all its cards?")) return;
|
||||
try {
|
||||
await deleteColumn(supabase, columnId);
|
||||
await loadKanbanBoard();
|
||||
} catch (err) {
|
||||
log.error("Failed to delete column", { error: err });
|
||||
toasts.error("Failed to delete column");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCard(cardId: string) {
|
||||
try {
|
||||
await deleteCard(supabase, cardId);
|
||||
await loadKanbanBoard();
|
||||
} catch (err) {
|
||||
log.error("Failed to delete card", { error: err });
|
||||
toasts.error("Failed to delete card");
|
||||
}
|
||||
}
|
||||
|
||||
// JSON Import for kanban board
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let isImporting = $state(false);
|
||||
|
||||
function triggerImport() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
async function handleJsonImport(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || !kanbanBoard) return;
|
||||
|
||||
isImporting = true;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
|
||||
// Support two formats:
|
||||
// 1. Full board export: { columns: [{ name, cards: [{ title, description, ... }] }] }
|
||||
// 2. Flat card list: [{ title, description, column?, ... }]
|
||||
if (Array.isArray(json)) {
|
||||
// Flat card list — add all to first column
|
||||
const firstCol = kanbanBoard.columns[0];
|
||||
if (!firstCol) {
|
||||
toasts.error("No columns exist to import cards into");
|
||||
return;
|
||||
}
|
||||
let pos = firstCol.cards.length;
|
||||
for (const card of json) {
|
||||
await supabase.from("kanban_cards").insert({
|
||||
column_id: firstCol.id,
|
||||
title: card.title || "Untitled",
|
||||
description: card.description || null,
|
||||
priority: card.priority || null,
|
||||
due_date: card.due_date || null,
|
||||
position: pos++,
|
||||
created_by: data.user?.id ?? null,
|
||||
});
|
||||
}
|
||||
toasts.success(`Imported ${json.length} cards`);
|
||||
} else if (json.columns && Array.isArray(json.columns)) {
|
||||
// Full board format with columns
|
||||
let colPos = kanbanBoard.columns.length;
|
||||
for (const col of json.columns) {
|
||||
// Check if column already exists by name
|
||||
let targetCol = kanbanBoard.columns.find(
|
||||
(c) =>
|
||||
c.name.toLowerCase() ===
|
||||
(col.name || "").toLowerCase(),
|
||||
);
|
||||
|
||||
if (!targetCol) {
|
||||
const { data: newCol, error: colErr } = await supabase
|
||||
.from("kanban_columns")
|
||||
.insert({
|
||||
board_id: kanbanBoard.id,
|
||||
name: col.name || `Column ${colPos}`,
|
||||
position: colPos++,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (colErr || !newCol) continue;
|
||||
targetCol = { ...newCol, cards: [] };
|
||||
}
|
||||
|
||||
if (col.cards && Array.isArray(col.cards)) {
|
||||
let cardPos = targetCol.cards?.length ?? 0;
|
||||
for (const card of col.cards) {
|
||||
await supabase.from("kanban_cards").insert({
|
||||
column_id: targetCol.id,
|
||||
title: card.title || "Untitled",
|
||||
description: card.description || null,
|
||||
priority: card.priority || null,
|
||||
due_date: card.due_date || null,
|
||||
color: card.color || null,
|
||||
position: cardPos++,
|
||||
created_by: data.user?.id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalCards = json.columns.reduce(
|
||||
(sum: number, c: any) => sum + (c.cards?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
toasts.success(
|
||||
`Imported ${json.columns.length} columns, ${totalCards} cards`,
|
||||
);
|
||||
} else {
|
||||
toasts.error(
|
||||
"Unrecognized JSON format. Expected { columns: [...] } or [{ title, ... }]",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await loadKanbanBoard();
|
||||
} catch (err) {
|
||||
log.error("JSON import failed", { error: err });
|
||||
toasts.error("Failed to import JSON — check file format");
|
||||
} finally {
|
||||
isImporting = false;
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportJson() {
|
||||
if (!kanbanBoard) return;
|
||||
const exportData = {
|
||||
board: kanbanBoard.name,
|
||||
columns: kanbanBoard.columns.map((col) => ({
|
||||
name: col.name,
|
||||
cards: col.cards.map((card) => ({
|
||||
title: card.title,
|
||||
description: card.description,
|
||||
priority: card.priority,
|
||||
due_date: card.due_date,
|
||||
color: card.color,
|
||||
assignee_id: card.assignee_id,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${kanbanBoard.name || "board"}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toasts.success("Board exported as JSON");
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.document.name} - {data.org.name} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
||||
{#if data.isKanban}
|
||||
<!-- Kanban: needs its own header since DocumentViewer is for documents -->
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
bind:this={fileInput}
|
||||
onchange={handleJsonImport}
|
||||
/>
|
||||
<header class="flex items-center gap-2 p-1">
|
||||
<h1 class="flex-1 font-heading text-h1 text-white truncate">
|
||||
{data.document.name}
|
||||
</h1>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon="upload"
|
||||
onclick={triggerImport}
|
||||
loading={isImporting}
|
||||
>
|
||||
Import JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon="download"
|
||||
onclick={handleExportJson}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
<div class="h-full">
|
||||
{#if kanbanBoard}
|
||||
<KanbanBoard
|
||||
columns={kanbanBoard.columns}
|
||||
onCardClick={handleCardClick}
|
||||
onCardMove={handleCardMove}
|
||||
onAddCard={handleAddCard}
|
||||
onAddColumn={() => (showAddColumnModal = true)}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
canEdit={true}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<span
|
||||
class="material-symbols-rounded text-light/30 animate-spin mb-4"
|
||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
||||
>
|
||||
progress_activity
|
||||
</span>
|
||||
<p class="text-light/50">Loading board...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Document Editor: use shared DocumentViewer component -->
|
||||
<DocumentViewer
|
||||
document={data.document}
|
||||
onSave={handleSave}
|
||||
mode="edit"
|
||||
locked={lockInfo.isLocked && !lockInfo.isOwnLock}
|
||||
lockedByName={lockInfo.lockedByName}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Status Bar -->
|
||||
{#if isSaving}
|
||||
<div class="text-body-sm text-light/50">Saving...</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Kanban Card Detail Modal -->
|
||||
{#if showCardModal}
|
||||
<CardDetailModal
|
||||
isOpen={showCardModal}
|
||||
card={selectedCard}
|
||||
mode={cardModalMode}
|
||||
onClose={() => {
|
||||
showCardModal = false;
|
||||
selectedCard = null;
|
||||
targetColumnId = null;
|
||||
}}
|
||||
onUpdate={(updatedCard) => {
|
||||
if (kanbanBoard) {
|
||||
kanbanBoard = {
|
||||
...kanbanBoard,
|
||||
columns: kanbanBoard.columns.map((col) => ({
|
||||
...col,
|
||||
cards: col.cards.map((c) =>
|
||||
c.id === updatedCard.id ? updatedCard : c,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}}
|
||||
onDelete={(cardId) => handleDeleteCard(cardId)}
|
||||
columnId={targetColumnId ?? undefined}
|
||||
userId={data.user?.id}
|
||||
orgId={data.org.id}
|
||||
onCreate={(newCard) => {
|
||||
loadKanbanBoard();
|
||||
showCardModal = false;
|
||||
selectedCard = null;
|
||||
targetColumnId = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Add Column Modal -->
|
||||
<Modal
|
||||
isOpen={showAddColumnModal}
|
||||
onClose={() => {
|
||||
showAddColumnModal = false;
|
||||
newColumnName = "";
|
||||
}}
|
||||
title="Add Column"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
label="Column Name"
|
||||
bind:value={newColumnName}
|
||||
placeholder="e.g., To Do, In Progress, Done"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => (showAddColumnModal = false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={handleAddColumn} disabled={!newColumnName.trim()}>
|
||||
Add Column
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
43
src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts
Normal file
43
src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.folder');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals, params }) => {
|
||||
const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null };
|
||||
const { supabase } = locals;
|
||||
const { id } = params;
|
||||
|
||||
log.debug('Loading folder by ID', { data: { id, orgId: org.id } });
|
||||
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (docError || !document) {
|
||||
log.error('Folder not found', { error: docError, data: { id, orgId: org.id } });
|
||||
throw error(404, 'Folder not found');
|
||||
}
|
||||
|
||||
if (document.type !== 'folder') {
|
||||
log.error('Document is not a folder', { data: { id, type: document.type } });
|
||||
throw error(404, 'Not a folder');
|
||||
}
|
||||
|
||||
// Load all documents in this org (for breadcrumb building and file listing)
|
||||
const { data: allDocuments } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.order('name');
|
||||
|
||||
return {
|
||||
folder: document,
|
||||
documents: allDocuments ?? [],
|
||||
user
|
||||
};
|
||||
};
|
||||
34
src/routes/[orgSlug]/documents/folder/[id]/+page.svelte
Normal file
34
src/routes/[orgSlug]/documents/folder/[id]/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { FileBrowser } from "$lib/components/documents";
|
||||
import type { Document } from "$lib/supabase/types";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
folder: Document;
|
||||
documents: Document[];
|
||||
user: { id: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let documents = $state(data.documents);
|
||||
$effect(() => {
|
||||
documents = data.documents;
|
||||
});
|
||||
const currentFolderId = $derived(data.folder.id);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.folder.name} - {data.org.name} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-full p-4 lg:p-5">
|
||||
<FileBrowser
|
||||
org={data.org}
|
||||
bind:documents
|
||||
{currentFolderId}
|
||||
user={data.user}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.kanban');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { org } = await parent();
|
||||
const { supabase } = locals;
|
||||
|
||||
const { data: boards } = await supabase
|
||||
const { data: boards, error } = await supabase
|
||||
.from('kanban_boards')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.order('created_at');
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to load kanban boards', { error, data: { orgId: org.id } });
|
||||
}
|
||||
|
||||
return {
|
||||
boards: boards ?? []
|
||||
};
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { Button, Card, Modal, Input } from "$lib/components/ui";
|
||||
import { getContext, onDestroy } from "svelte";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Modal,
|
||||
Input,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Icon,
|
||||
} from "$lib/components/ui";
|
||||
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
|
||||
import {
|
||||
fetchBoardWithColumns,
|
||||
createBoard,
|
||||
moveCard,
|
||||
subscribeToBoard,
|
||||
} from "$lib/api/kanban";
|
||||
import type { RealtimeChannel } from "@supabase/supabase-js";
|
||||
import type {
|
||||
KanbanBoard as KanbanBoardType,
|
||||
KanbanCard,
|
||||
@@ -28,6 +38,9 @@
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let boards = $state(data.boards);
|
||||
$effect(() => {
|
||||
boards = data.boards;
|
||||
});
|
||||
let selectedBoard = $state<BoardWithColumns | null>(null);
|
||||
let showCreateBoardModal = $state(false);
|
||||
let showEditBoardModal = $state(false);
|
||||
@@ -35,15 +48,49 @@
|
||||
let selectedCard = $state<KanbanCard | null>(null);
|
||||
let newBoardName = $state("");
|
||||
let editBoardName = $state("");
|
||||
let newBoardVisibility = $state<"team" | "personal">("team");
|
||||
let editBoardVisibility = $state<"team" | "personal">("team");
|
||||
let targetColumnId = $state<string | null>(null);
|
||||
let cardModalMode = $state<"edit" | "create">("edit");
|
||||
let realtimeChannel = $state<RealtimeChannel | null>(null);
|
||||
|
||||
async function loadBoard(boardId: string) {
|
||||
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
|
||||
}
|
||||
|
||||
// Realtime subscription with proper cleanup
|
||||
$effect(() => {
|
||||
const board = selectedBoard;
|
||||
if (!board) return;
|
||||
|
||||
// Subscribe to realtime changes for this board
|
||||
const channel = subscribeToBoard(
|
||||
supabase,
|
||||
board.id,
|
||||
() => {
|
||||
// Column changed - reload board data
|
||||
loadBoard(board.id);
|
||||
},
|
||||
() => {
|
||||
// Card changed - reload board data
|
||||
loadBoard(board.id);
|
||||
},
|
||||
);
|
||||
realtimeChannel = channel;
|
||||
|
||||
// Cleanup function - unsubscribe when board changes or component unmounts
|
||||
return () => {
|
||||
if (channel) {
|
||||
supabase.removeChannel(channel);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Additional cleanup on component destroy
|
||||
onDestroy(() => {
|
||||
if (realtimeChannel) {
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCreateBoard() {
|
||||
if (!newBoardName.trim()) return;
|
||||
|
||||
@@ -58,8 +105,6 @@
|
||||
let editingBoardId = $state<string | null>(null);
|
||||
let showAddColumnModal = $state(false);
|
||||
let newColumnName = $state("");
|
||||
let sidebarCollapsed = $state(false);
|
||||
|
||||
function openEditBoardModal(board: KanbanBoardType) {
|
||||
editingBoardId = board.id;
|
||||
editBoardName = board.name;
|
||||
@@ -254,127 +299,43 @@
|
||||
>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex h-full">
|
||||
<aside
|
||||
class="{sidebarCollapsed
|
||||
? 'w-12'
|
||||
: 'w-64'} border-r border-light/10 flex flex-col transition-all duration-200"
|
||||
>
|
||||
<div
|
||||
class="p-2 border-b border-light/10 flex items-center {sidebarCollapsed
|
||||
? 'justify-center'
|
||||
: 'justify-between gap-2'}"
|
||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
||||
<!-- Header -->
|
||||
<header class="flex items-center gap-2 p-1">
|
||||
<Avatar name="Kanban" size="md" />
|
||||
<h1 class="flex-1 font-heading text-h1 text-white">Kanban</h1>
|
||||
<Button size="md" onclick={() => (showCreateBoardModal = true)}
|
||||
>+ New</Button
|
||||
>
|
||||
{#if !sidebarCollapsed}
|
||||
<h2 class="font-semibold text-light px-2">Boards</h2>
|
||||
<Button size="sm" onclick={() => (showCreateBoardModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</Button>
|
||||
{/if}
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-light/10 text-light/50 hover:text-light transition-colors"
|
||||
onclick={() => (sidebarCollapsed = !sidebarCollapsed)}
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform {sidebarCollapsed
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
<IconButton
|
||||
title="More options"
|
||||
onclick={() => selectedBoard && openEditBoardModal(selectedBoard)}
|
||||
>
|
||||
<Icon name="more_horiz" size={24} />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<!-- Board selector (compact) -->
|
||||
{#if boards.length > 1}
|
||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||
{#each boards as board}
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-[32px] text-sm font-body whitespace-nowrap transition-colors {selectedBoard?.id ===
|
||||
board.id
|
||||
? 'bg-primary text-night'
|
||||
: 'bg-dark text-light hover:bg-dark/80'}"
|
||||
onclick={() => loadBoard(board.id)}
|
||||
>
|
||||
<path d="m11 17-5-5 5-5M17 17l-5-5 5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
{board.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{#if boards.length === 0}
|
||||
<div class="text-center text-light/40 py-8 text-sm">
|
||||
<p>No boards yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each boards as board}
|
||||
<div
|
||||
class="group flex items-center gap-1 px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer {selectedBoard?.id ===
|
||||
board.id
|
||||
? 'bg-primary text-white'
|
||||
: 'text-light/70 hover:bg-light/5'}"
|
||||
onclick={() => loadBoard(board.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="flex-1 truncate">{board.name}</span>
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity"
|
||||
>
|
||||
<button
|
||||
class="p-1 rounded hover:bg-light/20"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditBoardModal(board);
|
||||
}}
|
||||
title="Rename"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1 rounded hover:bg-error/20 hover:text-error"
|
||||
onclick={(e) => handleDeleteBoard(e, board)}
|
||||
title="Delete"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="3,6 5,6 21,6" />
|
||||
<path
|
||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 overflow-hidden p-6">
|
||||
<!-- Kanban Board -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
{#if selectedBoard}
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-light">
|
||||
{selectedBoard.name}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<KanbanBoard
|
||||
columns={selectedBoard.columns}
|
||||
onCardClick={handleCardClick}
|
||||
@@ -384,25 +345,30 @@
|
||||
onDeleteCard={handleCardDelete}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
/>
|
||||
{:else}
|
||||
{:else if boards.length === 0}
|
||||
<div class="h-full flex items-center justify-center text-light/40">
|
||||
<div class="text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto mb-4 opacity-50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
<span
|
||||
class="material-symbols-rounded text-light/30 mb-4 block"
|
||||
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
<p>Select a board or create a new one</p>
|
||||
view_kanban
|
||||
</span>
|
||||
<p class="mb-4">Kanban boards are now managed in Files</p>
|
||||
<Button
|
||||
onclick={() =>
|
||||
(window.location.href = `/${data.org.slug}/documents`)}
|
||||
>
|
||||
Go to Files
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-light/40">
|
||||
<p>Select a board above</p>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
@@ -418,7 +384,7 @@
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="tertiary"
|
||||
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
|
||||
@@ -440,8 +406,9 @@
|
||||
placeholder="Board name"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" onclick={() => (showEditBoardModal = false)}
|
||||
>Cancel</Button
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => (showEditBoardModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleEditBoard} disabled={!editBoardName.trim()}
|
||||
>Save</Button
|
||||
@@ -462,8 +429,9 @@
|
||||
placeholder="e.g. To Do, In Progress, Done"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" onclick={() => (showAddColumnModal = false)}
|
||||
>Cancel</Button
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => (showAddColumnModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
onclick={handleCreateColumn}
|
||||
@@ -486,5 +454,6 @@
|
||||
mode={cardModalMode}
|
||||
columnId={targetColumnId ?? undefined}
|
||||
userId={data.user?.id}
|
||||
orgId={data.org.id}
|
||||
onCreate={handleCardCreated}
|
||||
/>
|
||||
|
||||
@@ -1,61 +1,64 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.settings');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { org, userRole } = await parent();
|
||||
const { org, userRole } = await parent() as { org: { id: string; slug: string }; userRole: string };
|
||||
|
||||
// Only admins and owners can access settings
|
||||
if (userRole !== 'owner' && userRole !== 'admin') {
|
||||
redirect(303, `/${(org as any).slug}`);
|
||||
redirect(303, `/${org.slug}`);
|
||||
}
|
||||
|
||||
const orgId = (org as any).id;
|
||||
const orgId = org.id;
|
||||
|
||||
// Get org members with profiles
|
||||
const { data: members } = await locals.supabase
|
||||
.from('org_members')
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
role,
|
||||
role_id,
|
||||
created_at,
|
||||
profiles:user_id (
|
||||
// Fetch all settings data in parallel
|
||||
const [membersResult, rolesResult, invitesResult, calendarResult] = await Promise.all([
|
||||
// Get org members with profiles
|
||||
locals.supabase
|
||||
.from('org_members')
|
||||
.select(`
|
||||
id,
|
||||
email,
|
||||
full_name,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('org_id', orgId);
|
||||
|
||||
// Get org roles
|
||||
const { data: roles } = await locals.supabase
|
||||
.from('org_roles')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.order('position');
|
||||
|
||||
// Get pending invites
|
||||
const { data: invites } = await locals.supabase
|
||||
.from('org_invites')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.is('accepted_at', null)
|
||||
.gt('expires_at', new Date().toISOString());
|
||||
|
||||
// Get org Google Calendar connection
|
||||
const { data: orgCalendar } = await locals.supabase
|
||||
.from('org_google_calendars')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.single();
|
||||
user_id,
|
||||
role,
|
||||
role_id,
|
||||
created_at,
|
||||
profiles:user_id (
|
||||
id,
|
||||
email,
|
||||
full_name,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('org_id', orgId),
|
||||
// Get org roles
|
||||
locals.supabase
|
||||
.from('org_roles')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.order('position'),
|
||||
// Get pending invites
|
||||
locals.supabase
|
||||
.from('org_invites')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.is('accepted_at', null)
|
||||
.gt('expires_at', new Date().toISOString()),
|
||||
// Get org Google Calendar connection
|
||||
locals.supabase
|
||||
.from('org_google_calendars')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.single()
|
||||
]);
|
||||
|
||||
return {
|
||||
members: members ?? [],
|
||||
roles: roles ?? [],
|
||||
invites: invites ?? [],
|
||||
orgCalendar,
|
||||
members: membersResult.data ?? [],
|
||||
roles: rolesResult.data ?? [],
|
||||
invites: invitesResult.data ?? [],
|
||||
orgCalendar: calendarResult.data,
|
||||
userRole
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { Button, Modal, Card, Input } from "$lib/components/ui";
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Card,
|
||||
Input,
|
||||
Select,
|
||||
Icon,
|
||||
Avatar,
|
||||
IconButton,
|
||||
} from "$lib/components/ui";
|
||||
import { SettingsGeneral } from "$lib/components/settings";
|
||||
import {
|
||||
extractCalendarId,
|
||||
getCalendarSubscribeUrl,
|
||||
} from "$lib/api/google-calendar";
|
||||
import { theme, PRESET_COLORS, type ThemeMode } from "$lib/stores/theme";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
|
||||
@@ -18,18 +28,20 @@
|
||||
calendar_name: string | null;
|
||||
}
|
||||
|
||||
interface ProfileData {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
role_id: string | null;
|
||||
created_at: string;
|
||||
profiles: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
profiles: ProfileData | ProfileData[] | null;
|
||||
}
|
||||
|
||||
interface OrgRole {
|
||||
@@ -55,7 +67,12 @@
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
org: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
user: { id: string; email?: string } | null;
|
||||
userRole: string;
|
||||
members: Member[];
|
||||
@@ -70,14 +87,16 @@
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
// Active tab
|
||||
let activeTab = $state<
|
||||
"general" | "members" | "roles" | "integrations" | "appearance"
|
||||
>("general");
|
||||
let activeTab = $state<"general" | "members" | "roles" | "integrations">(
|
||||
"general",
|
||||
);
|
||||
|
||||
// General settings state
|
||||
let orgName = $state(data.org.name);
|
||||
let orgSlug = $state(data.org.slug);
|
||||
let isSavingGeneral = $state(false);
|
||||
const tabs: { id: typeof activeTab; label: string }[] = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "members", label: "Members" },
|
||||
{ id: "roles", label: "Roles" },
|
||||
{ id: "integrations", label: "Integrations" },
|
||||
];
|
||||
|
||||
// Members state
|
||||
let members = $state<Member[]>(data.members as Member[]);
|
||||
@@ -176,18 +195,45 @@
|
||||
}
|
||||
});
|
||||
|
||||
// General settings functions
|
||||
async function saveGeneralSettings() {
|
||||
isSavingGeneral = true;
|
||||
async function deleteOrganization() {
|
||||
if (!isOwner) return;
|
||||
const confirmText = prompt(
|
||||
`Type "${data.org.name}" to confirm deletion:`,
|
||||
);
|
||||
if (confirmText !== data.org.name) return;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.update({ name: orgName, slug: orgSlug })
|
||||
.delete()
|
||||
.eq("id", data.org.id);
|
||||
|
||||
if (!error && orgSlug !== data.org.slug) {
|
||||
window.location.href = `/${orgSlug}/settings`;
|
||||
if (error) {
|
||||
toasts.error("Failed to delete organization.");
|
||||
return;
|
||||
}
|
||||
isSavingGeneral = false;
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
async function leaveOrganization() {
|
||||
if (isOwner) {
|
||||
toasts.error(
|
||||
"Owners cannot leave. Transfer ownership first or delete the organization.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Are you sure you want to leave ${data.org.name}?`))
|
||||
return;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("org_members")
|
||||
.delete()
|
||||
.eq("org_id", data.org.id)
|
||||
.eq("user_id", data.user!.id);
|
||||
|
||||
if (error) {
|
||||
toasts.error("Failed to leave organization.");
|
||||
return;
|
||||
}
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
// Member functions
|
||||
@@ -219,13 +265,12 @@
|
||||
.single();
|
||||
|
||||
if (!error && invite) {
|
||||
// Remove old invite from UI if exists
|
||||
invites = invites.filter((i) => i.email !== email);
|
||||
invites = [...invites, invite as Invite];
|
||||
inviteEmail = "";
|
||||
showInviteModal = false;
|
||||
} else if (error) {
|
||||
alert("Failed to send invite: " + error.message);
|
||||
toasts.error("Failed to send invite: " + error.message);
|
||||
}
|
||||
isSendingInvite = false;
|
||||
}
|
||||
@@ -243,11 +288,15 @@
|
||||
|
||||
async function updateMemberRole() {
|
||||
if (!selectedMember) return;
|
||||
await supabase
|
||||
const { error } = await supabase
|
||||
.from("org_members")
|
||||
.update({ role: selectedMemberRole })
|
||||
.eq("id", selectedMember.id);
|
||||
|
||||
if (error) {
|
||||
toasts.error("Failed to update role.");
|
||||
return;
|
||||
}
|
||||
members = members.map((m) =>
|
||||
m.id === selectedMember!.id
|
||||
? { ...m, role: selectedMemberRole }
|
||||
@@ -258,14 +307,23 @@
|
||||
|
||||
async function removeMember() {
|
||||
if (!selectedMember) return;
|
||||
const rp = selectedMember.profiles;
|
||||
const prof = Array.isArray(rp) ? rp[0] : rp;
|
||||
if (
|
||||
!confirm(
|
||||
`Remove ${selectedMember.profiles.full_name || selectedMember.profiles.email} from the organization?`,
|
||||
`Remove ${prof?.full_name || prof?.email || "this member"} from the organization?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
await supabase.from("org_members").delete().eq("id", selectedMember.id);
|
||||
const { error } = await supabase
|
||||
.from("org_members")
|
||||
.delete()
|
||||
.eq("id", selectedMember.id);
|
||||
if (error) {
|
||||
toasts.error("Failed to remove member.");
|
||||
return;
|
||||
}
|
||||
members = members.filter((m) => m.id !== selectedMember!.id);
|
||||
showMemberModal = false;
|
||||
}
|
||||
@@ -348,7 +406,14 @@
|
||||
)
|
||||
return;
|
||||
|
||||
await supabase.from("org_roles").delete().eq("id", role.id);
|
||||
const { error } = await supabase
|
||||
.from("org_roles")
|
||||
.delete()
|
||||
.eq("id", role.id);
|
||||
if (error) {
|
||||
toasts.error("Failed to delete role.");
|
||||
return;
|
||||
}
|
||||
roles = roles.filter((r) => r.id !== role.id);
|
||||
}
|
||||
|
||||
@@ -417,192 +482,56 @@
|
||||
|
||||
async function disconnectOrgCalendar() {
|
||||
if (!confirm("Disconnect Google Calendar?")) return;
|
||||
await supabase
|
||||
const { error } = await supabase
|
||||
.from("org_google_calendars")
|
||||
.delete()
|
||||
.eq("org_id", data.org.id);
|
||||
if (error) {
|
||||
toasts.error("Failed to disconnect calendar.");
|
||||
return;
|
||||
}
|
||||
orgCalendar = null;
|
||||
}
|
||||
|
||||
async function deleteOrganization() {
|
||||
if (!isOwner) return;
|
||||
const confirmText = prompt(
|
||||
`Type "${data.org.name}" to confirm deletion:`,
|
||||
);
|
||||
if (confirmText !== data.org.name) return;
|
||||
|
||||
await supabase.from("organizations").delete().eq("id", data.org.id);
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
async function leaveOrganization() {
|
||||
if (isOwner) {
|
||||
alert(
|
||||
"Owners cannot leave. Transfer ownership first or delete the organization.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Are you sure you want to leave ${data.org.name}?`))
|
||||
return;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("org_members")
|
||||
.delete()
|
||||
.eq("org_id", data.org.id)
|
||||
.eq("user_id", data.user?.id);
|
||||
|
||||
if (!error) {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - {data.org.name} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 h-full overflow-auto">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-light">Settings</h1>
|
||||
<p class="text-light/50 mt-1">Manage {data.org.name}</p>
|
||||
</header>
|
||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4 overflow-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<header class="flex flex-wrap items-center gap-2 p-1 rounded-[32px]">
|
||||
<Avatar name="Settings" size="md" />
|
||||
<h1 class="flex-1 font-heading text-h1 text-white">Settings</h1>
|
||||
<IconButton title="More options">
|
||||
<Icon name="more_horiz" size={24} />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 mb-6 border-b border-light/10">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||
'general'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-light/50 hover:text-light'}"
|
||||
onclick={() => (activeTab = "general")}>General</button
|
||||
>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||
'members'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-light/50 hover:text-light'}"
|
||||
onclick={() => (activeTab = "members")}>Members</button
|
||||
>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||
'roles'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-light/50 hover:text-light'}"
|
||||
onclick={() => (activeTab = "roles")}>Roles</button
|
||||
>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||
'integrations'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-light/50 hover:text-light'}"
|
||||
onclick={() => (activeTab = "integrations")}>Integrations</button
|
||||
>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||
'appearance'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-light/50 hover:text-light'}"
|
||||
onclick={() => (activeTab = "appearance")}>Appearance</button
|
||||
>
|
||||
<!-- Pill Tab Navigation -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{#each tabs as tab}
|
||||
<Button
|
||||
variant={activeTab === tab.id ? "primary" : "secondary"}
|
||||
size="md"
|
||||
onclick={() => (activeTab = tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Tab -->
|
||||
{#if activeTab === "general"}
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-semibold text-light mb-4">
|
||||
Organization Details
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="org-name"
|
||||
class="block text-sm font-medium text-light mb-1"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
id="org-name"
|
||||
type="text"
|
||||
bind:value={orgName}
|
||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="org-slug"
|
||||
class="block text-sm font-medium text-light mb-1"
|
||||
>URL Slug</label
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-light/40 text-sm"
|
||||
>yoursite.com/</span
|
||||
>
|
||||
<input
|
||||
id="org-slug"
|
||||
type="text"
|
||||
bind:value={orgSlug}
|
||||
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-light font-mono text-sm focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-light/40 mt-1">
|
||||
Changing the slug will update all URLs for this
|
||||
organization.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pt-2">
|
||||
<Button
|
||||
onclick={saveGeneralSettings}
|
||||
loading={isSavingGeneral}>Save Changes</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{#if !isOwner}
|
||||
<Card>
|
||||
<div class="p-6 border-l-4 border-warning">
|
||||
<h2 class="text-lg font-semibold text-warning">
|
||||
Leave Organization
|
||||
</h2>
|
||||
<p class="text-sm text-light/50 mt-1">
|
||||
Leave this organization. You will need to be
|
||||
re-invited to rejoin.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={leaveOrganization}
|
||||
>Leave {data.org.name}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
<Card>
|
||||
<div class="p-6 border-l-4 border-error">
|
||||
<h2 class="text-lg font-semibold text-error">
|
||||
Danger Zone
|
||||
</h2>
|
||||
<p class="text-sm text-light/50 mt-1">
|
||||
Permanently delete this organization and all its
|
||||
data.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<Button
|
||||
variant="danger"
|
||||
onclick={deleteOrganization}
|
||||
>Delete Organization</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
<SettingsGeneral
|
||||
{supabase}
|
||||
org={data.org}
|
||||
{isOwner}
|
||||
onLeave={leaveOrganization}
|
||||
onDelete={deleteOrganization}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Members Tab -->
|
||||
@@ -654,18 +583,20 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-xs text-light/50 hover:text-light"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/invite/${invite.token}`,
|
||||
)}>Copy Link</button
|
||||
)}>Copy Link</Button
|
||||
>
|
||||
<button
|
||||
class="text-xs text-error hover:text-error/80"
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
cancelInvite(invite.id)}
|
||||
>Cancel</button
|
||||
>Cancel</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -679,7 +610,10 @@
|
||||
<Card>
|
||||
<div class="divide-y divide-light/10">
|
||||
{#each members as member}
|
||||
{@const profile = member.profiles}
|
||||
{@const rawProfile = member.profiles}
|
||||
{@const profile = Array.isArray(rawProfile)
|
||||
? rawProfile[0]
|
||||
: rawProfile}
|
||||
<div
|
||||
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
|
||||
>
|
||||
@@ -717,10 +651,11 @@
|
||||
)?.color ?? '#6366f1'}">{member.role}</span
|
||||
>
|
||||
{#if member.user_id !== data.user?.id && member.role !== "owner"}
|
||||
<button
|
||||
class="text-sm text-light/50 hover:text-light"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={() => openMemberModal(member)}
|
||||
>Edit</button
|
||||
>Edit</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -741,16 +676,7 @@
|
||||
Create custom roles with specific permissions.
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={() => openRoleModal()}>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<Button onclick={() => openRoleModal()} icon="add">
|
||||
Create Role
|
||||
</Button>
|
||||
</div>
|
||||
@@ -783,17 +709,19 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !role.is_system || role.name !== "Owner"}
|
||||
<button
|
||||
class="text-sm text-light/50 hover:text-light"
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={() => openRoleModal(role)}
|
||||
>Edit</button
|
||||
>Edit</Button
|
||||
>
|
||||
{/if}
|
||||
{#if !role.is_system}
|
||||
<button
|
||||
class="text-sm text-error/70 hover:text-error"
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={() => deleteRole(role)}
|
||||
>Delete</button
|
||||
>Delete</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -977,198 +905,6 @@
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Appearance Tab -->
|
||||
{#if activeTab === "appearance"}
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-semibold text-light mb-4">Theme</h2>
|
||||
<p class="text-sm text-light/50 mb-6">
|
||||
Customize the look and feel of your workspace.
|
||||
</p>
|
||||
|
||||
<!-- Mode Selector -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-light mb-3"
|
||||
>Mode</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
{#each ["dark", "light", "system"] as mode}
|
||||
<button
|
||||
class="flex-1 px-4 py-3 rounded-lg border transition-all {$theme.mode ===
|
||||
mode
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-light/20 text-light/60 hover:border-light/40'}"
|
||||
onclick={() =>
|
||||
theme.setMode(mode as ThemeMode)}
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-1"
|
||||
>
|
||||
{#if mode === "dark"}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if mode === "light"}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="1"
|
||||
x2="12"
|
||||
y2="3"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="21"
|
||||
x2="12"
|
||||
y2="23"
|
||||
/>
|
||||
<line
|
||||
x1="4.22"
|
||||
y1="4.22"
|
||||
x2="5.64"
|
||||
y2="5.64"
|
||||
/>
|
||||
<line
|
||||
x1="18.36"
|
||||
y1="18.36"
|
||||
x2="19.78"
|
||||
y2="19.78"
|
||||
/>
|
||||
<line
|
||||
x1="1"
|
||||
y1="12"
|
||||
x2="3"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="21"
|
||||
y1="12"
|
||||
x2="23"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="4.22"
|
||||
y1="19.78"
|
||||
x2="5.64"
|
||||
y2="18.36"
|
||||
/>
|
||||
<line
|
||||
x1="18.36"
|
||||
y1="5.64"
|
||||
x2="19.78"
|
||||
y2="4.22"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="3"
|
||||
width="20"
|
||||
height="14"
|
||||
rx="2"
|
||||
/>
|
||||
<line
|
||||
x1="8"
|
||||
y1="21"
|
||||
x2="16"
|
||||
y2="21"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="17"
|
||||
x2="12"
|
||||
y2="21"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="text-xs capitalize"
|
||||
>{mode}</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accent Color -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-light mb-3"
|
||||
>Accent Color</label
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
{#each PRESET_COLORS as color}
|
||||
<button
|
||||
class="group relative h-12 rounded-lg transition-all {$theme.primaryColor ===
|
||||
color.primary
|
||||
? 'ring-2 ring-offset-2 ring-offset-dark ring-white'
|
||||
: 'hover:scale-105'}"
|
||||
style="background-color: {color.primary}"
|
||||
onclick={() =>
|
||||
theme.setPrimaryColor(color.primary)}
|
||||
title={color.name}
|
||||
>
|
||||
{#if $theme.primaryColor === color.primary}
|
||||
<svg
|
||||
class="absolute inset-0 m-auto w-5 h-5 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-light/40 mt-3">
|
||||
Selected: {PRESET_COLORS.find(
|
||||
(c) => c.primary === $theme.primaryColor,
|
||||
)?.name || "Custom"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-semibold text-light mb-2">
|
||||
Reset Theme
|
||||
</h2>
|
||||
<p class="text-sm text-light/50 mb-4">
|
||||
Reset to the default theme settings.
|
||||
</p>
|
||||
<Button variant="secondary" onclick={() => theme.reset()}>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Invite Member Modal -->
|
||||
@@ -1178,44 +914,34 @@
|
||||
title="Invite Member"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="invite-email"
|
||||
class="block text-sm font-medium text-light mb-1"
|
||||
>Email address</label
|
||||
>
|
||||
<input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
bind:value={inviteEmail}
|
||||
placeholder="colleague@example.com"
|
||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="invite-role"
|
||||
class="block text-sm font-medium text-light mb-1">Role</label
|
||||
>
|
||||
<select
|
||||
id="invite-role"
|
||||
bind:value={inviteRole}
|
||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="viewer">Viewer - Can view content</option>
|
||||
<option value="commenter"
|
||||
>Commenter - Can view and comment</option
|
||||
>
|
||||
<option value="editor"
|
||||
>Editor - Can create and edit content</option
|
||||
>
|
||||
<option value="admin"
|
||||
>Admin - Can manage members and settings</option
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
type="email"
|
||||
label="Email address"
|
||||
bind:value={inviteEmail}
|
||||
placeholder="colleague@example.com"
|
||||
/>
|
||||
<Select
|
||||
label="Role"
|
||||
bind:value={inviteRole}
|
||||
placeholder=""
|
||||
options={[
|
||||
{ value: "viewer", label: "Viewer - Can view content" },
|
||||
{
|
||||
value: "commenter",
|
||||
label: "Commenter - Can view and comment",
|
||||
},
|
||||
{
|
||||
value: "editor",
|
||||
label: "Editor - Can create and edit content",
|
||||
},
|
||||
{
|
||||
value: "admin",
|
||||
label: "Admin - Can manage members and settings",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onclick={() => (showInviteModal = false)}
|
||||
<Button variant="tertiary" onclick={() => (showInviteModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
@@ -1234,48 +960,44 @@
|
||||
title="Edit Member"
|
||||
>
|
||||
{#if selectedMember}
|
||||
{@const rawP = selectedMember.profiles}
|
||||
{@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||
>
|
||||
{(selectedMember.profiles.full_name ||
|
||||
selectedMember.profiles.email ||
|
||||
{(memberProfile?.full_name ||
|
||||
memberProfile?.email ||
|
||||
"?")[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-light font-medium">
|
||||
{selectedMember.profiles.full_name || "No name"}
|
||||
{memberProfile?.full_name || "No name"}
|
||||
</p>
|
||||
<p class="text-sm text-light/50">
|
||||
{selectedMember.profiles.email}
|
||||
{memberProfile?.email || "No email"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="member-role"
|
||||
class="block text-sm font-medium text-light mb-1"
|
||||
>Role</label
|
||||
>
|
||||
<select
|
||||
id="member-role"
|
||||
bind:value={selectedMemberRole}
|
||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="commenter">Commenter</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<Select
|
||||
label="Role"
|
||||
bind:value={selectedMemberRole}
|
||||
placeholder=""
|
||||
options={[
|
||||
{ value: "viewer", label: "Viewer" },
|
||||
{ value: "commenter", label: "Commenter" },
|
||||
{ value: "editor", label: "Editor" },
|
||||
{ value: "admin", label: "Admin" },
|
||||
]}
|
||||
/>
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<Button variant="danger" onclick={removeMember}
|
||||
>Remove from Org</Button
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="tertiary"
|
||||
onclick={() => (showMemberModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button onclick={updateMemberRole}>Save</Button>
|
||||
@@ -1292,20 +1014,12 @@
|
||||
title={editingRole ? "Edit Role" : "Create Role"}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="role-name"
|
||||
class="block text-sm font-medium text-light mb-1">Name</label
|
||||
>
|
||||
<input
|
||||
id="role-name"
|
||||
type="text"
|
||||
bind:value={newRoleName}
|
||||
placeholder="e.g., Moderator"
|
||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
|
||||
disabled={editingRole?.is_system}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={newRoleName}
|
||||
placeholder="e.g., Moderator"
|
||||
disabled={editingRole?.is_system}
|
||||
/>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-light mb-2"
|
||||
>Color</label
|
||||
@@ -1357,7 +1071,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onclick={() => (showRoleModal = false)}
|
||||
<Button variant="tertiary" onclick={() => (showRoleModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
@@ -1412,8 +1126,9 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onclick={() => (showConnectModal = false)}
|
||||
>Cancel</Button
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => (showConnectModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
onclick={handleSaveOrgCalendar}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { GOOGLE_API_KEY } from '$env/static/private';
|
||||
import { fetchPublicCalendarEvents } from '$lib/api/google-calendar';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('api:google-calendar');
|
||||
|
||||
// Fetch events from a public Google Calendar
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const orgId = url.searchParams.get('org_id');
|
||||
|
||||
@@ -11,6 +13,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
return json({ error: 'org_id required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Auth check — must be logged in and a member of this org
|
||||
const { session, user } = await locals.safeGetSession();
|
||||
if (!session || !user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data: membership } = await locals.supabase
|
||||
.from('org_members')
|
||||
.select('id')
|
||||
.eq('org_id', orgId)
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (!membership) {
|
||||
return json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!GOOGLE_API_KEY) {
|
||||
return json({ error: 'Google API key not configured' }, { status: 500 });
|
||||
}
|
||||
@@ -24,7 +43,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
.single();
|
||||
|
||||
if (dbError) {
|
||||
console.error('DB error fetching calendar:', dbError);
|
||||
log.error('DB error fetching calendar', { data: { orgId }, error: dbError });
|
||||
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -32,7 +51,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
|
||||
}
|
||||
|
||||
console.log('Fetching events for calendar:', (orgCal as any).calendar_id);
|
||||
log.debug('Fetching events for calendar', { data: { calendarId: orgCal.calendar_id } });
|
||||
|
||||
// Fetch events for the next 3 months
|
||||
const now = new Date();
|
||||
@@ -40,21 +59,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0);
|
||||
|
||||
const events = await fetchPublicCalendarEvents(
|
||||
(orgCal as any).calendar_id,
|
||||
orgCal.calendar_id,
|
||||
GOOGLE_API_KEY,
|
||||
timeMin,
|
||||
timeMax
|
||||
);
|
||||
|
||||
console.log('Fetched', events.length, 'events');
|
||||
log.debug('Fetched events', { data: { count: events.length } });
|
||||
|
||||
return json({
|
||||
events,
|
||||
calendar_id: (orgCal as any).calendar_id,
|
||||
calendar_name: (orgCal as any).calendar_name
|
||||
calendar_id: orgCal.calendar_id,
|
||||
calendar_name: orgCal.calendar_name
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch calendar events:', err);
|
||||
log.error('Failed to fetch calendar events', { data: { orgId }, error: err });
|
||||
return json({ error: 'Failed to fetch events. Make sure the calendar is public.', events: [] }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
function safeRedirect(target: string): string {
|
||||
if (target.startsWith('/') && !target.startsWith('//')) return target;
|
||||
return '/';
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const code = url.searchParams.get('code');
|
||||
const next = url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/';
|
||||
const next = safeRedirect(url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/');
|
||||
|
||||
if (code) {
|
||||
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);
|
||||
|
||||
@@ -21,10 +21,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
};
|
||||
}
|
||||
|
||||
const inv = invite as any;
|
||||
|
||||
// Check if invite is expired
|
||||
if (new Date(inv.expires_at) < new Date()) {
|
||||
if (invite.expires_at && new Date(invite.expires_at) < new Date()) {
|
||||
return {
|
||||
error: 'This invite has expired',
|
||||
token
|
||||
@@ -36,10 +34,10 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
|
||||
return {
|
||||
invite: {
|
||||
id: inv.id,
|
||||
email: inv.email,
|
||||
role: inv.role,
|
||||
org: inv.organizations
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
org: (invite as any).organizations // join not typed
|
||||
},
|
||||
user,
|
||||
token
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
org_id: data.invite.org.id,
|
||||
user_id: data.user.id,
|
||||
role: data.invite.role,
|
||||
joined_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
@@ -84,7 +85,7 @@
|
||||
function goToSignup() {
|
||||
const returnUrl = `/invite/${data.token}`;
|
||||
goto(
|
||||
`/signup?redirect=${encodeURIComponent(returnUrl)}&email=${encodeURIComponent(data.invite?.email || "")}`,
|
||||
`/login?tab=signup&redirect=${encodeURIComponent(returnUrl)}&email=${encodeURIComponent(data.invite?.email || "")}`,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
@@ -166,7 +167,7 @@
|
||||
</div>
|
||||
<p class="text-light/40 text-xs mt-3">
|
||||
Wrong account? <a
|
||||
href="/logout"
|
||||
href="/auth/logout"
|
||||
class="text-primary hover:underline">Sign out</a
|
||||
>
|
||||
</p>
|
||||
@@ -177,7 +178,7 @@
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button onclick={goToLogin}>Sign In</Button>
|
||||
<Button onclick={goToSignup} variant="ghost"
|
||||
<Button onclick={goToSignup} variant="tertiary"
|
||||
>Create Account</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
@@ -26,8 +27,26 @@
|
||||
/* Typography - Figma Fonts */
|
||||
--font-heading: 'Tilt Warp', sans-serif;
|
||||
--font-body: 'Work Sans', sans-serif;
|
||||
--font-input: 'Inter', sans-serif;
|
||||
--font-sans: 'Work Sans', system-ui, -apple-system, sans-serif;
|
||||
|
||||
/* Font Sizes - Figma Text Styles (--text-* → text-* utilities) */
|
||||
/* Headings (heading font) */
|
||||
--text-h1: 32px;
|
||||
--text-h2: 28px;
|
||||
--text-h3: 24px;
|
||||
--text-h4: 20px;
|
||||
--text-h5: 16px;
|
||||
--text-h6: 14px;
|
||||
/* Button text (heading font) */
|
||||
--text-btn-lg: 20px;
|
||||
--text-btn-md: 16px;
|
||||
--text-btn-sm: 14px;
|
||||
/* Body text (body font) */
|
||||
--text-body: 16px;
|
||||
--text-body-md: 14px;
|
||||
--text-body-sm: 12px;
|
||||
|
||||
/* Border Radius - Figma Design */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 16px;
|
||||
@@ -37,127 +56,44 @@
|
||||
--radius-circle: 128px;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
html, body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-light);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* Base layer — element defaults via Tailwind utilities */
|
||||
@layer base {
|
||||
html, body {
|
||||
@apply bg-background text-light font-body antialiased;
|
||||
}
|
||||
|
||||
h1 { @apply font-heading font-normal text-h1 leading-normal; }
|
||||
h2 { @apply font-heading font-normal text-h2 leading-normal; }
|
||||
h3 { @apply font-heading font-normal text-h3 leading-normal; }
|
||||
h4 { @apply font-heading font-normal text-h4 leading-normal; }
|
||||
h5 { @apply font-heading font-normal text-h5 leading-normal; }
|
||||
h6 { @apply font-heading font-normal text-h6 leading-normal; }
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 400;
|
||||
}
|
||||
/* Scrollbar — no Tailwind equivalent for pseudo-elements */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { @apply bg-night rounded-pill; }
|
||||
::-webkit-scrollbar-thumb:hover { @apply bg-dark; }
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
/* Focus & Selection — pseudo-elements require raw CSS */
|
||||
:focus-visible { @apply outline-2 outline-primary outline-offset-2; }
|
||||
::selection { @apply text-light; background-color: rgba(0, 163, 224, 0.3); }
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-night);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-dark);
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: rgba(0, 163, 224, 0.3);
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
/* Prose/Markdown styles */
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
font-weight: 700;
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
.prose code {
|
||||
background: var(--color-night);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: var(--color-night);
|
||||
padding: 1em;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
padding-left: 1em;
|
||||
margin: 0.5em 0;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prose ul, .prose ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.prose h1, .prose h2, .prose h3, .prose h4 {
|
||||
color: var(--color-light);
|
||||
margin: 0.75em 0 0.5em;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-dark);
|
||||
margin: 1em 0;
|
||||
/* Prose/Markdown styles — used by the TipTap editor */
|
||||
@layer components {
|
||||
.prose { @apply leading-relaxed; }
|
||||
.prose p { @apply my-2; }
|
||||
.prose strong { @apply font-bold text-light; }
|
||||
.prose code { @apply bg-night px-1.5 py-0.5 rounded text-primary text-[0.9em]; font-family: 'Consolas', 'Monaco', monospace; }
|
||||
.prose pre { @apply bg-night p-4 rounded-sm overflow-x-auto my-2; }
|
||||
.prose pre code { @apply bg-transparent p-0 text-light; }
|
||||
.prose blockquote { @apply border-l-3 border-primary pl-4 my-2 text-text-muted italic; }
|
||||
.prose ul, .prose ol { @apply pl-6 my-2; }
|
||||
.prose ul { @apply list-disc; }
|
||||
.prose ol { @apply list-decimal; }
|
||||
.prose li { @apply my-1; }
|
||||
.prose h1, .prose h2, .prose h3, .prose h4 { @apply text-light font-heading; margin: 0.75em 0 0.5em; }
|
||||
.prose a { @apply text-primary underline; }
|
||||
.prose hr { @apply border-t border-dark my-4; }
|
||||
}
|
||||
|
||||
@@ -4,17 +4,29 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
let email = $state("");
|
||||
let email = $state($page.url.searchParams.get("email") || "");
|
||||
let password = $state("");
|
||||
let isLoading = $state(false);
|
||||
let error = $state("");
|
||||
let mode = $state<"login" | "signup">("login");
|
||||
let signupSuccess = $state(false);
|
||||
let mode = $state<"login" | "signup">(
|
||||
($page.url.searchParams.get("tab") as "login" | "signup") || "login",
|
||||
);
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
// Get redirect URL from query params (for invite flow)
|
||||
const redirectUrl = $derived($page.url.searchParams.get("redirect") || "/");
|
||||
|
||||
// Show error from callback (e.g. OAuth failure)
|
||||
const callbackError = $page.url.searchParams.get("error");
|
||||
if (callbackError) {
|
||||
error =
|
||||
callbackError === "auth_callback_error"
|
||||
? "Authentication failed. Please try again."
|
||||
: callbackError;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !password) {
|
||||
error = "Please fill in all fields";
|
||||
@@ -32,17 +44,24 @@
|
||||
password,
|
||||
});
|
||||
if (authError) throw authError;
|
||||
goto(redirectUrl);
|
||||
} else {
|
||||
const { error: authError } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
const { data: signUpData, error: authError } =
|
||||
await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirectUrl)}`,
|
||||
},
|
||||
});
|
||||
if (authError) throw authError;
|
||||
// If email confirmation is required, session will be null
|
||||
if (signUpData.session) {
|
||||
goto(redirectUrl);
|
||||
} else {
|
||||
signupSuccess = true;
|
||||
}
|
||||
}
|
||||
goto(redirectUrl);
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : "An error occurred";
|
||||
} finally {
|
||||
@@ -79,97 +98,129 @@
|
||||
</div>
|
||||
|
||||
<Card variant="elevated" padding="lg">
|
||||
<h2 class="text-xl font-semibold text-light mb-6">
|
||||
{mode === "login" ? "Welcome back" : "Create your account"}
|
||||
</h2>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm"
|
||||
>
|
||||
{error}
|
||||
{#if signupSuccess}
|
||||
<div class="text-center py-4">
|
||||
<div
|
||||
class="w-16 h-16 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-success"
|
||||
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 32;"
|
||||
>
|
||||
mark_email_read
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-light mb-2">
|
||||
Check your email
|
||||
</h2>
|
||||
<p class="text-light/60 text-sm mb-4">
|
||||
We've sent a confirmation link to <strong
|
||||
class="text-light">{email}</strong
|
||||
>. Click the link to activate your account.
|
||||
</p>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => {
|
||||
signupSuccess = false;
|
||||
mode = "login";
|
||||
}}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<h2 class="text-xl font-semibold text-light mb-6">
|
||||
{mode === "login" ? "Welcome back" : "Create your account"}
|
||||
</h2>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="you@example.com"
|
||||
bind:value={email}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth loading={isLoading}>
|
||||
{mode === "login" ? "Log In" : "Sign Up"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div class="my-6 flex items-center gap-3">
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
<span class="text-light/40 text-sm">or continue with</span>
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onclick={() => handleOAuth("google")}
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<p class="mt-6 text-center text-light/60 text-sm">
|
||||
{#if mode === "login"}
|
||||
Don't have an account?
|
||||
<button
|
||||
class="text-primary hover:underline"
|
||||
onclick={() => (mode = "signup")}
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
{:else}
|
||||
Already have an account?
|
||||
<button
|
||||
class="text-primary hover:underline"
|
||||
onclick={() => (mode = "login")}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="you@example.com"
|
||||
bind:value={email}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth loading={isLoading}>
|
||||
{mode === "login" ? "Log In" : "Sign Up"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div class="my-6 flex items-center gap-3">
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
<span class="text-light/40 text-sm">or continue with</span>
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onclick={() => handleOAuth("google")}
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<p class="mt-6 text-center text-light/60 text-sm">
|
||||
{#if mode === "login"}
|
||||
Don't have an account?
|
||||
<button
|
||||
class="text-primary hover:underline"
|
||||
onclick={() => (mode = "signup")}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
{:else}
|
||||
Already have an account?
|
||||
<button
|
||||
class="text-primary hover:underline"
|
||||
onclick={() => (mode = "login")}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { page } from 'vitest/browser';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render h1', async () => {
|
||||
render(Page);
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 });
|
||||
await expect.element(heading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,12 @@
|
||||
Spinner,
|
||||
Toggle,
|
||||
Toast,
|
||||
Chip,
|
||||
ListItem,
|
||||
OrgHeader,
|
||||
CalendarDay,
|
||||
Logo,
|
||||
ContentHeader,
|
||||
} from "$lib/components/ui";
|
||||
|
||||
let inputValue = $state("");
|
||||
@@ -124,7 +130,7 @@
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="tertiary">Tertiary</Button>
|
||||
<Button variant="danger">Danger</Button>
|
||||
<Button variant="success">Success</Button>
|
||||
</div>
|
||||
@@ -141,6 +147,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
With Icons (Material Symbols)
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button icon="add">Add Item</Button>
|
||||
<Button variant="secondary" icon="edit">Edit</Button>
|
||||
<Button variant="tertiary" icon="delete">Delete</Button>
|
||||
<Button icon="send">Send</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
States
|
||||
@@ -157,7 +175,9 @@
|
||||
Full Width
|
||||
</h3>
|
||||
<div class="max-w-sm">
|
||||
<Button fullWidth>Full Width Button</Button>
|
||||
<Button fullWidth icon="rocket_launch"
|
||||
>Full Width Button</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,6 +222,8 @@
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<Input placeholder="Message input with icon..." icon="add" />
|
||||
<Input label="Search" placeholder="Search..." icon="search" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -263,28 +285,22 @@
|
||||
Sizes
|
||||
</h3>
|
||||
<div class="flex items-end gap-4">
|
||||
<Avatar name="John Doe" size="xs" />
|
||||
<Avatar name="John Doe" size="sm" />
|
||||
<Avatar name="John Doe" size="md" />
|
||||
<Avatar name="John Doe" size="lg" />
|
||||
<Avatar name="John Doe" size="xl" />
|
||||
<Avatar name="John Doe" size="2xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
With Status
|
||||
With Status (placeholder)
|
||||
</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar name="Online User" size="lg" status="online" />
|
||||
<Avatar name="Away User" size="lg" status="away" />
|
||||
<Avatar name="Busy User" size="lg" status="busy" />
|
||||
<Avatar
|
||||
name="Offline User"
|
||||
size="lg"
|
||||
status="offline"
|
||||
/>
|
||||
<Avatar name="Online User" size="lg" />
|
||||
<Avatar name="Away User" size="lg" />
|
||||
<Avatar name="Busy User" size="lg" />
|
||||
<Avatar name="Offline User" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -303,6 +319,88 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Chips -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Chips
|
||||
</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Variants
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Chip variant="primary">Primary</Chip>
|
||||
<Chip variant="success">Success</Chip>
|
||||
<Chip variant="warning">Warning</Chip>
|
||||
<Chip variant="error">Error</Chip>
|
||||
<Chip variant="default">Default</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- List Items -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
List Items
|
||||
</h2>
|
||||
|
||||
<div class="max-w-[240px] space-y-2">
|
||||
<ListItem icon="info">Default Item</ListItem>
|
||||
<ListItem icon="settings" variant="hover">Hover State</ListItem>
|
||||
<ListItem icon="check_circle" variant="active"
|
||||
>Active Item</ListItem
|
||||
>
|
||||
<ListItem icon="folder">Documents</ListItem>
|
||||
<ListItem icon="dashboard">Dashboard</ListItem>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Org Header -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Organization Header
|
||||
</h2>
|
||||
|
||||
<div class="max-w-[240px] space-y-4">
|
||||
<OrgHeader name="Acme Corp" role="Admin" />
|
||||
<OrgHeader name="Design Team" role="Editor" isHover />
|
||||
<OrgHeader name="Small" size="sm" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Calendar Day -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Calendar Day
|
||||
</h2>
|
||||
|
||||
<div class="flex gap-1 max-w-[720px]">
|
||||
<CalendarDay day="Mon" isHeader />
|
||||
<CalendarDay day="Tue" isHeader />
|
||||
<CalendarDay day="Wed" isHeader />
|
||||
</div>
|
||||
<div class="flex gap-1 max-w-[720px]">
|
||||
<CalendarDay day="1" />
|
||||
<CalendarDay day="2">
|
||||
{#snippet events()}
|
||||
<Chip>Meeting</Chip>
|
||||
{/snippet}
|
||||
</CalendarDay>
|
||||
<CalendarDay day="3" isPast />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Badges -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
@@ -492,38 +590,132 @@
|
||||
Typography
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-4xl font-bold text-light">
|
||||
Heading 1 (4xl bold)
|
||||
</h1>
|
||||
<h2 class="text-3xl font-bold text-light">
|
||||
Heading 2 (3xl bold)
|
||||
</h2>
|
||||
<h3 class="text-2xl font-semibold text-light">
|
||||
Heading 3 (2xl semibold)
|
||||
</h3>
|
||||
<h4 class="text-xl font-semibold text-light">
|
||||
Heading 4 (xl semibold)
|
||||
</h4>
|
||||
<h5 class="text-lg font-medium text-light">
|
||||
Heading 5 (lg medium)
|
||||
</h5>
|
||||
<h6 class="text-base font-medium text-light">
|
||||
Heading 6 (base medium)
|
||||
</h6>
|
||||
<p class="text-base text-light/80">
|
||||
Body text (base, 80% opacity) - Lorem ipsum dolor sit amet,
|
||||
consectetur adipiscing elit. Sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua.
|
||||
</p>
|
||||
<p class="text-sm text-light/60">
|
||||
Small text (sm, 60% opacity) - Used for secondary
|
||||
information and hints.
|
||||
</p>
|
||||
<p class="text-xs text-light/40">
|
||||
Extra small text (xs, 40% opacity) - Used for metadata and
|
||||
timestamps.
|
||||
</p>
|
||||
<div class="space-y-6">
|
||||
<!-- Headings (Tilt Warp) -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Headings — Tilt Warp
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>h1 · 32</span
|
||||
>
|
||||
<h1 class="text-light">Heading 1</h1>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>h2 · 28</span
|
||||
>
|
||||
<h2 class="text-light">Heading 2</h2>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>h3 · 24</span
|
||||
>
|
||||
<h3 class="text-light">Heading 3</h3>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>h4 · 20</span
|
||||
>
|
||||
<h4 class="text-light">Heading 4</h4>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>h5 · 16</span
|
||||
>
|
||||
<h5 class="text-light">Heading 5</h5>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>h6 · 14</span
|
||||
>
|
||||
<h6 class="text-light">Heading 6</h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button Text (Tilt Warp) -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Button Text — Tilt Warp
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>btn-lg · 20</span
|
||||
>
|
||||
<span class="font-heading text-btn-lg text-light"
|
||||
>Button Large</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>btn-md · 16</span
|
||||
>
|
||||
<span class="font-heading text-btn-md text-light"
|
||||
>Button Medium</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>btn-sm · 14</span
|
||||
>
|
||||
<span class="font-heading text-btn-sm text-light"
|
||||
>Button Small</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body Text (Work Sans) -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Body — Work Sans
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>p · 16</span
|
||||
>
|
||||
<p class="text-body text-light">
|
||||
Body text — Lorem ipsum dolor sit amet,
|
||||
consectetur adipiscing elit.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>p-md · 14</span
|
||||
>
|
||||
<p class="text-body-md text-light/80">
|
||||
Body medium — Used for secondary information and
|
||||
descriptions.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||
>p-sm · 12</span
|
||||
>
|
||||
<p class="text-body-sm text-light/60">
|
||||
Body small — Used for metadata, timestamps, and
|
||||
hints.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -558,6 +750,51 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Logo -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Logo
|
||||
</h2>
|
||||
<p class="text-light/60">
|
||||
Brand logo component with size variants.
|
||||
</p>
|
||||
<div class="flex items-center gap-8 bg-night p-6 rounded-xl">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Logo size="sm" />
|
||||
<span class="text-xs text-light/60">Small</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Logo size="md" />
|
||||
<span class="text-xs text-light/60">Medium</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ContentHeader -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Content Header
|
||||
</h2>
|
||||
<p class="text-light/60">
|
||||
Page header component with avatar, title, action button, and
|
||||
more menu.
|
||||
</p>
|
||||
<div class="bg-night p-6 rounded-xl space-y-4">
|
||||
<ContentHeader
|
||||
title="Page Title"
|
||||
actionLabel="+ New"
|
||||
onAction={() => {}}
|
||||
onMore={() => {}}
|
||||
/>
|
||||
<ContentHeader title="Without Action" onMore={() => {}} />
|
||||
<ContentHeader title="Simple Header" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="text-center py-8 border-t border-light/10">
|
||||
<p class="text-light/40 text-sm">
|
||||
|
||||
Reference in New Issue
Block a user