Mega push vol 5, working on messaging now

This commit is contained in:
AlacrisDevs
2026-02-07 01:31:55 +02:00
parent d8bbfd9dc3
commit e55881b38b
77 changed files with 8478 additions and 1554 deletions

View File

@@ -1,11 +1,18 @@
<!doctype html>
<html lang="en">
<html lang="%paraglide.lang%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<body data-sveltekit-preload-data="tap">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,15 +1,42 @@
import { sequence } from '@sveltejs/kit/hooks';
import { paraglideMiddleware } from '$lib/paraglide/server';
import { createServerClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
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 }) => {
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)
};
};
const originalHandle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
getAll() {
return event.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
@@ -19,18 +46,13 @@ export const handle: Handle = async ({ event, resolve }) => {
});
event.locals.safeGetSession = async () => {
const {
data: { session }
} = await event.locals.supabase.auth.getSession();
const { data: { session } } = await event.locals.supabase.auth.getSession();
if (!session) {
return { session: null, user: null };
}
const {
data: { user },
error
} = await event.locals.supabase.auth.getUser();
const { data: { user }, error } = await event.locals.supabase.auth.getUser();
if (error) {
return { session: null, user: null };
@@ -46,26 +68,12 @@ export const handle: Handle = async ({ event, resolve }) => {
});
};
const serverLog = createLogger('server.error');
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
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 resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
});
});
return {
message: message || 'An unexpected error occurred',
errorId,
context: `${event.request.method} ${event.url.pathname}`,
code: String(status),
};
};
export const handle = sequence(originalHandle, handleParaglide);

3
src/hooks.ts Normal file
View File

@@ -0,0 +1,3 @@
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute = (request) => deLocalizeUrl(request.url).pathname;

38
src/lib/api/activity.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, Json } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.activity');
export type ActivityAction = 'create' | 'update' | 'delete' | 'move' | 'rename';
export type EntityType = 'document' | 'folder' | 'kanban_board' | 'kanban_card' | 'kanban_column' | 'member' | 'role' | 'invite';
interface LogActivityParams {
orgId: string;
userId: string;
action: ActivityAction;
entityType: EntityType;
entityId?: string;
entityName?: string;
metadata?: Record<string, unknown>;
}
export async function logActivity(
supabase: SupabaseClient<Database>,
params: LogActivityParams
): Promise<void> {
const { error } = await supabase.from('activity_log').insert({
org_id: params.orgId,
user_id: params.userId,
action: params.action,
entity_type: params.entityType,
entity_id: params.entityId ?? null,
entity_name: params.entityName ?? null,
metadata: (params.metadata ?? {}) as Json,
});
if (error) {
// Activity logging should never block the main action — just warn
log.warn('Failed to log activity', { error: { message: error.message } });
}
}

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { getMonthDays, isSameDay, formatTime } from './calendar';
describe('getMonthDays', () => {
it('returns exactly 42 days (6 weeks grid)', () => {
const days = getMonthDays(2024, 0); // January 2024
expect(days).toHaveLength(42);
});
it('first day of grid is a Monday', () => {
const days = getMonthDays(2024, 0); // January 2024
// getDay() returns 0=Sun, 1=Mon
expect(days[0].getDay()).toBe(1);
});
it('contains all days of the target month', () => {
const days = getMonthDays(2024, 1); // February 2024 (leap year, 29 days)
const febDays = days.filter(
(d) => d.getMonth() === 1 && d.getFullYear() === 2024,
);
expect(febDays).toHaveLength(29);
});
it('contains all days of a 31-day month', () => {
const days = getMonthDays(2024, 2); // March 2024
const marchDays = days.filter(
(d) => d.getMonth() === 2 && d.getFullYear() === 2024,
);
expect(marchDays).toHaveLength(31);
});
it('pads with previous month days at the start', () => {
// January 2024 starts on Monday, so no padding needed from December
const days = getMonthDays(2024, 0);
expect(days[0].getDate()).toBe(1);
expect(days[0].getMonth()).toBe(0);
});
it('pads with next month days at the end', () => {
const days = getMonthDays(2024, 0); // January 2024
const lastDay = days[days.length - 1];
// Last day should be in February
expect(lastDay.getMonth()).toBe(1);
});
it('handles December correctly (month 11)', () => {
const days = getMonthDays(2024, 11);
expect(days).toHaveLength(42);
const decDays = days.filter(
(d) => d.getMonth() === 11 && d.getFullYear() === 2024,
);
expect(decDays).toHaveLength(31);
});
});
describe('isSameDay', () => {
it('returns true for same date', () => {
const a = new Date(2024, 5, 15, 10, 30);
const b = new Date(2024, 5, 15, 22, 0);
expect(isSameDay(a, b)).toBe(true);
});
it('returns false for different days', () => {
const a = new Date(2024, 5, 15);
const b = new Date(2024, 5, 16);
expect(isSameDay(a, b)).toBe(false);
});
it('returns false for different months', () => {
const a = new Date(2024, 5, 15);
const b = new Date(2024, 6, 15);
expect(isSameDay(a, b)).toBe(false);
});
it('returns false for different years', () => {
const a = new Date(2024, 5, 15);
const b = new Date(2025, 5, 15);
expect(isSameDay(a, b)).toBe(false);
});
});
describe('formatTime', () => {
it('returns a string with hours and minutes', () => {
const date = new Date(2024, 0, 1, 14, 30);
const result = formatTime(date);
// Format varies by locale, but should contain "30" for minutes
expect(result).toContain('30');
expect(result.length).toBeGreaterThan(0);
});
});

View File

@@ -27,14 +27,7 @@ export async function getLockInfo(
const { data: lock } = await supabase
.from('document_locks')
.select(`
id,
document_id,
user_id,
locked_at,
last_heartbeat,
profiles:user_id (full_name, email)
`)
.select('id, document_id, user_id, locked_at, last_heartbeat')
.eq('document_id', documentId)
.gt('last_heartbeat', cutoff)
.single();
@@ -43,11 +36,23 @@ export async function getLockInfo(
return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false };
}
const profile = (lock as any).profiles; // join type not inferred by Supabase
// Fetch profile separately — document_locks.user_id FK points to auth.users, not profiles
let lockedByName = 'Someone';
if (lock.user_id) {
const { data: profile } = await supabase
.from('profiles')
.select('full_name, email')
.eq('id', lock.user_id)
.single();
if (profile) {
lockedByName = profile.full_name || profile.email || 'Someone';
}
}
return {
isLocked: true,
lockedBy: lock.user_id,
lockedByName: profile?.full_name || profile?.email || 'Someone',
lockedByName,
isOwnLock: lock.user_id === currentUserId,
};
}

View File

@@ -0,0 +1,132 @@
import { describe, it, expect, vi } from 'vitest';
import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments } from './documents';
// Lightweight Supabase mock builder
function mockSupabase(response: { data?: unknown; error?: unknown }) {
const chain: Record<string, unknown> = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn().mockReturnValue(chain);
}
// Terminal calls resolve the response
chain['single'] = vi.fn().mockResolvedValue(response);
chain['order'] = vi.fn().mockReturnValue({ ...chain, order: vi.fn().mockResolvedValue(response) });
// For delete → eq chain (no .select().single())
const eqAfterDelete = vi.fn().mockResolvedValue(response);
const originalDelete = chain['delete'];
chain['delete'] = vi.fn().mockReturnValue({ eq: eqAfterDelete, in: vi.fn().mockResolvedValue(response) });
// For update → eq (moveDocument has no .select().single())
chain['update'] = vi.fn().mockReturnValue({ ...chain, eq: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue(response) }), ...response }) });
return chain as any;
}
function mockSupabaseSuccess(data: unknown) {
return mockSupabase({ data, error: null });
}
function mockSupabaseError(message: string) {
return mockSupabase({ data: null, error: { message, code: 'ERROR' } });
}
const fakeDoc = {
id: 'doc-1',
org_id: 'org-1',
name: 'Test Doc',
type: 'document' as const,
parent_id: null,
path: null,
position: 0,
content: { type: 'doc', content: [] },
created_by: 'user-1',
created_at: '2024-01-01',
updated_at: '2024-01-01',
};
describe('createDocument', () => {
it('creates a document with default content for type "document"', async () => {
const sb = mockSupabaseSuccess(fakeDoc);
const result = await createDocument(sb, 'org-1', 'Test Doc', 'document', null, 'user-1');
expect(result).toEqual(fakeDoc);
expect(sb.from).toHaveBeenCalledWith('documents');
});
it('creates a folder with null content', async () => {
const folderDoc = { ...fakeDoc, type: 'folder', content: null };
const sb = mockSupabaseSuccess(folderDoc);
const result = await createDocument(sb, 'org-1', 'Folder', 'folder', null, 'user-1');
expect(result.type).toBe('folder');
});
it('creates a kanban document with custom id and content', async () => {
const kanbanDoc = { ...fakeDoc, id: 'board-1', type: 'kanban', content: { type: 'kanban', board_id: 'board-1' } };
const sb = mockSupabaseSuccess(kanbanDoc);
const result = await createDocument(
sb, 'org-1', 'Board', 'kanban', null, 'user-1',
{ id: 'board-1', content: { type: 'kanban', board_id: 'board-1' } },
);
expect(result.id).toBe('board-1');
});
it('throws on Supabase error', async () => {
const sb = mockSupabaseError('insert failed');
await expect(createDocument(sb, 'org-1', 'Fail', 'document', null, 'user-1'))
.rejects.toEqual({ message: 'insert failed', code: 'ERROR' });
});
});
describe('copyDocument', () => {
it('appends " (copy)" to the document name', async () => {
const copiedDoc = { ...fakeDoc, id: 'doc-2', name: 'Test Doc (copy)' };
const sb = mockSupabaseSuccess(copiedDoc);
const result = await copyDocument(sb, fakeDoc, 'org-1', 'user-1');
expect(result.name).toBe('Test Doc (copy)');
});
it('throws on Supabase error', async () => {
const sb = mockSupabaseError('copy failed');
await expect(copyDocument(sb, fakeDoc, 'org-1', 'user-1'))
.rejects.toEqual({ message: 'copy failed', code: 'ERROR' });
});
});
describe('deleteDocument', () => {
it('calls delete with correct id', async () => {
const sb = mockSupabase({ data: null, error: null });
await deleteDocument(sb, 'doc-1');
expect(sb.from).toHaveBeenCalledWith('documents');
expect(sb.delete).toHaveBeenCalled();
});
it('throws on Supabase error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'delete failed', code: 'ERROR' } });
await expect(deleteDocument(sb, 'doc-1'))
.rejects.toEqual({ message: 'delete failed', code: 'ERROR' });
});
});
describe('fetchDocuments', () => {
it('returns documents array on success', async () => {
const docs = [fakeDoc];
// fetchDocuments calls .from().select().eq().order().order() — need deeper chain
const orderFn2 = vi.fn().mockResolvedValue({ data: docs, error: null });
const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 });
const eqFn = vi.fn().mockReturnValue({ order: orderFn1 });
const selectFn = vi.fn().mockReturnValue({ eq: eqFn });
const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any;
const result = await fetchDocuments(sb, 'org-1');
expect(result).toEqual(docs);
expect(sb.from).toHaveBeenCalledWith('documents');
});
it('throws on Supabase error', async () => {
const orderFn2 = vi.fn().mockResolvedValue({ data: null, error: { message: 'fetch failed' } });
const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 });
const eqFn = vi.fn().mockReturnValue({ order: orderFn1 });
const selectFn = vi.fn().mockReturnValue({ eq: eqFn });
const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any;
await expect(fetchDocuments(sb, 'org-1')).rejects.toEqual({ message: 'fetch failed' });
});
});

View File

@@ -27,19 +27,26 @@ export async function createDocument(
supabase: SupabaseClient<Database>,
orgId: string,
name: string,
type: 'folder' | 'document',
type: 'folder' | 'document' | 'kanban',
parentId: string | null = null,
userId: string
userId: string,
options?: { id?: string; content?: import('$lib/supabase/types').Json }
): Promise<Document> {
let content: import('$lib/supabase/types').Json | null = options?.content ?? null;
if (!content && type === 'document') {
content = { type: 'doc', content: [] };
}
const { data, error } = await supabase
.from('documents')
.insert({
...(options?.id ? { id: options.id } : {}),
org_id: orgId,
name,
type,
parent_id: parentId,
created_by: userId,
content: type === 'document' ? { type: 'doc', content: [] } : null
content,
})
.select()
.single();
@@ -99,6 +106,33 @@ export async function moveDocument(
}
export async function copyDocument(
supabase: SupabaseClient<Database>,
doc: Pick<Document, 'name' | 'type' | 'parent_id' | 'content'>,
orgId: string,
userId: string
): Promise<Document> {
const { data, error } = await supabase
.from('documents')
.insert({
org_id: orgId,
name: `${doc.name} (copy)`,
type: doc.type,
parent_id: doc.parent_id,
created_by: userId,
content: doc.content,
})
.select()
.single();
if (error) {
log.error('copyDocument failed', { error, data: { orgId, name: doc.name } });
throw error;
}
log.info('copyDocument ok', { data: { id: data.id, name: data.name } });
return data;
}
export function subscribeToDocuments(
supabase: SupabaseClient<Database>,
orgId: string,

View File

@@ -0,0 +1,218 @@
import { GoogleAuth } from 'google-auth-library';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.google-calendar-push');
const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3';
const SCOPES = ['https://www.googleapis.com/auth/calendar.events'];
/**
* Google Calendar push integration via Service Account.
*
* Setup:
* 1. Create a service account in Google Cloud Console
* 2. Download the JSON key file
* 3. Set GOOGLE_SERVICE_ACCOUNT_KEY env var to the JSON string (or base64-encoded)
* 4. Share the Google Calendar with the service account email (give "Make changes to events" permission)
*/
interface ServiceAccountCredentials {
client_email: string;
private_key: string;
project_id?: string;
}
let cachedAuth: GoogleAuth | null = null;
function getServiceAccountCredentials(keyJson: string): ServiceAccountCredentials {
try {
// Try parsing directly as JSON
const parsed = JSON.parse(keyJson);
return parsed;
} catch {
// Try base64 decode first
try {
const decoded = Buffer.from(keyJson, 'base64').toString('utf-8');
return JSON.parse(decoded);
} catch {
throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY must be valid JSON or base64-encoded JSON');
}
}
}
function getAuth(keyJson: string): GoogleAuth {
if (cachedAuth) return cachedAuth;
const credentials = getServiceAccountCredentials(keyJson);
cachedAuth = new GoogleAuth({
credentials: {
client_email: credentials.client_email,
private_key: credentials.private_key,
},
scopes: SCOPES,
});
return cachedAuth;
}
async function getAccessToken(keyJson: string): Promise<string> {
const auth = getAuth(keyJson);
const client = await auth.getClient();
const tokenResponse = await client.getAccessToken();
const token = typeof tokenResponse === 'string' ? tokenResponse : tokenResponse?.token;
if (!token) throw new Error('Failed to get access token from service account');
return token;
}
export function getServiceAccountEmail(keyJson: string): string | null {
try {
const creds = getServiceAccountCredentials(keyJson);
return creds.client_email;
} catch {
return null;
}
}
/**
* Fetch events from a Google Calendar using the service account.
* No need for the calendar to be public — just shared with the service account.
*/
export async function fetchCalendarEventsViaServiceAccount(
keyJson: string,
calendarId: string,
timeMin: Date,
timeMax: Date
): Promise<unknown[]> {
const token = await getAccessToken(keyJson);
const params = new URLSearchParams({
timeMin: timeMin.toISOString(),
timeMax: timeMax.toISOString(),
singleEvents: 'true',
orderBy: 'startTime',
maxResults: '250',
});
const response = await fetch(
`${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`,
{
headers: { Authorization: `Bearer ${token}` },
}
);
if (!response.ok) {
const errorText = await response.text();
log.error('Failed to fetch calendar events via service account', {
error: errorText,
data: { calendarId },
});
throw new Error(`Google Calendar API error (${response.status}): ${errorText}`);
}
const data = await response.json();
return data.items ?? [];
}
export interface GoogleEventPayload {
summary: string;
description?: string | null;
start: { dateTime?: string; date?: string; timeZone?: string };
end: { dateTime?: string; date?: string; timeZone?: string };
colorId?: string;
}
/**
* Create an event in Google Calendar.
* Returns the Google event ID.
*/
export async function pushEventToGoogle(
keyJson: string,
calendarId: string,
event: GoogleEventPayload
): Promise<string> {
const token = await getAccessToken(keyJson);
const response = await fetch(
`${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(event),
}
);
if (!response.ok) {
const errorText = await response.text();
log.error('Failed to create Google Calendar event', { error: errorText, data: { calendarId } });
throw new Error(`Google Calendar API error (${response.status}): ${errorText}`);
}
const data = await response.json();
log.info('Created Google Calendar event', { data: { googleEventId: data.id, calendarId } });
return data.id;
}
/**
* Update an existing event in Google Calendar.
*/
export async function updateGoogleEvent(
keyJson: string,
calendarId: string,
googleEventId: string,
event: GoogleEventPayload
): Promise<void> {
const token = await getAccessToken(keyJson);
const response = await fetch(
`${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(googleEventId)}`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(event),
}
);
if (!response.ok) {
const errorText = await response.text();
log.error('Failed to update Google Calendar event', { error: errorText, data: { calendarId, googleEventId } });
throw new Error(`Google Calendar API error (${response.status}): ${errorText}`);
}
log.info('Updated Google Calendar event', { data: { googleEventId, calendarId } });
}
/**
* Delete an event from Google Calendar.
*/
export async function deleteGoogleEvent(
keyJson: string,
calendarId: string,
googleEventId: string
): Promise<void> {
const token = await getAccessToken(keyJson);
const response = await fetch(
`${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(googleEventId)}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
}
);
// 410 Gone means already deleted — treat as success
if (!response.ok && response.status !== 410) {
const errorText = await response.text();
log.error('Failed to delete Google Calendar event', { error: errorText, data: { calendarId, googleEventId } });
throw new Error(`Google Calendar API error (${response.status}): ${errorText}`);
}
log.info('Deleted Google Calendar event', { data: { googleEventId, calendarId } });
}

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { extractCalendarId, getCalendarSubscribeUrl } from './google-calendar';
describe('extractCalendarId', () => {
it('returns null for empty input', () => {
expect(extractCalendarId('')).toBeNull();
});
it('returns email-style calendar ID as-is', () => {
expect(extractCalendarId('user@gmail.com')).toBe('user@gmail.com');
});
it('trims whitespace from email-style IDs', () => {
expect(extractCalendarId(' user@gmail.com ')).toBe('user@gmail.com');
});
it('returns group calendar ID as-is', () => {
const id = 'abc123@group.calendar.google.com';
expect(extractCalendarId(id)).toBe(id);
});
it('extracts calendar ID from cid parameter (base64)', () => {
const calId = 'user@gmail.com';
const encoded = btoa(calId);
const url = `https://calendar.google.com/calendar/u/0?cid=${encoded}`;
expect(extractCalendarId(url)).toBe(calId);
});
it('extracts calendar ID from src parameter', () => {
const url = 'https://calendar.google.com/calendar/embed?src=user@gmail.com';
expect(extractCalendarId(url)).toBe('user@gmail.com');
});
it('extracts calendar ID from ical path', () => {
const url = 'https://calendar.google.com/calendar/ical/user%40gmail.com/public/basic.ics';
expect(extractCalendarId(url)).toBe('user@gmail.com');
});
it('returns null for non-URL non-email input', () => {
expect(extractCalendarId('random-string')).toBeNull();
});
it('handles URL without recognized parameters', () => {
expect(extractCalendarId('https://example.com/page')).toBeNull();
});
});
describe('getCalendarSubscribeUrl', () => {
it('generates a subscribe URL with base64-encoded calendar ID', () => {
const calId = 'user@gmail.com';
const url = getCalendarSubscribeUrl(calId);
expect(url).toContain('https://calendar.google.com/calendar/u/0?cid=');
expect(url).toContain(btoa(calId));
});
it('roundtrips with extractCalendarId', () => {
const calId = 'test@group.calendar.google.com';
const url = getCalendarSubscribeUrl(calId);
expect(extractCalendarId(url)).toBe(calId);
});
});

View File

@@ -90,9 +90,8 @@ export async function fetchPublicCalendarEvents(
);
if (!response.ok) {
const error = await response.text();
console.error('Google Calendar API error:', error);
throw new Error('Failed to fetch calendar events. Make sure the calendar is set to public.');
const errorText = await response.text();
throw new Error(`Failed to fetch calendar events (${response.status}): ${errorText}`);
}
const data = await response.json();

View File

@@ -34,30 +34,30 @@ export async function fetchBoardWithColumns(
supabase: SupabaseClient<Database>,
boardId: string
): Promise<BoardWithColumns | null> {
const { data: board, error: boardError } = await supabase
.from('kanban_boards')
.select('*')
.eq('id', boardId)
.single();
// Fetch board and columns in parallel
const [boardResult, columnsResult] = await Promise.all([
supabase.from('kanban_boards').select('*').eq('id', boardId).single(),
supabase.from('kanban_columns').select('*').eq('board_id', boardId).order('position'),
]);
if (boardError) {
log.error('fetchBoardWithColumns failed (board)', { error: boardError, data: { boardId } });
throw boardError;
if (boardResult.error) {
log.error('fetchBoardWithColumns failed (board)', { error: boardResult.error, data: { boardId } });
throw boardResult.error;
}
if (!board) return null;
if (!boardResult.data) return null;
const { data: columns, error: colError } = await supabase
.from('kanban_columns')
.select('*')
.eq('board_id', boardId)
.order('position');
if (colError) {
log.error('fetchBoardWithColumns failed (columns)', { error: colError, data: { boardId } });
throw colError;
if (columnsResult.error) {
log.error('fetchBoardWithColumns failed (columns)', { error: columnsResult.error, data: { boardId } });
throw columnsResult.error;
}
const columnIds = (columns ?? []).map((c) => c.id);
const board = boardResult.data;
const columns = columnsResult.data ?? [];
const columnIds = columns.map((c) => c.id);
if (columnIds.length === 0) {
return { ...board, columns: columns.map((col) => ({ ...col, cards: [] })) };
}
const { data: cards, error: cardError } = await supabase
.from('kanban_cards')
@@ -70,42 +70,71 @@ export async function fetchBoardWithColumns(
throw cardError;
}
// 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 }[]>();
const cardTagsMap = new Map<string, { id: string; name: string; color: string | null }[]>();
const checklistMap = new Map<string, { total: number; done: number }>();
const assigneeMap = new Map<string, { name: string | null; avatar: 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);
const assigneeIds = [...new Set((cards ?? []).map((c) => c.assignee_id).filter(Boolean))] as string[];
(cardTags ?? []).forEach((ct: any) => {
const tag = Array.isArray(ct.tags) ? ct.tags[0] : ct.tags;
// Fetch tags, checklists, and assignee profiles in parallel
const [cardTagsResult, checklistResult, profilesResult] = await Promise.all([
supabase.from('card_tags').select('card_id, tags:tag_id (id, name, color)').in('card_id', cardIds),
supabase.from('kanban_checklist_items').select('card_id, completed').in('card_id', cardIds),
assigneeIds.length > 0
? supabase.from('profiles').select('id, full_name, avatar_url').in('id', assigneeIds)
: Promise.resolve({ data: null }),
]);
(cardTagsResult.data ?? []).forEach((ct: Record<string, unknown>) => {
const rawTags = ct.tags;
const tag = Array.isArray(rawTags) ? rawTags[0] : rawTags;
if (!tag) return;
if (!cardTagsMap.has(ct.card_id)) {
cardTagsMap.set(ct.card_id, []);
const cardId = ct.card_id as string;
if (!cardTagsMap.has(cardId)) {
cardTagsMap.set(cardId, []);
}
cardTagsMap.get(ct.card_id)!.push(tag);
cardTagsMap.get(cardId)!.push(tag as { id: string; name: string; color: string | null });
});
(checklistResult.data ?? []).forEach((item: Record<string, unknown>) => {
const cardId = item.card_id as string;
if (!checklistMap.has(cardId)) {
checklistMap.set(cardId, { total: 0, done: 0 });
}
const entry = checklistMap.get(cardId)!;
entry.total++;
if (item.completed) entry.done++;
});
(profilesResult.data ?? []).forEach((p: Record<string, unknown>) => {
assigneeMap.set(p.id as string, { name: p.full_name as string | null, avatar: p.avatar_url as string | null });
});
}
const cardsByColumn = new Map<string, (KanbanCard & { tags?: { id: string; name: string; color: string | null }[] })[]>();
const cardsByColumn = new Map<string, (KanbanCard & { tags?: { id: string; name: string; color: string | null }[]; checklist_total?: number; checklist_done?: number; assignee_name?: string | null; assignee_avatar?: string | null })[]>();
(cards ?? []).forEach((card) => {
const colId = card.column_id;
if (!colId) return;
if (!cardsByColumn.has(colId)) {
cardsByColumn.set(colId, []);
}
const cl = checklistMap.get(card.id);
const assignee = card.assignee_id ? assigneeMap.get(card.assignee_id) : null;
cardsByColumn.get(colId)!.push({
...card,
tags: cardTagsMap.get(card.id) ?? []
tags: cardTagsMap.get(card.id) ?? [],
checklist_total: cl?.total ?? 0,
checklist_done: cl?.done ?? 0,
assignee_name: assignee?.name ?? null,
assignee_avatar: assignee?.avatar ?? null,
});
});
return {
...board,
columns: (columns ?? []).map((col) => ({
columns: columns.map((col) => ({
...col,
cards: cardsByColumn.get(col.id) ?? []
}))
@@ -283,39 +312,76 @@ export async function moveCard(
...otherCards.slice(newPosition),
];
// Batch update: move card to column + set position, then update siblings
const updates = reordered.map((c, i) => {
if (c.id === cardId) {
// Build a map of old positions to detect what actually changed
const oldPositionMap = new Map((targetCards ?? []).map((c) => [c.id, c.position]));
// Only update cards whose position or column actually changed
const updates = reordered
.map((c, i) => {
if (c.id === cardId) {
// The moved card always needs updating (column + position)
return supabase
.from('kanban_cards')
.update({ column_id: newColumnId, position: i })
.eq('id', c.id);
}
// Skip siblings whose position hasn't changed
if (oldPositionMap.get(c.id) === i) return null;
return supabase
.from('kanban_cards')
.update({ column_id: newColumnId, position: i })
.update({ position: i })
.eq('id', c.id);
}
return supabase
.from('kanban_cards')
.update({ position: i })
.eq('id', c.id);
});
})
.filter(Boolean);
if (updates.length === 0) return;
const results = await Promise.all(updates);
const failed = results.find((r) => r.error);
const failed = results.find((r) => r && r.error);
if (failed?.error) {
log.error('moveCard failed', { error: failed.error, data: { cardId, newColumnId, newPosition } });
throw failed.error;
}
}
export interface RealtimeChangePayload<T = Record<string, unknown>> {
event: 'INSERT' | 'UPDATE' | 'DELETE';
new: T;
old: Partial<T>;
}
export function subscribeToBoard(
supabase: SupabaseClient<Database>,
boardId: string,
onColumnChange: () => void,
onCardChange: () => void
columnIds: string[],
onColumnChange: (payload: RealtimeChangePayload<KanbanColumn>) => void,
onCardChange: (payload: RealtimeChangePayload<KanbanCard>) => void
) {
const channel = supabase.channel(`kanban:${boardId}`);
const columnIdSet = new Set(columnIds);
channel
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_columns', filter: `board_id=eq.${boardId}` }, onColumnChange)
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' }, onCardChange)
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_columns', filter: `board_id=eq.${boardId}` },
(payload) => onColumnChange({
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
new: payload.new as KanbanColumn,
old: payload.old as Partial<KanbanColumn>,
})
)
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' },
(payload) => {
// Client-side filter: only process cards belonging to this board's columns
const card = (payload.new ?? payload.old) as Partial<KanbanCard>;
const colId = card.column_id ?? (payload.old as Partial<KanbanCard>)?.column_id;
if (colId && !columnIdSet.has(colId)) return;
onCardChange({
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
new: payload.new as KanbanCard,
old: payload.old as Partial<KanbanCard>,
});
}
)
.subscribe();
return channel;

View File

@@ -128,7 +128,7 @@
<div class="flex items-center justify-between px-2">
<div class="flex items-center gap-2">
<button
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-full transition-colors"
onclick={prev}
aria-label="Previous"
>
@@ -143,7 +143,7 @@
>{headerTitle}</span
>
<button
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-full transition-colors"
onclick={next}
aria-label="Next"
>
@@ -203,7 +203,9 @@
</div>
<!-- Calendar Grid -->
<div class="flex-1 flex flex-col gap-2 min-h-0">
<div
class="flex-1 flex flex-col gap-2 min-h-0 rounded-lg overflow-hidden"
>
{#each weeks as week}
<div class="grid grid-cols-7 gap-2 flex-1">
{#each week as day}
@@ -211,7 +213,7 @@
{@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
class="bg-night rounded-none flex flex-col items-start px-2 py-2.5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer
{!inMonth ? 'opacity-50' : ''}"
onclick={() => onDateClick?.(day)}
>
@@ -254,12 +256,14 @@
<div
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
>
<div class="grid grid-cols-7 gap-2 flex-1">
<div
class="grid grid-cols-7 gap-2 flex-1 rounded-lg overflow-hidden"
>
{#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="px-2 py-2 text-center">
<div
class="font-heading text-h4 {isToday
? 'text-primary'
@@ -275,7 +279,9 @@
{day.getDate()}
</div>
</div>
<div class="flex-1 px-2 pb-2 space-y-1 overflow-y-auto">
<div
class="bg-night 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"

View File

@@ -86,7 +86,7 @@
{/if}
<button
type="button"
class="p-1 hover:bg-dark rounded-lg transition-colors"
class="p-1 hover:bg-dark rounded-full transition-colors"
aria-label="More options"
>
<span

View File

@@ -15,6 +15,15 @@
import type { Document } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
import { logActivity } from "$lib/api/activity";
import {
moveDocument,
updateDocument,
deleteDocument,
createDocument,
copyDocument,
} from "$lib/api/documents";
const log = createLogger("component.file-browser");
@@ -32,7 +41,7 @@
documents = $bindable(),
currentFolderId,
user,
title = "Files",
title = m.files_title(),
}: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
@@ -43,7 +52,19 @@
let editingDoc = $state<Document | null>(null);
let newDocName = $state("");
let newDocType = $state<"folder" | "document" | "kanban">("document");
let viewMode = $state<"list" | "grid">("grid");
let viewMode = $state<"list" | "grid">(
typeof localStorage !== "undefined" &&
localStorage.getItem("root:viewMode") === "list"
? "list"
: "grid",
);
function toggleViewMode() {
viewMode = viewMode === "list" ? "grid" : "list";
if (typeof localStorage !== "undefined") {
localStorage.setItem("root:viewMode", viewMode);
}
}
// Context menu state
let contextMenu = $state<{ x: number; y: number; doc: Document } | null>(
@@ -171,23 +192,11 @@
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];
try {
const newDoc = await copyDocument(supabase, doc, org.id, user.id);
documents = [...documents, newDoc];
toasts.success(`Copied "${doc.name}"`);
} else if (error) {
log.error("Failed to copy document", { error });
} catch {
toasts.error("Failed to copy document");
}
}
@@ -294,22 +303,15 @@
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 },
});
try {
await moveDocument(supabase, docId, newParentId);
} catch {
toasts.error("Failed to move file");
const { data: freshDocs } = await supabase
.from("documents")
.select("*")
.select(
"id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id",
)
.eq("org_id", org.id)
.order("name");
if (freshDocs) documents = freshDocs as Document[];
@@ -319,67 +321,71 @@
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));
try {
if (newDocType === "kanban") {
// Create kanban board first, then link as document
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 newDoc = await createDocument(
supabase,
org.id,
newDocName,
"kanban",
currentFolderId,
user.id,
{
id: newBoard.id,
content: {
type: "kanban",
board_id: newBoard.id,
} as import("$lib/supabase/types").Json,
},
);
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "create",
entityType: "kanban_board",
entityId: newDoc.id,
entityName: newDocName,
});
goto(getFileUrl(newDoc));
} else {
const newDoc = await createDocument(
supabase,
org.id,
newDocName,
newDocType,
currentFolderId,
user.id,
);
documents = [...documents, newDoc];
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "create",
entityType: newDocType === "folder" ? "folder" : "document",
entityId: newDoc.id,
entityName: newDocName,
});
if (newDocType === "document") {
goto(getFileUrl(newDoc));
}
} else if (error) {
toasts.error("Failed to create document");
}
} catch {
toasts.error("Failed to create document");
}
showCreateModal = false;
@@ -389,28 +395,39 @@
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,
);
try {
await updateDocument(supabase, selectedDoc.id, { content });
documents = documents.map((d) =>
d.id === selectedDoc!.id ? { ...d, content } : d,
);
} catch {
toasts.error("Failed to save document");
}
}
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) {
try {
await updateDocument(supabase, editingDoc.id, { name: newDocName });
if (user) {
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "rename",
entityType:
editingDoc.type === "folder" ? "folder" : "document",
entityId: editingDoc.id,
entityName: newDocName,
});
}
documents = documents.map((d) =>
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
);
if (selectedDoc?.id === editingDoc.id) {
selectedDoc = { ...selectedDoc, name: newDocName };
}
} catch {
toasts.error("Failed to rename document");
}
showEditModal = false;
editingDoc = null;
@@ -435,21 +452,30 @@
return ids;
}
if (doc.type === "folder") {
const descendantIds = collectDescendantIds(doc.id);
if (descendantIds.length > 0) {
await supabase
.from("documents")
.delete()
.in("id", descendantIds);
try {
if (doc.type === "folder") {
const descendantIds = collectDescendantIds(doc.id);
for (const id of descendantIds) {
await deleteDocument(supabase, id);
}
}
}
await deleteDocument(supabase, doc.id);
const { error } = await supabase
.from("documents")
.delete()
.eq("id", doc.id);
if (!error) {
if (user) {
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "delete",
entityType:
doc.type === "folder"
? "folder"
: doc.type === "kanban"
? "kanban_board"
: "document",
entityId: doc.id,
entityName: doc.name,
});
}
const deletedIds = new Set([
doc.id,
...(doc.type === "folder" ? collectDescendantIds(doc.id) : []),
@@ -458,6 +484,8 @@
if (selectedDoc?.id === doc.id) {
selectedDoc = null;
}
} catch {
toasts.error("Failed to delete document");
}
}
</script>
@@ -471,12 +499,8 @@
<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")}
>
<Button size="md" onclick={handleAdd}>{m.btn_new()}</Button>
<IconButton title={m.files_toggle_view()} onclick={toggleViewMode}>
<Icon
name={viewMode === "list" ? "grid_view" : "view_list"}
size={24}
@@ -668,7 +692,7 @@
<Modal
isOpen={showCreateModal}
onClose={() => (showCreateModal = false)}
title="Create New"
title={m.files_create_title()}
>
<div class="space-y-4">
<div class="flex gap-2">
@@ -685,7 +709,7 @@
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>description</span
>
Document
{m.files_type_document()}
</button>
<button
type="button"
@@ -700,7 +724,7 @@
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>folder</span
>
Folder
{m.files_type_folder()}
</button>
<button
type="button"
@@ -715,24 +739,24 @@
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>view_kanban</span
>
Kanban
{m.files_type_kanban()}
</button>
</div>
<Input
label="Name"
label={m.files_name_label()}
bind:value={newDocName}
placeholder={newDocType === "folder"
? "Folder name"
? m.files_folder_placeholder()
: newDocType === "kanban"
? "Kanban board name"
: "Document name"}
? m.files_kanban_placeholder()
: m.files_doc_placeholder()}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
>Cancel</Button
>{m.btn_cancel()}</Button
>
<Button onclick={handleCreate} disabled={!newDocName.trim()}
>Create</Button
>{m.btn_create()}</Button
>
</div>
</div>
@@ -757,7 +781,7 @@
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>edit</span
>
Rename
{m.files_context_rename()}
</button>
<button
type="button"
@@ -837,7 +861,7 @@
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>delete</span
>
Delete
{m.files_context_delete()}
</button>
</div>
{/if}
@@ -849,13 +873,13 @@
editingDoc = null;
newDocName = "";
}}
title="Rename"
title={m.files_rename_title()}
>
<div class="space-y-4">
<Input
label="Name"
label={m.files_name_label()}
bind:value={newDocName}
placeholder="Enter new name"
placeholder={m.files_name_label()}
/>
<div class="flex justify-end gap-2 pt-2">
<Button
@@ -864,10 +888,10 @@
showEditModal = false;
editingDoc = null;
newDocName = "";
}}>Cancel</Button
}}>{m.btn_cancel()}</Button
>
<Button onclick={handleRename} disabled={!newDocName.trim()}
>Save</Button
>{m.btn_save()}</Button
>
</div>
</div>

View File

@@ -100,6 +100,10 @@
let cardTagIds = $state<Set<string>>(new Set());
let newTagName = $state("");
let showTagInput = $state(false);
let editingTagId = $state<string | null>(null);
let editTagName = $state("");
let editTagColor = $state("");
let showTagManager = $state(false);
const TAG_COLORS = [
"#00A3E0",
@@ -238,6 +242,38 @@
showTagInput = false;
}
function startEditTag(tag: OrgTag) {
editingTagId = tag.id;
editTagName = tag.name;
editTagColor = tag.color || TAG_COLORS[0];
}
async function saveEditTag() {
if (!editingTagId || !editTagName.trim()) return;
const { error } = await supabase
.from("tags")
.update({ name: editTagName.trim(), color: editTagColor })
.eq("id", editingTagId);
if (!error) {
orgTags = orgTags.map((t) =>
t.id === editingTagId
? { ...t, name: editTagName.trim(), color: editTagColor }
: t,
);
}
editingTagId = null;
}
async function deleteTag(tagId: string) {
if (!confirm("Delete this tag from the organization?")) return;
const { error } = await supabase.from("tags").delete().eq("id", tagId);
if (!error) {
orgTags = orgTags.filter((t) => t.id !== tagId);
cardTagIds.delete(tagId);
cardTagIds = new Set(cardTagIds);
}
}
async function handleSave() {
if (!isMounted) return;
if (mode === "create") {
@@ -282,7 +318,10 @@
.eq("id", columnId)
.single();
const position = (column as any)?.cards?.[0]?.count ?? 0; // join aggregation not typed
const cards = (column as Record<string, unknown> | null)?.cards as
| { count: number }[]
| undefined;
const position = cards?.[0]?.count ?? 0;
const { data: newCard, error } = await supabase
.from("kanban_cards")
@@ -300,6 +339,26 @@
.single();
if (!error && newCard) {
// Persist checklist items added during creation
if (checklist.length > 0) {
await supabase.from("kanban_checklist_items").insert(
checklist.map((item, i) => ({
card_id: newCard.id,
title: item.title,
position: i,
completed: false,
})),
);
}
// Persist tags assigned during creation
if (cardTagIds.size > 0) {
await supabase.from("card_tags").insert(
[...cardTagIds].map((tagId) => ({
card_id: newCard.id,
tag_id: tagId,
})),
);
}
onCreate?.(newCard as KanbanCard);
onClose();
}
@@ -307,7 +366,25 @@
}
async function handleAddItem() {
if (!card || !newItemTitle.trim()) return;
if (!newItemTitle.trim()) return;
if (mode === "create") {
// In create mode, add items locally (no card ID yet)
checklist = [
...checklist,
{
id: `temp-${Date.now()}`,
card_id: "",
title: newItemTitle.trim(),
completed: false,
position: checklist.length,
},
];
newItemTitle = "";
return;
}
if (!card) return;
const position = checklist.length;
const { data, error } = await supabase
@@ -429,10 +506,118 @@
<!-- Tags -->
<div>
<span
class="px-3 font-bold font-body text-body text-white mb-2 block"
>Tags</span
>
<div class="flex items-center justify-between mb-2">
<span class="px-3 font-bold font-body text-body text-white"
>Tags</span
>
<button
type="button"
class="text-xs text-light/40 hover:text-light transition-colors"
onclick={() => (showTagManager = !showTagManager)}
>
{showTagManager ? "Done" : "Manage"}
</button>
</div>
{#if showTagManager}
<!-- Tag Manager: edit/delete/create tags -->
<div class="space-y-2 mb-3 p-3 bg-background rounded-2xl">
{#each orgTags as tag}
<div class="flex items-center gap-2 group">
{#if editingTagId === tag.id}
<div class="flex items-center gap-2 flex-1">
<input
type="color"
class="w-6 h-6 rounded cursor-pointer border-0 bg-transparent"
bind:value={editTagColor}
/>
<input
type="text"
class="bg-dark border border-primary rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none"
bind:value={editTagName}
onkeydown={(e) => {
if (e.key === "Enter")
saveEditTag();
if (e.key === "Escape") {
editingTagId = null;
}
}}
/>
<button
type="button"
class="text-primary text-xs font-bold"
onclick={saveEditTag}>Save</button
>
<button
type="button"
class="text-light/40 text-xs"
onclick={() =>
(editingTagId = null)}
>Cancel</button
>
</div>
{:else}
<span
class="w-3 h-3 rounded-sm shrink-0"
style="background-color: {tag.color ||
'#00A3E0'}"
></span>
<span
class="text-sm text-light flex-1 truncate"
>{tag.name}</span
>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-light transition-all"
onclick={() => startEditTag(tag)}
aria-label="Edit tag"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>edit</span
>
</button>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-error transition-all"
onclick={() => deleteTag(tag.id)}
aria-label="Delete tag"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>delete</span
>
</button>
{/if}
</div>
{/each}
<!-- Inline create new tag -->
<div
class="flex items-center gap-2 pt-1 border-t border-light/10"
>
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none focus:border-primary"
placeholder="New tag name..."
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
/>
<button
type="button"
class="text-primary text-xs font-bold hover:text-primary/80 whitespace-nowrap"
onclick={createTag}
disabled={!newTagName.trim()}
>
+ Add
</button>
</div>
</div>
{/if}
<!-- Tag toggle chips -->
<div class="flex flex-wrap gap-2 items-center">
{#each orgTags as tag}
<button
@@ -450,42 +635,44 @@
{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()}
/>
{#if !showTagManager}
{#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
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={() => {
showTagInput = false;
newTagName = "";
}}
>
Cancel
</button>
</div>
{:else}
<button
type="button"
class="text-primary text-sm font-bold hover:text-primary/80"
onclick={createTag}
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)}
>
Add
+ New tag
</button>
<button
type="button"
class="text-light/40 text-sm hover:text-light"
onclick={() => {
showTagInput = false;
newTagName = "";
}}
>
Cancel
</button>
</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}
{/if}
</div>
</div>

View File

@@ -2,6 +2,7 @@
import type { ColumnWithCards } from "$lib/api/kanban";
import type { KanbanCard } from "$lib/supabase/types";
import KanbanCardComponent from "./KanbanCard.svelte";
import { Button } from "$lib/components/ui";
interface Props {
columns: ColumnWithCards[];
@@ -15,6 +16,7 @@
onAddColumn?: () => void;
onDeleteCard?: (cardId: string) => void;
onDeleteColumn?: (columnId: string) => void;
onRenameColumn?: (columnId: string, newName: string) => void;
canEdit?: boolean;
}
@@ -26,9 +28,37 @@
onAddColumn,
onDeleteCard,
onDeleteColumn,
onRenameColumn,
canEdit = true,
}: Props = $props();
let columnMenuId = $state<string | null>(null);
let renamingColumnId = $state<string | null>(null);
let renameValue = $state("");
function openColumnMenu(columnId: string) {
columnMenuId = columnMenuId === columnId ? null : columnId;
}
function startRename(column: ColumnWithCards) {
renameValue = column.name;
renamingColumnId = column.id;
columnMenuId = null;
}
function confirmRename() {
if (renamingColumnId && renameValue.trim()) {
onRenameColumn?.(renamingColumnId, renameValue.trim());
}
renamingColumnId = null;
renameValue = "";
}
function cancelRename() {
renamingColumnId = null;
renameValue = "";
}
let draggedCard = $state<KanbanCard | null>(null);
let dragOverColumn = $state<string | null>(null);
let dragOverCardIndex = $state<{ columnId: string; index: number } | null>(
@@ -58,7 +88,6 @@
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;
@@ -84,7 +113,6 @@
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,
@@ -92,7 +120,6 @@
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;
@@ -107,10 +134,13 @@
}
</script>
<div class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll">
{#each columns as column}
<div
class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll"
role="presentation"
>
{#each columns as column, colIndex (column.id)}
<div
class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full {dragOverColumn ===
class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full transition-opacity {dragOverColumn ===
column.id
? 'ring-2 ring-primary'
: ''}"
@@ -120,11 +150,25 @@
role="list"
>
<!-- Column Header -->
<div class="flex items-center gap-2 p-1 rounded-[32px]">
<div class="flex items-center gap-1 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>
{#if renamingColumnId === column.id}
<input
type="text"
class="bg-dark border border-primary rounded-lg px-2 py-1 text-white font-heading text-h4 w-full focus:outline-none"
bind:value={renameValue}
onkeydown={(e) => {
if (e.key === "Enter") confirmRename();
if (e.key === "Escape") cancelRename();
}}
onblur={confirmRename}
autofocus
/>
{:else}
<h3 class="font-heading text-h4 text-white truncate">
{column.name}
</h3>
{/if}
<div
class="bg-dark flex items-center justify-center px-1.5 py-0.5 rounded-[8px] shrink-0"
>
@@ -133,19 +177,62 @@
>
</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>
</button>
{#if canEdit}
<div class="relative shrink-0">
<button
type="button"
class="p-1 hover:bg-night rounded-full transition-colors"
onclick={() => openColumnMenu(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>
</button>
{#if columnMenuId === column.id}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-40"
onclick={() => (columnMenuId = null)}
></div>
<div
class="absolute right-0 top-full mt-1 bg-night border border-light/10 rounded-2xl shadow-xl z-50 py-1 min-w-[160px]"
>
<button
type="button"
class="w-full px-4 py-2.5 text-left text-sm text-white hover:bg-dark transition-colors flex items-center gap-3"
onclick={() => startRename(column)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>edit</span
>
Rename
</button>
<button
type="button"
class="w-full px-4 py-2.5 text-left text-sm text-error hover:bg-dark transition-colors flex items-center gap-3"
onclick={() => {
columnMenuId = null;
onDeleteColumn?.(column.id);
}}
>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>delete</span
>
Delete
</button>
</div>
{/if}
</div>
{/if}
</div>
<!-- Cards -->
@@ -182,34 +269,31 @@
{/if}
</div>
<!-- Add Card Button (secondary style) -->
<!-- Add Card Button -->
{#if canEdit}
<button
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"
<Button
variant="secondary"
fullWidth
icon="add"
onclick={() => onAddCard?.(column.id)}
>
Add card
</button>
</Button>
{/if}
</div>
{/each}
<!-- Add Column Button -->
{#if canEdit}
<button
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?.()}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
<div class="flex-shrink-0 w-[256px]">
<Button
variant="secondary"
fullWidth
icon="add"
onclick={() => onAddColumn?.()}
>
add
</span>
Add column
</button>
Add column
</Button>
</div>
{/if}
</div>

View File

@@ -67,7 +67,7 @@
{#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"
class="absolute top-1 right-1 p-1 rounded-full opacity-0 group-hover:opacity-100 hover:bg-error/20 transition-all z-10"
onclick={handleDelete}
aria-label="Delete card"
>
@@ -95,7 +95,7 @@
{/if}
<!-- Title -->
<p class="font-body text-body text-white w-full leading-none">
<p class="font-body text-body text-white w-full leading-none p-1">
{card.title}
</p>

View File

@@ -0,0 +1,369 @@
<script lang="ts">
import { Button, Modal, Card, Input } from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import {
extractCalendarId,
getCalendarSubscribeUrl,
} from "$lib/api/google-calendar";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
interface OrgCalendar {
id: string;
org_id: string;
calendar_id: string;
calendar_name: string | null;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
userId: string;
orgCalendar: OrgCalendar | null;
initialShowConnect?: boolean;
serviceAccountEmail?: string | null;
}
let {
supabase,
orgId,
userId,
orgCalendar = $bindable(),
initialShowConnect = false,
serviceAccountEmail = null,
}: Props = $props();
let emailCopied = $state(false);
async function copyServiceEmail() {
if (!serviceAccountEmail) return;
await navigator.clipboard.writeText(serviceAccountEmail);
emailCopied = true;
setTimeout(() => (emailCopied = false), 2000);
}
let showConnectModal = $state(initialShowConnect);
let isLoading = $state(false);
let calendarUrlInput = $state("");
let calendarError = $state<string | null>(null);
async function handleSaveOrgCalendar() {
if (!calendarUrlInput.trim()) return;
isLoading = true;
calendarError = null;
const calendarId = extractCalendarId(calendarUrlInput.trim());
if (!calendarId) {
calendarError =
"Invalid calendar URL or ID. Please paste a Google Calendar share URL or calendar ID.";
isLoading = false;
return;
}
let calendarName = "Google Calendar";
if (calendarId.includes("@group.calendar.google.com")) {
calendarName = "Shared Calendar";
} else if (calendarId.includes("@gmail.com")) {
calendarName = calendarId.split("@")[0] + "'s Calendar";
}
const { data: newCal, error } = await supabase
.from("org_google_calendars")
.upsert(
{
org_id: orgId,
calendar_id: calendarId,
calendar_name: calendarName,
connected_by: userId,
},
{ onConflict: "org_id" },
)
.select()
.single();
if (error) {
calendarError = "Failed to save calendar.";
} else if (newCal) {
orgCalendar = newCal as OrgCalendar;
calendarUrlInput = "";
}
showConnectModal = false;
isLoading = false;
}
async function disconnectOrgCalendar() {
if (!confirm("Disconnect Google Calendar?")) return;
const { error } = await supabase
.from("org_google_calendars")
.delete()
.eq("org_id", orgId);
if (error) {
toasts.error(m.toast_error_disconnect_cal());
return;
}
orgCalendar = null;
}
</script>
<div class="space-y-6 max-w-2xl">
<Card>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-white rounded-lg flex items-center justify-center"
>
<svg class="w-8 h-8" viewBox="0 0 24 24">
<path
fill="#4285F4"
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="#34A853"
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="#FBBC05"
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="#EA4335"
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>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-light">
Google Calendar
</h3>
<p class="text-sm text-light/50 mt-1">
Sync events between your organization and Google
Calendar.
</p>
{#if orgCalendar}
<div
class="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg"
>
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-green-500/10 rounded-lg"
>
<div class="min-w-0 flex-1">
<p
class="text-sm font-medium text-green-400"
>
Connected
</p>
<p class="text-light font-medium">
{orgCalendar.calendar_name ||
"Google Calendar"}
</p>
<p
class="text-xs text-light/50 truncate"
title={orgCalendar.calendar_id}
>
{orgCalendar.calendar_id}
</p>
<p class="text-xs text-light/40 mt-1">
Events sync both ways — create here or
in Google Calendar.
</p>
<a
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(
orgCalendar.calendar_id,
)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 mt-2"
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
/>
<polyline points="15 3 21 3 21 9" />
<line
x1="10"
y1="14"
x2="21"
y2="3"
/>
</svg>
Open in Google Calendar
</a>
</div>
<Button
variant="danger"
size="sm"
onclick={disconnectOrgCalendar}
>Disconnect</Button
>
</div>
</div>
{:else if !serviceAccountEmail}
<div
class="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
>
<p class="text-sm text-yellow-400 font-medium">
Setup required
</p>
<p class="text-xs text-light/50 mt-1">
A server administrator needs to configure the <code
class="bg-light/10 px-1 rounded"
>GOOGLE_SERVICE_ACCOUNT_KEY</code
> environment variable before calendars can be connected.
</p>
</div>
{:else}
<div class="mt-4">
<Button onclick={() => (showConnectModal = true)}
>Connect Google Calendar</Button
>
</div>
{/if}
</div>
</div>
</div>
</Card>
<Card>
<div class="p-6 opacity-50">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-[#7289da] rounded-lg flex items-center justify-center"
>
<svg
class="w-7 h-7 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-light">Discord</h3>
<p class="text-sm text-light/50 mt-1">
Get notifications in your Discord server.
</p>
<p class="text-xs text-light/40 mt-2">Coming soon</p>
</div>
</div>
</div>
</Card>
<Card>
<div class="p-6 opacity-50">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-[#4A154B] rounded-lg flex items-center justify-center"
>
<svg
class="w-7 h-7 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52a2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521a2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521a2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523a2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"
/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-light">Slack</h3>
<p class="text-sm text-light/50 mt-1">
Get notifications in your Slack workspace.
</p>
<p class="text-xs text-light/40 mt-2">Coming soon</p>
</div>
</div>
</div>
</Card>
</div>
<!-- Connect Calendar Modal -->
<Modal
isOpen={showConnectModal}
onClose={() => (showConnectModal = false)}
title="Connect Google Calendar"
>
<div class="space-y-4">
<p class="text-sm text-light/70">
Connect any Google Calendar to your organization. Events you create
here will automatically appear in Google Calendar and vice versa.
</p>
<!-- Step 1: Share with service account -->
{#if serviceAccountEmail}
<div
class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg"
>
<p class="text-blue-400 font-medium text-sm mb-2">
Step 1: Share your calendar
</p>
<p class="text-xs text-light/60 mb-2">
In Google Calendar, go to your calendar's settings → "Share
with specific people" → add this email with <strong
>"Make changes to events"</strong
> permission:
</p>
<div class="flex items-center gap-2">
<code
class="flex-1 text-xs bg-light/10 px-3 py-2 rounded-lg text-light/80 truncate"
title={serviceAccountEmail}
>
{serviceAccountEmail}
</code>
<Button
size="sm"
variant="tertiary"
onclick={copyServiceEmail}
>
{emailCopied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
{/if}
<!-- Step 2: Paste calendar ID -->
<div class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p class="text-blue-400 font-medium text-sm mb-2">
{serviceAccountEmail ? "Step 2" : "Step 1"}: Paste your Calendar
ID
</p>
<p class="text-xs text-light/60 mb-2">
In your calendar settings, scroll to "Integrate calendar" and
copy the <strong>Calendar ID</strong>.
</p>
</div>
<Input
label="Calendar ID"
bind:value={calendarUrlInput}
placeholder="e.g. abc123@group.calendar.google.com"
/>
{#if calendarError}
<p class="text-red-400 text-sm">{calendarError}</p>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showConnectModal = false)}>Cancel</Button
>
<Button
onclick={handleSaveOrgCalendar}
loading={isLoading}
disabled={!calendarUrlInput.trim()}>Connect</Button
>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,398 @@
<script lang="ts">
import {
Button,
Modal,
Card,
Input,
Select,
Avatar,
} from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
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;
invited_at: string;
profiles: ProfileData | ProfileData[] | null;
}
interface OrgRole {
id: string;
org_id: string;
name: string;
color: string;
permissions: string[];
is_default: boolean;
is_system: boolean;
position: number;
}
interface Invite {
id: string;
email: string;
role: string;
role_id: string | null;
token: string;
expires_at: string;
created_at: string;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
userId: string;
members: Member[];
roles: OrgRole[];
invites: Invite[];
}
let {
supabase,
orgId,
userId,
members = $bindable(),
roles,
invites = $bindable(),
}: Props = $props();
let showInviteModal = $state(false);
let inviteEmail = $state("");
let inviteRole = $state("editor");
let isSendingInvite = $state(false);
let showMemberModal = $state(false);
let selectedMember = $state<Member | null>(null);
let selectedMemberRole = $state("");
async function sendInvite() {
if (!inviteEmail.trim()) return;
isSendingInvite = true;
const email = inviteEmail.toLowerCase().trim();
// Delete any existing invite for this email first (handles 409 conflict)
await supabase
.from("org_invites")
.delete()
.eq("org_id", orgId)
.eq("email", email);
const { data: invite, error } = await supabase
.from("org_invites")
.insert({
org_id: orgId,
email,
role: inviteRole,
invited_by: userId,
expires_at: new Date(
Date.now() + INVITE_EXPIRY_MS,
).toISOString(),
})
.select()
.single();
if (!error && invite) {
invites = invites.filter((i) => i.email !== email);
invites = [...invites, invite as Invite];
inviteEmail = "";
showInviteModal = false;
} else if (error) {
toasts.error(m.toast_error_invite({ error: error.message }));
}
isSendingInvite = false;
}
async function cancelInvite(inviteId: string) {
await supabase.from("org_invites").delete().eq("id", inviteId);
invites = invites.filter((i) => i.id !== inviteId);
}
function openMemberModal(member: Member) {
selectedMember = member;
selectedMemberRole = member.role;
showMemberModal = true;
}
async function updateMemberRole() {
if (!selectedMember) return;
const { error } = await supabase
.from("org_members")
.update({ role: selectedMemberRole })
.eq("id", selectedMember.id);
if (error) {
toasts.error(m.toast_error_update_role());
return;
}
members = members.map((m) =>
m.id === selectedMember!.id
? { ...m, role: selectedMemberRole }
: m,
);
showMemberModal = false;
}
async function removeMember() {
if (!selectedMember) return;
const rp = selectedMember.profiles;
const prof = Array.isArray(rp) ? rp[0] : rp;
if (
!confirm(
`Remove ${prof?.full_name || prof?.email || "this member"} from the organization?`,
)
)
return;
const { error } = await supabase
.from("org_members")
.delete()
.eq("id", selectedMember.id);
if (error) {
toasts.error(m.toast_error_remove_member());
return;
}
members = members.filter((m) => m.id !== selectedMember!.id);
showMemberModal = false;
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-light">
{m.settings_members_title({
count: String(members.length),
})}
</h2>
<Button onclick={() => (showInviteModal = true)}>
<svg
class="w-4 h-4 mr-2"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><line x1="19" y1="8" x2="19" y2="14" /><line
x1="22"
y1="11"
x2="16"
y2="11"
/>
</svg>
{m.settings_members_invite()}
</Button>
</div>
<!-- Pending Invites -->
{#if invites.length > 0}
<Card>
<div class="p-4">
<h3 class="text-sm font-medium text-light/70 mb-3">
{m.settings_members_pending()}
</h3>
<div class="space-y-2">
{#each invites as invite}
<div
class="flex items-center justify-between py-2 px-3 bg-light/5 rounded-lg"
>
<div>
<p class="text-light">{invite.email}</p>
<p class="text-xs text-light/40">
Invited as {invite.role} • Expires {new Date(
invite.expires_at,
).toLocaleDateString()}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="tertiary"
size="sm"
onclick={() =>
navigator.clipboard.writeText(
`${window.location.origin}/invite/${invite.token}`,
)}
>{m.settings_members_copy_link()}</Button
>
<Button
variant="danger"
size="sm"
onclick={() => cancelInvite(invite.id)}
>Cancel</Button
>
</div>
</div>
{/each}
</div>
</div>
</Card>
{/if}
<!-- Members List -->
<Card>
<div class="divide-y divide-light/10">
{#each members as member}
{@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"
>
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
>
{(profile?.full_name ||
profile?.email ||
"?")[0].toUpperCase()}
</div>
<div>
<p class="text-light font-medium">
{profile?.full_name ||
profile?.email ||
"Unknown User"}
</p>
<p class="text-sm text-light/50">
{profile?.email || "No email"}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span
class="px-2 py-1 text-xs rounded-full capitalize"
style="background-color: {roles.find(
(r) => r.name.toLowerCase() === member.role,
)?.color ?? '#6366f1'}20; color: {roles.find(
(r) => r.name.toLowerCase() === member.role,
)?.color ?? '#6366f1'}">{member.role}</span
>
{#if member.user_id !== userId && member.role !== "owner"}
<Button
variant="tertiary"
size="sm"
onclick={() => openMemberModal(member)}
>Edit</Button
>
{/if}
</div>
</div>
{/each}
</div>
</Card>
</div>
<!-- Invite Member Modal -->
<Modal
isOpen={showInviteModal}
onClose={() => (showInviteModal = false)}
title="Invite Member"
>
<div class="space-y-4">
<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="tertiary" onclick={() => (showInviteModal = false)}
>Cancel</Button
>
<Button
onclick={sendInvite}
loading={isSendingInvite}
disabled={!inviteEmail.trim()}>Send Invite</Button
>
</div>
</div>
</Modal>
<!-- Edit Member Modal -->
<Modal
isOpen={showMemberModal}
onClose={() => (showMemberModal = false)}
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"
>
{(memberProfile?.full_name ||
memberProfile?.email ||
"?")[0].toUpperCase()}
</div>
<div>
<p class="text-light font-medium">
{memberProfile?.full_name || "No name"}
</p>
<p class="text-sm text-light/50">
{memberProfile?.email || "No email"}
</p>
</div>
</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="tertiary"
onclick={() => (showMemberModal = false)}>Cancel</Button
>
<Button onclick={updateMemberRole}>Save</Button>
</div>
</div>
</div>
{/if}
</Modal>

View File

@@ -0,0 +1,350 @@
<script lang="ts">
import { Button, Modal, Card, Input } from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
interface OrgRole {
id: string;
org_id: string;
name: string;
color: string;
permissions: string[];
is_default: boolean;
is_system: boolean;
position: number;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
roles: OrgRole[];
}
let { supabase, orgId, roles = $bindable() }: Props = $props();
let showRoleModal = $state(false);
let editingRole = $state<OrgRole | null>(null);
let newRoleName = $state("");
let newRoleColor = $state("#6366f1");
let newRolePermissions = $state<string[]>([]);
let isSavingRole = $state(false);
const permissionGroups = [
{
name: "Documents",
permissions: [
"documents.view",
"documents.create",
"documents.edit",
"documents.delete",
],
},
{
name: "Kanban",
permissions: [
"kanban.view",
"kanban.create",
"kanban.edit",
"kanban.delete",
],
},
{
name: "Calendar",
permissions: [
"calendar.view",
"calendar.create",
"calendar.edit",
"calendar.delete",
],
},
{
name: "Members",
permissions: [
"members.view",
"members.invite",
"members.manage",
"members.remove",
],
},
{
name: "Roles",
permissions: [
"roles.view",
"roles.create",
"roles.edit",
"roles.delete",
],
},
{ name: "Settings", permissions: ["settings.view", "settings.edit"] },
];
const roleColors = [
{ value: "#ef4444", label: "Red" },
{ value: "#f59e0b", label: "Amber" },
{ value: "#10b981", label: "Emerald" },
{ value: "#3b82f6", label: "Blue" },
{ value: "#6366f1", label: "Indigo" },
{ value: "#8b5cf6", label: "Violet" },
{ value: "#ec4899", label: "Pink" },
{ value: "#6b7280", label: "Gray" },
];
function openRoleModal(role?: OrgRole) {
if (role) {
editingRole = role;
newRoleName = role.name;
newRoleColor = role.color;
newRolePermissions = [...role.permissions];
} else {
editingRole = null;
newRoleName = "";
newRoleColor = "#6366f1";
newRolePermissions = [
"documents.view",
"kanban.view",
"calendar.view",
"members.view",
];
}
showRoleModal = true;
}
async function saveRole() {
if (!newRoleName.trim()) return;
isSavingRole = true;
if (editingRole) {
const { error } = await supabase
.from("org_roles")
.update({
name: newRoleName,
color: newRoleColor,
permissions: newRolePermissions,
})
.eq("id", editingRole.id);
if (!error) {
roles = roles.map((r) =>
r.id === editingRole!.id
? {
...r,
name: newRoleName,
color: newRoleColor,
permissions: newRolePermissions,
}
: r,
);
}
} else {
const { data: role, error } = await supabase
.from("org_roles")
.insert({
org_id: orgId,
name: newRoleName,
color: newRoleColor,
permissions: newRolePermissions,
position: roles.length,
})
.select()
.single();
if (!error && role) {
roles = [...roles, role as OrgRole];
}
}
showRoleModal = false;
isSavingRole = false;
}
async function deleteRole(role: OrgRole) {
if (role.is_system) return;
if (
!confirm(
`Delete role "${role.name}"? Members with this role will need to be reassigned.`,
)
)
return;
const { error } = await supabase
.from("org_roles")
.delete()
.eq("id", role.id);
if (error) {
toasts.error(m.toast_error_delete_role());
return;
}
roles = roles.filter((r) => r.id !== role.id);
}
function togglePermission(perm: string) {
if (newRolePermissions.includes(perm)) {
newRolePermissions = newRolePermissions.filter((p) => p !== perm);
} else {
newRolePermissions = [...newRolePermissions, perm];
}
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-light">Roles</h2>
<p class="text-sm text-light/50">
Create custom roles with specific permissions.
</p>
</div>
<Button onclick={() => openRoleModal()} icon="add">
Create Role
</Button>
</div>
<div class="grid gap-4">
{#each roles as role}
<Card>
<div class="p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full"
style="background-color: {role.color}"
></div>
<span class="font-medium text-light"
>{role.name}</span
>
{#if role.is_system}
<span
class="text-xs text-light/40 bg-light/10 px-2 py-0.5 rounded"
>System</span
>
{/if}
{#if role.is_default}
<span
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded"
>Default</span
>
{/if}
</div>
<div class="flex items-center gap-2">
{#if !role.is_system || role.name !== "Owner"}
<Button
variant="tertiary"
size="sm"
onclick={() => openRoleModal(role)}
>Edit</Button
>
{/if}
{#if !role.is_system}
<Button
variant="danger"
size="sm"
onclick={() => deleteRole(role)}
>Delete</Button
>
{/if}
</div>
</div>
<div class="flex flex-wrap gap-1">
{#if role.permissions.includes("*")}
<span
class="text-xs bg-light/10 text-light/70 px-2 py-1 rounded"
>All Permissions</span
>
{:else}
{#each role.permissions.slice(0, 6) as perm}
<span
class="text-xs bg-light/10 text-light/50 px-2 py-1 rounded"
>{perm}</span
>
{/each}
{#if role.permissions.length > 6}
<span class="text-xs text-light/40"
>+{role.permissions.length - 6} more</span
>
{/if}
{/if}
</div>
</div>
</Card>
{/each}
</div>
</div>
<!-- Edit/Create Role Modal -->
<Modal
isOpen={showRoleModal}
onClose={() => (showRoleModal = false)}
title={editingRole ? "Edit Role" : "Create Role"}
>
<div class="space-y-4">
<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
>
<div class="flex gap-2">
{#each roleColors as color}
<button
type="button"
class="w-8 h-8 rounded-full transition-transform {newRoleColor ===
color.value
? 'ring-2 ring-white scale-110'
: ''}"
style="background-color: {color.value}"
onclick={() => (newRoleColor = color.value)}
title={color.label}
></button>
{/each}
</div>
</div>
<div>
<label class="block text-sm font-medium text-light mb-2"
>Permissions</label
>
<div class="space-y-3 max-h-64 overflow-y-auto">
{#each permissionGroups as group}
<div class="p-3 bg-light/5 rounded-lg">
<p class="text-sm font-medium text-light mb-2">
{group.name}
</p>
<div class="grid grid-cols-2 gap-2">
{#each group.permissions as perm}
<label
class="flex items-center gap-2 text-sm text-light/70 cursor-pointer"
>
<input
type="checkbox"
checked={newRolePermissions.includes(
perm,
)}
onchange={() => togglePermission(perm)}
class="rounded"
/>
{perm.split(".")[1]}
</label>
{/each}
</div>
</div>
{/each}
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showRoleModal = false)}
>Cancel</Button
>
<Button
onclick={saveRole}
loading={isSavingRole}
disabled={!newRoleName.trim()}
>{editingRole ? "Save" : "Create"}</Button
>
</div>
</div>
</Modal>

View File

@@ -1 +1,4 @@
export { default as SettingsGeneral } from './SettingsGeneral.svelte';
export { default as SettingsMembers } from './SettingsMembers.svelte';
export { default as SettingsRoles } from './SettingsRoles.svelte';
export { default as SettingsIntegrations } from './SettingsIntegrations.svelte';

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { on } from "svelte/events";
interface MenuItem {
label: string;
icon?: string;
onclick: () => void;
danger?: boolean;
divider?: boolean;
}
interface Props {
items: MenuItem[];
align?: "left" | "right";
}
let { items, align = "right" }: Props = $props();
let isOpen = $state(false);
let containerEl = $state<HTMLElement | null>(null);
// Attach click-outside and Escape listeners only while menu is open
$effect(() => {
if (!isOpen) return;
let cleanupClick: (() => void) | undefined;
const timer = setTimeout(() => {
cleanupClick = on(document, "click", (e: MouseEvent) => {
if (containerEl && !containerEl.contains(e.target as Node)) {
isOpen = false;
}
});
}, 0);
const cleanupKey = on(document, "keydown", (e: Event) => {
if ((e as KeyboardEvent).key === "Escape") isOpen = false;
});
return () => {
clearTimeout(timer);
cleanupClick?.();
cleanupKey();
};
});
function handleItemClick(item: MenuItem) {
item.onclick();
isOpen = false;
}
</script>
<div class="relative context-menu-container" bind:this={containerEl}>
<button
type="button"
class="w-8 h-8 flex items-center justify-center rounded-full hover:bg-light/10 transition-colors"
onclick={() => (isOpen = !isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
aria-label="More options"
>
<span
class="material-symbols-rounded text-light/60 hover:text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
more_horiz
</span>
</button>
{#if isOpen}
<div
class="
absolute z-50 mt-1 py-1 bg-dark border border-light/10 rounded-xl shadow-xl min-w-[180px]
animate-in fade-in slide-in-from-top-2 duration-150
{align === 'right' ? 'right-0' : 'left-0'}
"
>
{#each items as item}
{#if item.divider}
<div class="border-t border-light/10 my-1"></div>
{/if}
<button
type="button"
class="
w-full flex items-center gap-3 px-3 py-2 text-sm text-left transition-colors
{item.danger ? 'text-error hover:bg-error/10' : 'text-light hover:bg-light/5'}
"
onclick={() => handleItemClick(item)}
>
{#if item.icon}
<span
class="material-symbols-rounded shrink-0 {item.danger
? 'text-error/60'
: 'text-light/50'}"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
{item.icon}
</span>
{/if}
<span class="flex-1">{item.label}</span>
</button>
{/each}
</div>
{/if}
</div>

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
onclick?: () => void;
variant?: 'ghost' | 'subtle' | 'solid';
size?: 'sm' | 'md' | 'lg';
variant?: "ghost" | "subtle" | "solid";
size?: "sm" | "md" | "lg";
disabled?: boolean;
title?: string;
class?: string;
@@ -14,29 +14,29 @@
let {
children,
onclick,
variant = 'ghost',
size = 'md',
variant = "ghost",
size = "md",
disabled = false,
title,
class: className = '',
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',
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',
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',
sm: "[&>svg]:w-4 [&>svg]:h-4",
md: "[&>svg]:w-5 [&>svg]:h-5",
lg: "[&>svg]:w-6 [&>svg]:h-6",
};
</script>
@@ -47,7 +47,7 @@
{title}
aria-label={title}
class="
inline-flex items-center justify-center rounded-lg transition-colors
inline-flex items-center justify-center rounded-full transition-colors cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed
{variantClasses[variant]}
{sizeClasses[size]}

View File

@@ -54,7 +54,7 @@
<!-- Add button -->
{#if onAddCard}
<Button variant="secondary" fullWidth onclick={onAddCard}>
<Button variant="secondary" fullWidth icon="add" onclick={onAddCard}>
Add card
</Button>
{/if}

View File

@@ -1,39 +1,54 @@
<script lang="ts">
interface Props {
size?: "sm" | "md";
size?: "sm" | "md" | "lg";
showText?: boolean;
}
let { size = "md" }: Props = $props();
let { size = "md", showText = false }: Props = $props();
const sizeClasses = {
sm: "w-10 h-10",
md: "w-12 h-12",
const iconSizes = {
sm: "w-8 h-8",
md: "w-10 h-10",
lg: "w-12 h-12",
};
const textSizes = {
sm: "text-[14px]",
md: "text-[18px]",
lg: "text-[22px]",
};
</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 class="flex items-center gap-2">
<div class="shrink-0 {iconSizes[size]} transition-all duration-300">
<svg
viewBox="0 0 38 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-full h-auto"
>
<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"
/>
<circle cx="11.5" cy="7.5" r="5" fill="#00A3E0" />
<circle cx="23.5" cy="7.5" r="5" fill="#00A3E0" />
<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>
{#if showText}
<span
class="font-heading {textSizes[
size
]} text-primary leading-none whitespace-nowrap transition-all duration-300"
>
Root
</span>
{/if}
</div>

View File

@@ -64,7 +64,7 @@
{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"
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-full transition-colors"
onclick={onClose}
aria-label="Close"
>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import Skeleton from "./Skeleton.svelte";
interface Props {
variant?: "default" | "kanban" | "files" | "calendar" | "settings";
}
let { variant = "default" }: Props = $props();
</script>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4 animate-in">
<!-- Header skeleton -->
<header class="flex items-center gap-2 p-1">
<Skeleton variant="text" width="200px" height="2rem" />
<div class="flex-1"></div>
<Skeleton variant="rectangular" width="80px" height="40px" class="rounded-[32px]" />
<Skeleton variant="circular" width="32px" height="32px" />
</header>
{#if variant === "kanban"}
<!-- Kanban skeleton: columns with cards -->
<div class="flex gap-2 flex-1 overflow-hidden">
{#each Array(3) as _}
<div class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4">
<div class="flex items-center gap-2">
<Skeleton variant="text" width="120px" height="1.25rem" />
<Skeleton variant="rectangular" width="24px" height="20px" class="rounded-[8px]" />
</div>
{#each Array(3) as __}
<Skeleton variant="card" height="80px" class="rounded-[16px]" />
{/each}
</div>
{/each}
</div>
{:else if variant === "files"}
<!-- Files skeleton: toolbar + grid -->
<div class="flex items-center gap-2">
<Skeleton variant="text" width="300px" height="2.5rem" class="rounded-[32px]" />
<div class="flex-1"></div>
<Skeleton variant="circular" width="36px" height="36px" />
<Skeleton variant="circular" width="36px" height="36px" />
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each Array(12) as _}
<Skeleton variant="card" height="120px" class="rounded-[16px]" />
{/each}
</div>
{:else if variant === "calendar"}
<!-- Calendar skeleton: nav + grid -->
<div class="flex items-center gap-2 px-2">
<Skeleton variant="circular" width="32px" height="32px" />
<Skeleton variant="text" width="200px" height="1.5rem" />
<Skeleton variant="circular" width="32px" height="32px" />
<div class="flex-1"></div>
<Skeleton variant="rectangular" width="200px" height="32px" class="rounded-[32px]" />
</div>
<div class="flex-1 bg-background rounded-xl p-2">
<div class="grid grid-cols-7 gap-2">
{#each Array(7) as _}
<Skeleton variant="text" width="100%" height="2rem" />
{/each}
</div>
<div class="grid grid-cols-7 gap-2 mt-2">
{#each Array(35) as _}
<Skeleton variant="rectangular" width="100%" height="80px" class="rounded-none" />
{/each}
</div>
</div>
{:else if variant === "settings"}
<!-- Settings skeleton: tabs + content -->
<div class="flex gap-2">
{#each Array(4) as _}
<Skeleton variant="rectangular" width="80px" height="36px" class="rounded-[32px]" />
{/each}
</div>
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-4">
<Skeleton variant="text" width="160px" height="1.5rem" />
<Skeleton variant="text" lines={3} />
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-[32px]" />
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-[32px]" />
</div>
{:else}
<!-- Default: overview-like skeleton -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
{#each Array(4) as _}
<Skeleton variant="card" height="100px" class="rounded-2xl" />
{/each}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1">
<div class="lg:col-span-2 bg-night rounded-2xl p-5 flex flex-col gap-3">
<Skeleton variant="text" width="160px" height="1.5rem" />
{#each Array(5) as _}
<div class="flex items-center gap-3 px-3 py-2">
<Skeleton variant="circular" width="24px" height="24px" />
<Skeleton variant="text" width="80%" height="1rem" />
</div>
{/each}
</div>
<div class="flex flex-col gap-6">
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<Skeleton variant="text" width="120px" height="1.5rem" />
{#each Array(3) as _}
<Skeleton variant="rectangular" width="100%" height="40px" class="rounded-xl" />
{/each}
</div>
</div>
</div>
{/if}
</div>
<style>
.animate-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -24,3 +24,5 @@ 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';
export { default as ContextMenu } from './ContextMenu.svelte';
export { default as PageSkeleton } from './PageSkeleton.svelte';

29
src/lib/types/layout.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { Organization, Profile } from '$lib/supabase/types';
export interface OrgMemberWithProfile {
id: string;
user_id: string | null;
role: string;
profiles: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
} | null;
}
export interface OrgLayoutData {
org: Organization;
userRole: string;
userPermissions: string[] | null;
members: OrgMemberWithProfile[];
recentActivity: unknown[];
stats: {
memberCount: number;
documentCount: number;
folderCount: number;
kanbanCount: number;
};
user: { id: string; email?: string };
profile: Pick<Profile, 'id' | 'email' | 'full_name' | 'avatar_url'>;
}

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createLogger, setLogLevel, getRecentLogs, clearRecentLogs, dumpLogs } from './logger';
describe('logger', () => {
beforeEach(() => {
clearRecentLogs();
setLogLevel('debug');
});
it('createLogger returns scoped logger with all levels', () => {
const log = createLogger('test.context');
expect(log.debug).toBeTypeOf('function');
expect(log.info).toBeTypeOf('function');
expect(log.warn).toBeTypeOf('function');
expect(log.error).toBeTypeOf('function');
});
it('logs are stored in recent logs buffer', () => {
const log = createLogger('test');
log.info('hello');
log.warn('warning');
const recent = getRecentLogs();
expect(recent).toHaveLength(2);
expect(recent[0].level).toBe('info');
expect(recent[0].context).toBe('test');
expect(recent[0].message).toBe('hello');
expect(recent[1].level).toBe('warn');
});
it('clearRecentLogs empties the buffer', () => {
const log = createLogger('test');
log.info('msg');
expect(getRecentLogs()).toHaveLength(1);
clearRecentLogs();
expect(getRecentLogs()).toHaveLength(0);
});
it('setLogLevel filters lower-priority logs', () => {
setLogLevel('warn');
const log = createLogger('test');
log.debug('should be filtered');
log.info('should be filtered');
log.warn('should appear');
log.error('should appear');
const recent = getRecentLogs();
expect(recent).toHaveLength(2);
expect(recent[0].level).toBe('warn');
expect(recent[1].level).toBe('error');
});
it('log entries include structured data', () => {
const log = createLogger('test');
log.info('with data', { data: { userId: '123' } });
const recent = getRecentLogs();
expect(recent[0].data).toEqual({ userId: '123' });
});
it('log entries include error objects', () => {
const log = createLogger('test');
const err = new Error('test error');
log.error('failed', { error: err });
const recent = getRecentLogs();
expect(recent[0].error).toBe(err);
});
it('log entries have ISO timestamp', () => {
const log = createLogger('test');
log.info('timestamped');
const recent = getRecentLogs();
expect(recent[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('dumpLogs formats entries as readable string', () => {
const log = createLogger('ctx');
log.info('message one', { data: { key: 'val' } });
log.error('message two');
const dump = dumpLogs();
expect(dump).toContain('[INFO]');
expect(dump).toContain('[ctx]');
expect(dump).toContain('message one');
expect(dump).toContain('"key": "val"');
expect(dump).toContain('[ERROR]');
expect(dump).toContain('message two');
});
it('getRecentLogs returns a copy, not the internal buffer', () => {
const log = createLogger('test');
log.info('msg');
const copy = getRecentLogs();
copy.push({ level: 'debug', context: 'fake', message: 'injected', timestamp: '' });
expect(getRecentLogs()).toHaveLength(1);
});
it('buffer caps at 100 entries', () => {
const log = createLogger('test');
for (let i = 0; i < 110; i++) {
log.info(`msg ${i}`);
}
const recent = getRecentLogs();
expect(recent).toHaveLength(100);
expect(recent[0].message).toBe('msg 10');
expect(recent[99].message).toBe('msg 109');
});
});

View File

@@ -40,8 +40,8 @@ const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
const DIM = '\x1b[2m';
// Minimum level to output — can be overridden
let minLevel: LogLevel = 'debug';
// Minimum level to output — debug in dev, info in prod
let minLevel: LogLevel = typeof window !== 'undefined' && window.location?.hostname === 'localhost' ? 'debug' : 'info';
function shouldLog(level: LogLevel): boolean {
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[minLevel];
@@ -80,16 +80,16 @@ function serverLog(entry: LogEntry) {
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);
@@ -103,10 +103,10 @@ function serverLog(entry: LogEntry) {
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);
@@ -124,7 +124,7 @@ function clientLog(entry: LogEntry) {
function log(level: LogLevel, context: string, message: string, extra?: { data?: unknown; error?: unknown }) {
if (!shouldLog(level)) return;
const entry: LogEntry = {
level,
context,
@@ -133,19 +133,19 @@ function log(level: LogLevel, context: string, message: string, 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;
}

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest';
import {
hasPermission,
hasAllPermissions,
hasAnyPermission,
resolvePermissions,
PERMISSIONS,
} from './permissions';
describe('hasPermission', () => {
it('owner has wildcard — grants any permission', () => {
expect(hasPermission('owner', null, PERMISSIONS.DOCUMENTS_VIEW)).toBe(true);
expect(hasPermission('owner', null, PERMISSIONS.SETTINGS_EDIT)).toBe(true);
expect(hasPermission('owner', null, PERMISSIONS.MEMBERS_REMOVE)).toBe(true);
});
it('admin has wildcard — grants any permission', () => {
expect(hasPermission('admin', null, PERMISSIONS.ROLES_DELETE)).toBe(true);
expect(hasPermission('admin', null, PERMISSIONS.KANBAN_CREATE)).toBe(true);
});
it('editor has limited permissions', () => {
expect(hasPermission('editor', null, PERMISSIONS.DOCUMENTS_VIEW)).toBe(true);
expect(hasPermission('editor', null, PERMISSIONS.DOCUMENTS_CREATE)).toBe(true);
expect(hasPermission('editor', null, PERMISSIONS.DOCUMENTS_DELETE)).toBe(false);
expect(hasPermission('editor', null, PERMISSIONS.MEMBERS_MANAGE)).toBe(false);
expect(hasPermission('editor', null, PERMISSIONS.SETTINGS_EDIT)).toBe(false);
});
it('viewer has read-only permissions', () => {
expect(hasPermission('viewer', null, PERMISSIONS.DOCUMENTS_VIEW)).toBe(true);
expect(hasPermission('viewer', null, PERMISSIONS.KANBAN_VIEW)).toBe(true);
expect(hasPermission('viewer', null, PERMISSIONS.DOCUMENTS_CREATE)).toBe(false);
expect(hasPermission('viewer', null, PERMISSIONS.KANBAN_EDIT)).toBe(false);
expect(hasPermission('viewer', null, PERMISSIONS.SETTINGS_VIEW)).toBe(false);
});
it('custom permissions override built-in role defaults', () => {
const custom = ['documents.view', 'documents.create', 'kanban.view'];
expect(hasPermission('viewer', custom, PERMISSIONS.DOCUMENTS_CREATE)).toBe(true);
expect(hasPermission('viewer', custom, PERMISSIONS.KANBAN_VIEW)).toBe(true);
expect(hasPermission('viewer', custom, PERMISSIONS.KANBAN_CREATE)).toBe(false);
});
it('wildcard in custom permissions grants everything', () => {
expect(hasPermission('viewer', ['*'], PERMISSIONS.SETTINGS_EDIT)).toBe(true);
});
it('unknown role with no custom permissions has no access', () => {
expect(hasPermission('unknown_role', null, PERMISSIONS.DOCUMENTS_VIEW)).toBe(false);
});
it('empty custom permissions array denies everything', () => {
expect(hasPermission('editor', [], PERMISSIONS.DOCUMENTS_VIEW)).toBe(false);
});
});
describe('hasAllPermissions', () => {
it('returns true when user has all requested permissions', () => {
expect(hasAllPermissions('owner', null, [
PERMISSIONS.DOCUMENTS_VIEW,
PERMISSIONS.DOCUMENTS_CREATE,
PERMISSIONS.SETTINGS_EDIT,
])).toBe(true);
});
it('returns false when user is missing one permission', () => {
expect(hasAllPermissions('editor', null, [
PERMISSIONS.DOCUMENTS_VIEW,
PERMISSIONS.DOCUMENTS_DELETE,
])).toBe(false);
});
it('returns true for empty permission list', () => {
expect(hasAllPermissions('viewer', null, [])).toBe(true);
});
});
describe('hasAnyPermission', () => {
it('returns true when user has at least one permission', () => {
expect(hasAnyPermission('viewer', null, [
PERMISSIONS.DOCUMENTS_DELETE,
PERMISSIONS.DOCUMENTS_VIEW,
])).toBe(true);
});
it('returns false when user has none of the permissions', () => {
expect(hasAnyPermission('viewer', null, [
PERMISSIONS.SETTINGS_EDIT,
PERMISSIONS.MEMBERS_MANAGE,
])).toBe(false);
});
it('returns false for empty permission list', () => {
expect(hasAnyPermission('owner', null, [])).toBe(false);
});
});
describe('resolvePermissions', () => {
it('returns built-in defaults when no custom permissions', () => {
const perms = resolvePermissions('viewer', null);
expect(perms).toContain('documents.view');
expect(perms).not.toContain('documents.create');
});
it('returns custom permissions when provided', () => {
const custom = ['documents.view', 'kanban.create'];
expect(resolvePermissions('viewer', custom)).toEqual(custom);
});
it('returns empty array for unknown role with no custom permissions', () => {
expect(resolvePermissions('unknown', null)).toEqual([]);
});
});

View File

@@ -0,0 +1,116 @@
/**
* Permission enforcement utility for role-based access control.
*
* Permission keys follow the pattern: `resource.action`
* e.g. "documents.view", "kanban.create", "members.manage"
*
* System roles (owner, admin) have wildcard "*" permission.
* Custom roles have explicit permission arrays stored on org_roles.
*/
/** All known permission keys */
export const PERMISSIONS = {
// Documents
DOCUMENTS_VIEW: 'documents.view',
DOCUMENTS_CREATE: 'documents.create',
DOCUMENTS_EDIT: 'documents.edit',
DOCUMENTS_DELETE: 'documents.delete',
// Kanban
KANBAN_VIEW: 'kanban.view',
KANBAN_CREATE: 'kanban.create',
KANBAN_EDIT: 'kanban.edit',
KANBAN_DELETE: 'kanban.delete',
// Calendar
CALENDAR_VIEW: 'calendar.view',
CALENDAR_CREATE: 'calendar.create',
CALENDAR_EDIT: 'calendar.edit',
CALENDAR_DELETE: 'calendar.delete',
// Members
MEMBERS_VIEW: 'members.view',
MEMBERS_INVITE: 'members.invite',
MEMBERS_MANAGE: 'members.manage',
MEMBERS_REMOVE: 'members.remove',
// Roles
ROLES_VIEW: 'roles.view',
ROLES_CREATE: 'roles.create',
ROLES_EDIT: 'roles.edit',
ROLES_DELETE: 'roles.delete',
// Settings
SETTINGS_VIEW: 'settings.view',
SETTINGS_EDIT: 'settings.edit',
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
/** Default permissions for built-in roles (when no custom org_role is assigned) */
const BUILTIN_ROLE_PERMISSIONS: Record<string, readonly string[]> = {
owner: ['*'],
admin: ['*'],
editor: [
'documents.view', 'documents.create', 'documents.edit',
'kanban.view', 'kanban.create', 'kanban.edit',
'calendar.view', 'calendar.create', 'calendar.edit',
'members.view',
],
viewer: [
'documents.view',
'kanban.view',
'calendar.view',
'members.view',
],
};
/**
* Check if a user has a specific permission.
*
* @param userRole - The user's built-in role string (owner/admin/editor/viewer)
* @param userPermissions - The user's resolved permission array from their org_role (if any)
* @param permission - The permission key to check
* @returns true if the user has the permission
*/
export function hasPermission(
userRole: string,
userPermissions: readonly string[] | null | undefined,
permission: Permission | string,
): boolean {
// If we have explicit permissions from a custom org_role, use those
const perms = userPermissions ?? BUILTIN_ROLE_PERMISSIONS[userRole] ?? [];
// Wildcard grants everything
if (perms.includes('*')) return true;
return perms.includes(permission);
}
/**
* Check if a user has ALL of the specified permissions.
*/
export function hasAllPermissions(
userRole: string,
userPermissions: readonly string[] | null | undefined,
permissions: readonly (Permission | string)[],
): boolean {
return permissions.every((p) => hasPermission(userRole, userPermissions, p));
}
/**
* Check if a user has ANY of the specified permissions.
*/
export function hasAnyPermission(
userRole: string,
userPermissions: readonly string[] | null | undefined,
permissions: readonly (Permission | string)[],
): boolean {
return permissions.some((p) => hasPermission(userRole, userPermissions, p));
}
/**
* Get the resolved permission array for a user.
* Useful for passing to components that need to check multiple permissions.
*/
export function resolvePermissions(
userRole: string,
userPermissions: readonly string[] | null | undefined,
): readonly string[] {
return userPermissions ?? BUILTIN_ROLE_PERMISSIONS[userRole] ?? [];
}

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { locales, localizeHref } from '$lib/paraglide/runtime';
import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
import { createClient } from "$lib/supabase";
@@ -6,11 +8,21 @@
import { ToastContainer } from "$lib/components/ui";
let { children, data } = $props();
const supabase = createClient();
setContext("supabase", supabase);
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
<ToastContainer />
<div style="display:none">
{#each locales as locale}
<a
href={localizeHref(page.url.pathname, { locale })}
>
{locale}
</a>
{/each}
</div>

View File

@@ -19,27 +19,17 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
error(404, 'Organization not found');
}
// Now fetch membership, members, and activity in parallel (all depend on org.id)
const [membershipResult, membersResult, activityResult] = await Promise.all([
// Now fetch membership, members, activity, and user profile in parallel (all depend on org.id)
const [membershipResult, membersResult, activityResult, profileResult, docCountResult, folderCountResult, kanbanCountResult] = await Promise.all([
locals.supabase
.from('org_members')
.select('role')
.select('role, role_id')
.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
)
`)
.select('id, user_id, role')
.eq('org_id', org.id)
.limit(10),
locals.supabase
@@ -58,23 +48,87 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
`)
.eq('org_id', org.id)
.order('created_at', { ascending: false })
.limit(10)
.limit(10),
locals.supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.eq('id', user.id)
.single(),
locals.supabase
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
.eq('type', 'document'),
locals.supabase
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
.eq('type', 'folder'),
locals.supabase
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
.eq('type', 'kanban')
]);
const { data: membership } = membershipResult;
const { data: members } = membersResult;
const { data: rawMembers } = membersResult;
const { data: recentActivity } = activityResult;
const { data: profile } = profileResult;
const stats = {
memberCount: (rawMembers ?? []).length,
documentCount: docCountResult.count ?? 0,
folderCount: folderCountResult.count ?? 0,
kanbanCount: kanbanCountResult.count ?? 0,
};
if (!membership) {
error(403, 'You are not a member of this organization');
}
// Resolve user's permissions from their custom org_role (if assigned)
let userPermissions: string[] | null = null;
if (membership.role_id) {
const { data: roleData } = await locals.supabase
.from('org_roles')
.select('permissions')
.eq('id', membership.role_id)
.single();
if (roleData?.permissions && Array.isArray(roleData.permissions)) {
userPermissions = roleData.permissions as string[];
}
}
// Fetch profiles separately since org_members.user_id FK points to auth.users, not profiles
const memberUserIds = (rawMembers ?? []).map(m => m.user_id).filter((id): id is string => id !== null);
let memberProfilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null }> = {};
if (memberUserIds.length > 0) {
const { data: memberProfiles } = await locals.supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.in('id', memberUserIds);
if (memberProfiles) {
memberProfilesMap = Object.fromEntries(memberProfiles.map(p => [p.id, p]));
}
}
const members = (rawMembers ?? []).map(m => ({
...m,
profiles: (m.user_id ? memberProfilesMap[m.user_id] : null) ?? null
}));
return {
org,
role: membership.role,
userRole: membership.role, // kept for backwards compat — same as role
members: members ?? [],
userRole: membership.role,
userPermissions,
members,
recentActivity: recentActivity ?? [],
user
stats,
user,
profile: profile ?? { id: user.id, email: user.email ?? '', full_name: null, avatar_url: null }
};
};

View File

@@ -1,7 +1,15 @@
<script lang="ts">
import { page, navigating } from "$app/stores";
import { goto } from "$app/navigation";
import type { Snippet } from "svelte";
import { Avatar, Logo } from "$lib/components/ui";
import { getContext } from "svelte";
import { on } from "svelte/events";
import { Avatar, Logo, PageSkeleton } from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { hasPermission, type Permission } from "$lib/utils/permissions";
import { setContext } from "svelte";
import * as m from "$lib/paraglide/messages";
interface Member {
id: string;
@@ -15,6 +23,13 @@
};
}
interface UserProfile {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
}
interface Props {
data: {
org: {
@@ -23,41 +38,96 @@
slug: string;
avatar_url?: string | null;
};
role: string;
userRole: string;
userPermissions: string[] | null;
members: Member[];
profile: UserProfile;
};
children: Snippet;
}
let { data, children }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
// Provide a permission checker via context so any child component can use it
const canAccess = (permission: Permission | string): boolean =>
hasPermission(data.userRole, data.userPermissions, permission);
setContext("canAccess", canAccess);
// 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);
// User dropdown
let showUserMenu = $state(false);
let menuContainerEl = $state<HTMLElement | null>(null);
// Attach click-outside and Escape listeners only while menu is open.
// Uses svelte/events 'on' to respect Svelte 5 event delegation order.
$effect(() => {
if (!showUserMenu) return;
// Defer so the opening click doesn't immediately close the menu
const timer = setTimeout(() => {
cleanupClick = on(document, "click", (e: MouseEvent) => {
if (
menuContainerEl &&
!menuContainerEl.contains(e.target as Node)
) {
showUserMenu = false;
}
});
}, 0);
const cleanupKey = on(document, "keydown", (e: Event) => {
if ((e as KeyboardEvent).key === "Escape") showUserMenu = false;
});
let cleanupClick: (() => void) | undefined;
return () => {
clearTimeout(timer);
cleanupClick?.();
cleanupKey();
};
});
async function handleLogout() {
await supabase.auth.signOut();
goto("/");
}
const navItems = $derived([
{
href: `/${data.org.slug}/documents`,
label: "Files",
icon: "cloud",
},
{
href: `/${data.org.slug}/calendar`,
label: "Calendar",
icon: "calendar_today",
},
// Only show settings for admins
...(isAdmin
...(canAccess("documents.view")
? [
{
href: `/${data.org.slug}/documents`,
label: m.nav_files(),
icon: "cloud",
},
]
: []),
...(canAccess("calendar.view")
? [
{
href: `/${data.org.slug}/calendar`,
label: m.nav_calendar(),
icon: "calendar_today",
},
]
: []),
// Settings requires settings.view or admin role
...(canAccess("settings.view")
? [
{
href: `/${data.org.slug}/settings`,
label: "Settings",
label: m.nav_settings(),
icon: "settings",
},
]
@@ -110,7 +180,7 @@
<p
class="text-body-sm text-white font-body capitalize whitespace-nowrap"
>
{data.role}
{data.userRole}
</p>
</div>
</a>
@@ -152,10 +222,107 @@
{/each}
</nav>
<!-- Logo at bottom -->
<div class="mt-auto">
<a href="/" title="Back to organizations">
<Logo size={sidebarCollapsed ? "sm" : "md"} />
<!-- User Section + Logo at bottom -->
<div class="mt-auto flex flex-col gap-3">
<!-- User Avatar + Quick Menu -->
<div
class="relative user-menu-container"
bind:this={menuContainerEl}
>
<button
type="button"
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors w-full"
onclick={() => (showUserMenu = !showUserMenu)}
aria-expanded={showUserMenu}
aria-haspopup="true"
>
<div
class="shrink-0 transition-all duration-300 {sidebarCollapsed
? 'w-8 h-8'
: 'w-10 h-10'}"
>
<Avatar
name={data.profile.full_name || data.profile.email}
src={data.profile.avatar_url}
size="sm"
/>
</div>
<div
class="min-w-0 flex-1 overflow-hidden text-left transition-all duration-300 {sidebarCollapsed
? 'opacity-0 max-w-0'
: 'opacity-100 max-w-[200px]'}"
>
<p
class="font-body text-body-sm text-white truncate whitespace-nowrap leading-tight"
>
{data.profile.full_name || "User"}
</p>
<p
class="font-body text-[11px] text-light/50 truncate whitespace-nowrap leading-tight"
>
{data.profile.email}
</p>
</div>
</button>
{#if showUserMenu}
<div
class="absolute bottom-full left-0 mb-2 py-1 bg-dark border border-light/10 rounded-xl shadow-xl min-w-[200px] z-50"
>
<a
href="/{data.org.slug}/account"
class="flex items-center gap-3 px-3 py-2 text-sm text-light hover:bg-light/5 transition-colors"
onclick={() => (showUserMenu = false)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
person
</span>
<span>{m.user_menu_account_settings()}</span>
</a>
<a
href="/"
class="flex items-center gap-3 px-3 py-2 text-sm text-light hover:bg-light/5 transition-colors"
onclick={() => (showUserMenu = false)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
swap_horiz
</span>
<span>{m.user_menu_switch_org()}</span>
</a>
<div class="border-t border-light/10 my-1"></div>
<button
type="button"
class="w-full flex items-center gap-3 px-3 py-2 text-sm text-error hover:bg-error/10 transition-colors"
onclick={handleLogout}
>
<span
class="material-symbols-rounded text-error/60"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
logout
</span>
<span>{m.user_menu_logout()}</span>
</button>
</div>
{/if}
</div>
<!-- Logo -->
<a
href="/"
title="Back to organizations"
class="flex items-center justify-center"
>
<Logo
size={sidebarCollapsed ? "sm" : "md"}
showText={!sidebarCollapsed}
/>
</a>
</div>
</aside>
@@ -163,17 +330,19 @@
<!-- Main Content Area -->
<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>
{@const target = $navigating.to?.url.pathname ?? ""}
{@const skeletonVariant = target.includes("/kanban")
? "kanban"
: target.includes("/documents")
? "files"
: target.includes("/calendar")
? "calendar"
: target.includes("/settings")
? "settings"
: "default"}
<PageSkeleton variant={skeletonVariant} />
{:else}
{@render children()}
{/if}
{@render children()}
</main>
</div>

View File

@@ -1,21 +1,369 @@
<script lang="ts">
import { Avatar, Card } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface ActivityEntry {
id: string;
action: string;
entity_type: string;
entity_id: string | null;
entity_name: string | null;
created_at: string | null;
profiles: {
full_name: string | null;
email: string | null;
} | null;
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
role: string;
userRole: string;
stats: {
memberCount: number;
documentCount: number;
folderCount: number;
kanbanCount: number;
};
recentActivity: ActivityEntry[];
members: {
id: string;
user_id: string;
role: string;
profiles: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
} | null;
}[];
};
}
let { data }: Props = $props();
const stats = $derived(
data.stats ?? {
memberCount: 0,
documentCount: 0,
folderCount: 0,
kanbanCount: 0,
},
);
const recentActivity = $derived(data.recentActivity ?? []);
const members = $derived(data.members ?? []);
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
const statCards = $derived([
{
label: m.overview_stat_members(),
value: stats.memberCount,
icon: "group",
href: isAdmin ? `/${data.org.slug}/settings` : null,
color: "text-blue-400",
bg: "bg-blue-400/10",
},
{
label: m.overview_stat_documents(),
value: stats.documentCount,
icon: "description",
href: `/${data.org.slug}/documents`,
color: "text-emerald-400",
bg: "bg-emerald-400/10",
},
{
label: m.overview_stat_folders(),
value: stats.folderCount,
icon: "folder",
href: `/${data.org.slug}/documents`,
color: "text-amber-400",
bg: "bg-amber-400/10",
},
{
label: m.overview_stat_boards(),
value: stats.kanbanCount,
icon: "view_kanban",
href: `/${data.org.slug}/documents`,
color: "text-purple-400",
bg: "bg-purple-400/10",
},
]);
const quickLinks = $derived([
{
label: m.nav_files(),
icon: "cloud",
href: `/${data.org.slug}/documents`,
},
{
label: m.nav_calendar(),
icon: "calendar_today",
href: `/${data.org.slug}/calendar`,
},
...(isAdmin
? [
{
label: m.nav_settings(),
icon: "settings",
href: `/${data.org.slug}/settings`,
},
]
: []),
]);
function getEntityTypeLabel(entityType: string): string {
const map: Record<string, () => string> = {
document: m.entity_document,
folder: m.entity_folder,
kanban_board: m.entity_kanban_board,
kanban_card: m.entity_kanban_card,
kanban_column: m.entity_kanban_column,
member: m.entity_member,
role: m.entity_role,
invite: m.entity_invite,
};
return (map[entityType] ?? (() => entityType))();
}
function getActivityIcon(action: string): string {
const map: Record<string, string> = {
create: "add_circle",
update: "edit",
delete: "delete",
move: "drive_file_move",
rename: "edit_note",
};
return map[action] ?? "info";
}
function getActivityColor(action: string): string {
const map: Record<string, string> = {
create: "text-emerald-400",
update: "text-blue-400",
delete: "text-red-400",
move: "text-amber-400",
rename: "text-purple-400",
};
return map[action] ?? "text-light/50";
}
function formatTimeAgo(dateStr: string | null): string {
if (!dateStr) return "";
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return m.activity_just_now();
if (diffMin < 60)
return m.activity_minutes_ago({ count: String(diffMin) });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return m.activity_hours_ago({ count: String(diffHr) });
const diffDay = Math.floor(diffHr / 24);
return m.activity_days_ago({ count: String(diffDay) });
}
function getActivityDescription(entry: ActivityEntry): string {
const userName =
entry.profiles?.full_name || entry.profiles?.email || "Someone";
const entityType = getEntityTypeLabel(entry.entity_type);
const name = entry.entity_name ?? "—";
const map: Record<string, () => string> = {
create: () =>
m.activity_created({ user: userName, entityType, name }),
update: () =>
m.activity_updated({ user: userName, entityType, name }),
delete: () =>
m.activity_deleted({ user: userName, entityType, name }),
move: () => m.activity_moved({ user: userName, entityType, name }),
rename: () =>
m.activity_renamed({ user: userName, entityType, name }),
};
return (map[entry.action] ?? map["update"]!)();
}
</script>
<svelte:head>
<title>{data.org.name} | Root</title>
</svelte:head>
<div class="p-4 lg:p-6">
<div class="flex flex-col h-full p-4 lg:p-5 gap-6 overflow-auto">
<!-- Header -->
<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>
<p class="text-body text-light/60 font-body">{m.overview_title()}</p>
</header>
<!-- Stats Grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
{#each statCards as stat}
{#if stat.href}
<a
href={stat.href}
class="bg-night rounded-2xl p-5 flex flex-col gap-3 hover:bg-night/80 transition-colors group"
>
<div
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {stat.color}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{stat.icon}
</span>
</div>
<div>
<p class="text-2xl font-bold text-white">
{stat.value}
</p>
<p class="text-body-sm text-light/50">{stat.label}</p>
</div>
</a>
{:else}
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<div
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {stat.color}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{stat.icon}
</span>
</div>
<div>
<p class="text-2xl font-bold text-white">
{stat.value}
</p>
<p class="text-body-sm text-light/50">{stat.label}</p>
</div>
</div>
{/if}
{/each}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
<!-- Recent Activity -->
<div
class="lg:col-span-2 bg-night rounded-2xl p-5 flex flex-col gap-4 min-h-0"
>
<h2 class="text-h3 font-heading text-white">
{m.activity_title()}
</h2>
{#if recentActivity.length === 0}
<div
class="flex-1 flex flex-col items-center justify-center text-light/40 py-12"
>
<span
class="material-symbols-rounded mb-3"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
history
</span>
<p class="text-body">{m.activity_empty()}</p>
</div>
{:else}
<div class="flex flex-col gap-1 overflow-auto flex-1">
{#each recentActivity as entry}
<div
class="flex items-start gap-3 px-3 py-2.5 rounded-xl hover:bg-dark/50 transition-colors"
>
<span
class="material-symbols-rounded {getActivityColor(
entry.action,
)} mt-0.5 shrink-0"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{getActivityIcon(entry.action)}
</span>
<div class="flex-1 min-w-0">
<p
class="text-body-sm text-light leading-relaxed"
>
{getActivityDescription(entry)}
</p>
<p class="text-[11px] text-light/40 mt-0.5">
{formatTimeAgo(entry.created_at)}
</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Sidebar: Quick Links + Members -->
<div class="flex flex-col gap-6">
<!-- Quick Links -->
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<h2 class="text-h3 font-heading text-white">
{m.overview_quick_links()}
</h2>
<div class="flex flex-col gap-1">
{#each quickLinks as link}
<a
href={link.href}
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-light hover:bg-dark/50 hover:text-white transition-colors"
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{link.icon}
</span>
<span class="text-body">{link.label}</span>
</a>
{/each}
</div>
</div>
<!-- Team Members Preview -->
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<div class="flex items-center justify-between">
<h2 class="text-h3 font-heading text-white">
{m.overview_stat_members()}
</h2>
<span class="text-body-sm text-light/40"
>{stats.memberCount}</span
>
</div>
<div class="flex flex-col gap-2">
{#each members.slice(0, 5) as member}
<div class="flex items-center gap-3 px-1 py-1">
<Avatar
name={member.profiles?.full_name ||
member.profiles?.email ||
"?"}
src={member.profiles?.avatar_url}
size="sm"
/>
<div class="flex-1 min-w-0">
<p class="text-body-sm text-white truncate">
{member.profiles?.full_name ||
member.profiles?.email ||
"Unknown"}
</p>
<p class="text-[11px] text-light/40 capitalize">
{member.role}
</p>
</div>
</div>
{/each}
{#if stats.memberCount > 5}
<a
href="/{data.org.slug}/settings"
class="text-body-sm text-primary hover:underline text-center pt-1"
>
+{stats.memberCount - 5} more
</a>
{/if}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
export const load: PageServerLoad = async ({ parent, locals }) => {
const { user } = await parent() as OrgLayoutData;
const [profileResult, prefsResult] = await Promise.all([
locals.supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single(),
locals.supabase
.from('user_preferences')
.select('*')
.eq('user_id', user.id)
.single()
]);
if (profileResult.error || !profileResult.data) {
error(500, 'Failed to load profile');
}
return {
profile: profileResult.data,
preferences: prefsResult.data
};
};

View File

@@ -0,0 +1,485 @@
<script lang="ts">
import { getContext } from "svelte";
import { invalidateAll } from "$app/navigation";
import { Button, Input, Avatar, Select } from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
import { getLocale, setLocale, locales } from "$lib/paraglide/runtime.js";
interface Props {
data: {
org: { id: string; slug: string };
profile: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
preferences: {
id: string;
theme: string | null;
accent_color: string | null;
sidebar_collapsed: boolean | null;
use_org_theme: boolean | null;
} | null;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
// Profile state
let fullName = $state(data.profile.full_name ?? "");
let avatarUrl = $state(data.profile.avatar_url ?? null);
let isSaving = $state(false);
let isUploading = $state(false);
let avatarInput = $state<HTMLInputElement | null>(null);
// Preferences state
let theme = $state(data.preferences?.theme ?? "dark");
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
let useOrgTheme = $state(data.preferences?.use_org_theme ?? true);
let currentLocale = $state<(typeof locales)[number]>(getLocale());
const localeLabels: Record<string, string> = {
en: "English",
et: "Eesti",
};
function handleLanguageChange(newLocale: (typeof locales)[number]) {
currentLocale = newLocale;
setLocale(newLocale);
}
$effect(() => {
fullName = data.profile.full_name ?? "";
avatarUrl = data.profile.avatar_url ?? null;
theme = data.preferences?.theme ?? "dark";
accentColor = data.preferences?.accent_color ?? "#00A3E0";
useOrgTheme = data.preferences?.use_org_theme ?? true;
});
// Try to extract Google avatar from auth metadata
async function syncGoogleAvatar() {
const {
data: { user },
} = await supabase.auth.getUser();
const googleAvatar =
user?.user_metadata?.avatar_url || user?.user_metadata?.picture;
if (!googleAvatar) {
toasts.error("No Google avatar found.");
return;
}
const { error } = await supabase
.from("profiles")
.update({ avatar_url: googleAvatar })
.eq("id", data.profile.id);
if (error) {
toasts.error("Failed to sync avatar.");
return;
}
avatarUrl = googleAvatar;
await invalidateAll();
toasts.success("Google avatar synced.");
}
async function handleAvatarUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
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 = `user-avatars/${data.profile.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("profiles")
.update({ avatar_url: publicUrl })
.eq("id", data.profile.id);
if (dbError) {
toasts.error("Failed to save avatar.");
return;
}
avatarUrl = publicUrl;
await invalidateAll();
toasts.success("Avatar updated.");
} catch {
toasts.error("Avatar upload failed.");
} finally {
isUploading = false;
input.value = "";
}
}
async function removeAvatar() {
const { error } = await supabase
.from("profiles")
.update({ avatar_url: null })
.eq("id", data.profile.id);
if (error) {
toasts.error("Failed to remove avatar.");
return;
}
avatarUrl = null;
await invalidateAll();
toasts.success("Avatar removed.");
}
async function saveProfile() {
isSaving = true;
const { error } = await supabase
.from("profiles")
.update({ full_name: fullName || null })
.eq("id", data.profile.id);
if (error) {
toasts.error("Failed to save profile.");
} else {
await invalidateAll();
toasts.success("Profile saved.");
}
isSaving = false;
}
async function savePreferences() {
isSaving = true;
const prefs = {
theme,
accent_color: accentColor,
use_org_theme: useOrgTheme,
user_id: data.profile.id,
};
if (data.preferences) {
const { error } = await supabase
.from("user_preferences")
.update(prefs)
.eq("id", data.preferences.id);
if (error) {
toasts.error("Failed to save preferences.");
isSaving = false;
return;
}
} else {
const { error } = await supabase
.from("user_preferences")
.insert(prefs);
if (error) {
toasts.error("Failed to save preferences.");
isSaving = false;
return;
}
}
await invalidateAll();
toasts.success("Preferences saved.");
isSaving = false;
}
const accentColors = [
{ value: "#00A3E0", label: "Blue (Default)" },
{ value: "#33E000", label: "Green" },
{ value: "#E03D00", label: "Red" },
{ value: "#FFAB00", label: "Amber" },
{ value: "#A855F7", label: "Purple" },
{ value: "#EC4899", label: "Pink" },
{ value: "#6366F1", label: "Indigo" },
{ value: "#14B8A6", label: "Teal" },
];
</script>
<svelte:head>
<title>Account Settings | Root</title>
</svelte:head>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<div>
<h1 class="font-heading text-h1 text-white">{m.account_title()}</h1>
<p class="font-body text-body text-light/60 mt-1">
{m.account_subtitle()}
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 flex-1 min-h-0">
<!-- Profile Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
{m.account_profile()}
</h2>
<!-- Avatar -->
<div class="flex flex-col gap-3">
<span class="font-body text-body-sm text-light"
>{m.account_photo()}</span
>
<div class="flex items-center gap-4">
<Avatar
name={fullName || data.profile.email}
src={avatarUrl}
size="xl"
/>
<div class="flex flex-col gap-2">
<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}
>
{m.btn_upload()}
</Button>
<Button
variant="tertiary"
size="sm"
onclick={syncGoogleAvatar}
>
{m.account_sync_google()}
</Button>
</div>
{#if avatarUrl}
<Button
variant="tertiary"
size="sm"
onclick={removeAvatar}
>
{m.account_remove_photo()}
</Button>
{/if}
</div>
</div>
</div>
<!-- Name -->
<Input
label={m.account_display_name()}
bind:value={fullName}
placeholder={m.account_display_name_placeholder()}
/>
<!-- Email (read-only) -->
<Input
label={m.account_email()}
value={data.profile.email}
disabled
/>
<div>
<Button onclick={saveProfile} loading={isSaving}>
{m.account_save_profile()}
</Button>
</div>
</div>
<!-- Appearance Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
{m.account_appearance()}
</h2>
<!-- Theme -->
<Select
label={m.account_theme()}
bind:value={theme}
placeholder=""
options={[
{ value: "dark", label: m.account_theme_dark() },
{ value: "light", label: m.account_theme_light() },
{ value: "system", label: m.account_theme_system() },
]}
/>
<!-- Accent Color -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light"
>{m.account_accent_color()}</span
>
<div class="flex flex-wrap gap-2 items-center">
{#each accentColors as color}
<button
type="button"
class="w-8 h-8 rounded-full border-2 transition-all {accentColor ===
color.value
? 'border-white scale-110'
: 'border-transparent hover:scale-105'}"
style="background-color: {color.value}"
title={color.label}
onclick={() => (accentColor = color.value)}
></button>
{/each}
<label
class="w-8 h-8 rounded-full border-2 border-dashed border-light/30 hover:border-light/60 transition-all cursor-pointer flex items-center justify-center overflow-hidden"
title="Custom color"
>
<input
type="color"
class="opacity-0 absolute w-0 h-0"
bind:value={accentColor}
/>
<span
class="material-symbols-rounded text-light/40"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>
colorize
</span>
</label>
</div>
</div>
<!-- Use Org Theme -->
<div class="flex items-center justify-between">
<div>
<p class="font-body text-body text-white">
{m.account_use_org_theme()}
</p>
<p class="font-body text-[12px] text-light/50">
{m.account_use_org_theme_desc()}
</p>
</div>
<button
type="button"
class="w-11 h-6 rounded-full transition-colors {useOrgTheme
? 'bg-primary'
: 'bg-light/20'}"
onclick={() => (useOrgTheme = !useOrgTheme)}
aria-label="Toggle organization theme"
>
<div
class="w-5 h-5 bg-white rounded-full shadow transition-transform {useOrgTheme
? 'translate-x-[22px]'
: 'translate-x-[2px]'}"
></div>
</button>
</div>
<!-- Language -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light"
>{m.account_language()}</span
>
<p class="font-body text-[12px] text-light/50">
{m.account_language_desc()}
</p>
<div class="flex gap-2 mt-1">
{#each locales as locale}
<button
type="button"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {currentLocale ===
locale
? 'bg-primary text-night'
: 'bg-light/10 text-light/70 hover:bg-light/20'}"
onclick={() => handleLanguageChange(locale)}
>
{localeLabels[locale] ?? locale}
</button>
{/each}
</div>
</div>
<div>
<Button onclick={savePreferences} loading={isSaving}>
{m.account_save_preferences()}
</Button>
</div>
</div>
<!-- Security & Sessions Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
{m.account_security()}
</h2>
<div class="flex flex-col gap-2">
<p class="font-body text-body text-white">
{m.account_password()}
</p>
<p class="font-body text-body-sm text-light/50">
{m.account_password_desc()}
</p>
<div class="mt-2">
<Button
variant="secondary"
size="sm"
onclick={async () => {
const { error } =
await supabase.auth.resetPasswordForEmail(
data.profile.email,
{
redirectTo: `${window.location.origin}/${data.org.slug}/account`,
},
);
if (error)
toasts.error("Failed to send reset email.");
else toasts.success("Password reset email sent.");
}}
>
{m.account_send_reset()}
</Button>
</div>
</div>
<div class="border-t border-light/10 pt-4 flex flex-col gap-2">
<p class="font-body text-body text-white">
{m.account_active_sessions()}
</p>
<p class="font-body text-body-sm text-light/50">
{m.account_sessions_desc()}
</p>
<div class="mt-2">
<Button
variant="danger"
size="sm"
onclick={async () => {
await supabase.auth.signOut({ scope: "others" });
toasts.success("Other sessions signed out.");
}}
>
{m.account_signout_others()}
</Button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,10 +1,11 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.calendar');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org, userRole } = await parent();
const { org, userRole } = await parent() as OrgLayoutData;
const { supabase } = locals;
// Fetch events for current month ± 1 month

View File

@@ -1,6 +1,14 @@
<script lang="ts">
import { getContext, onMount } from "svelte";
import { Button, Modal, Avatar } from "$lib/components/ui";
import { getContext, onMount, onDestroy } from "svelte";
import { createLogger } from "$lib/utils/logger";
import {
Button,
Modal,
Avatar,
ContextMenu,
Input,
Textarea,
} from "$lib/components/ui";
import { Calendar } from "$lib/components/calendar";
import {
getCalendarSubscribeUrl,
@@ -9,6 +17,7 @@
import type { CalendarEvent } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
@@ -22,6 +31,7 @@
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.calendar");
let events = $state(data.events);
$effect(() => {
@@ -32,17 +42,86 @@
let isLoadingGoogle = $state(false);
let orgCalendarId = $state<string | null>(null);
let orgCalendarName = $state<string | null>(null);
// Track Google event IDs that are pending deletion to prevent ghost re-appearance
let deletedGoogleEventIds = $state<Set<string>>(new Set());
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
const allEvents = $derived([...events, ...googleEvents]);
// Deduplicate: exclude Google Calendar events that already exist locally (synced events)
// Also exclude events that are pending deletion
const allEvents = $derived.by(() => {
const localGoogleIds = new Set(
events
.filter((e) => e.google_event_id)
.map((e) => e.google_event_id),
);
const filteredGoogle = googleEvents.filter((ge) => {
const rawId = ge.id.replace("google-", "");
if (localGoogleIds.has(rawId)) return false;
if (deletedGoogleEventIds.has(rawId)) return false;
return true;
});
return [...events, ...filteredGoogle];
});
let showEventModal = $state(false);
let showEventFormModal = $state(false);
let eventFormMode = $state<"create" | "edit">("create");
let isDeleting = $state(false);
let isSavingEvent = $state(false);
let selectedEvent = $state<CalendarEvent | null>(null);
function handleDateClick(_date: Date) {
// Event creation disabled
// Event form state
let eventTitle = $state("");
let eventDescription = $state("");
let eventDate = $state("");
let eventStartTime = $state("09:00");
let eventEndTime = $state("10:00");
let eventAllDay = $state(false);
let eventColor = $state("#7986cb");
let syncToGoogleCal = $state(true);
// Google Calendar official event colors (colorId → hex)
const GCAL_COLORS: Record<string, string> = {
"1": "#7986cb", // Lavender
"2": "#33b679", // Sage
"3": "#8e24aa", // Grape
"4": "#e67c73", // Flamingo
"5": "#f6bf26", // Banana
"6": "#f4511e", // Tangerine
"7": "#039be5", // Peacock
"8": "#616161", // Graphite
"9": "#3f51b5", // Blueberry
"10": "#0b8043", // Basil
"11": "#d50000", // Tomato
};
// Reverse map: hex → colorId (for pushing to Google)
const HEX_TO_COLOR_ID: Record<string, string> = Object.fromEntries(
Object.entries(GCAL_COLORS).map(([id, hex]) => [hex, id]),
);
const EVENT_COLORS = Object.values(GCAL_COLORS);
function toLocalDateString(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function handleDateClick(date: Date) {
eventFormMode = "create";
eventTitle = "";
eventDescription = "";
eventDate = toLocalDateString(date);
eventStartTime = "09:00";
eventEndTime = "10:00";
eventAllDay = false;
eventColor = "#7986cb";
syncToGoogleCal = isOrgCalendarConnected;
showEventFormModal = true;
}
function handleEventClick(event: CalendarEvent) {
@@ -50,11 +129,207 @@
showEventModal = true;
}
function openEditEvent() {
if (!selectedEvent || selectedEvent.id.startsWith("google-")) return;
eventFormMode = "edit";
eventTitle = selectedEvent.title;
eventDescription = selectedEvent.description ?? "";
const start = new Date(selectedEvent.start_time);
const end = new Date(selectedEvent.end_time);
eventDate = toLocalDateString(start);
eventStartTime = start.toTimeString().slice(0, 5);
eventEndTime = end.toTimeString().slice(0, 5);
eventAllDay = selectedEvent.all_day ?? false;
eventColor = selectedEvent.color ?? "#7986cb";
showEventModal = false;
showEventFormModal = true;
}
/**
* Push event to Google Calendar in the background.
* Does not block the UI — updates google_event_id on success.
*/
async function syncToGoogle(
action: "create" | "update" | "delete",
eventData: {
id?: string;
google_event_id?: string | null;
title?: string;
description?: string | null;
start_time?: string;
end_time?: string;
all_day?: boolean;
color?: string;
},
) {
const colorId = eventData.color
? (HEX_TO_COLOR_ID[eventData.color] ?? undefined)
: undefined;
try {
if (action === "create") {
const res = await fetch("/api/google-calendar/push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
title: eventData.title,
description: eventData.description,
start_time: eventData.start_time,
end_time: eventData.end_time,
all_day: eventData.all_day,
color_id: colorId,
}),
});
if (res.ok) {
const { google_event_id } = await res.json();
if (google_event_id && eventData.id) {
// Store the Google event ID back to Supabase
await supabase
.from("calendar_events")
.update({
google_event_id,
synced_at: new Date().toISOString(),
})
.eq("id", eventData.id);
// Update local state
events = events.map((e) =>
e.id === eventData.id
? {
...e,
google_event_id,
synced_at: new Date().toISOString(),
}
: e,
);
}
}
} else if (action === "update" && eventData.google_event_id) {
await fetch("/api/google-calendar/push", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
google_event_id: eventData.google_event_id,
title: eventData.title,
description: eventData.description,
start_time: eventData.start_time,
end_time: eventData.end_time,
all_day: eventData.all_day,
color_id: colorId,
}),
});
} else if (action === "delete" && eventData.google_event_id) {
await fetch("/api/google-calendar/push", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
google_event_id: eventData.google_event_id,
}),
});
}
} catch (e) {
log.error("Google Calendar sync failed", {
error: e,
data: { action },
});
}
}
async function handleSaveEvent() {
if (!eventTitle.trim() || !eventDate) return;
isSavingEvent = true;
const startTime = eventAllDay
? `${eventDate}T00:00:00`
: `${eventDate}T${eventStartTime}:00`;
const endTime = eventAllDay
? `${eventDate}T23:59:59`
: `${eventDate}T${eventEndTime}:00`;
if (eventFormMode === "edit" && selectedEvent) {
const { error } = await supabase
.from("calendar_events")
.update({
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
})
.eq("id", selectedEvent.id);
if (!error) {
events = events.map((e) =>
e.id === selectedEvent!.id
? {
...e,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
}
: e,
);
// Push update to Google Calendar in background
syncToGoogle("update", {
google_event_id: selectedEvent.google_event_id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
});
}
} else {
const { data: newEvent, error } = await supabase
.from("calendar_events")
.insert({
org_id: data.org.id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
created_by: data.user?.id,
})
.select()
.single();
if (!error && newEvent) {
events = [...events, newEvent as CalendarEvent];
// Push new event to Google Calendar if sync is enabled
if (syncToGoogleCal && isOrgCalendarConnected) {
syncToGoogle("create", {
id: newEvent.id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
});
}
}
}
showEventFormModal = false;
selectedEvent = null;
isSavingEvent = false;
}
async function handleDeleteEvent() {
if (!selectedEvent || selectedEvent.id.startsWith("google-")) return;
isDeleting = true;
try {
const googleEventId = selectedEvent.google_event_id;
const { error } = await supabase
.from("calendar_events")
.delete()
@@ -62,11 +337,25 @@
if (!error) {
events = events.filter((e) => e.id !== selectedEvent?.id);
if (googleEventId) {
// Immediately exclude from Google events display
deletedGoogleEventIds = new Set([
...deletedGoogleEventIds,
googleEventId,
]);
googleEvents = googleEvents.filter(
(ge) => ge.id !== `google-${googleEventId}`,
);
// Await Google delete so it completes before any refresh
await syncToGoogle("delete", {
google_event_id: googleEventId,
});
}
showEventModal = false;
selectedEvent = null;
}
} catch (e) {
console.error("Failed to delete event:", e);
log.error("Failed to delete event", { error: e });
}
isDeleting = false;
}
@@ -78,8 +367,33 @@
return `${start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
let pollInterval: ReturnType<typeof setInterval> | null = null;
function handleWindowFocus() {
if (isOrgCalendarConnected) {
loadGoogleCalendarEvents();
}
}
onMount(async () => {
await loadGoogleCalendarEvents();
// Re-fetch when user returns to the tab (e.g. after editing in Google Calendar)
window.addEventListener("focus", handleWindowFocus);
// Poll every 60s for changes made in Google Calendar
pollInterval = setInterval(() => {
if (isOrgCalendarConnected && !document.hidden) {
loadGoogleCalendarEvents();
}
}, 60_000);
});
onDestroy(() => {
if (typeof window !== "undefined") {
window.removeEventListener("focus", handleWindowFocus);
}
if (pollInterval) clearInterval(pollInterval);
});
async function loadGoogleCalendarEvents() {
@@ -96,31 +410,37 @@
orgCalendarId = result.calendar_id;
orgCalendarName = result.calendar_name;
if (result.events && result.events.length > 0) {
googleEvents = result.events.map(
(ge: GoogleCalendarEvent) => ({
id: `google-${ge.id}`,
org_id: data.org.id,
title: ge.summary || "(No title)",
description: ge.description ?? null,
start_time:
ge.start.dateTime ||
`${ge.start.date}T00:00:00`,
end_time:
ge.end.dateTime || `${ge.end.date}T23:59:59`,
all_day: !ge.start.dateTime,
color: "#4285f4",
recurrence: null,
created_by: data.user?.id ?? "",
created_at: new Date().toISOString(),
}),
) as CalendarEvent[];
}
const fetchedEvents = result.events ?? [];
googleEvents = fetchedEvents.map((ge: GoogleCalendarEvent) => ({
id: `google-${ge.id}`,
org_id: data.org.id,
title: ge.summary || "(No title)",
description: ge.description ?? null,
start_time:
ge.start.dateTime || `${ge.start.date}T00:00:00`,
end_time: ge.end.dateTime || `${ge.end.date}T23:59:59`,
all_day: !ge.start.dateTime,
color: ge.colorId
? (GCAL_COLORS[ge.colorId] ?? "#7986cb")
: "#7986cb",
recurrence: null,
created_by: data.user?.id ?? "",
created_at: new Date().toISOString(),
})) as CalendarEvent[];
// Clear deleted IDs that Google has confirmed are gone
const fetchedIds = new Set(
fetchedEvents.map((ge: GoogleCalendarEvent) => ge.id),
);
deletedGoogleEventIds = new Set(
[...deletedGoogleEventIds].filter((id) =>
fetchedIds.has(id),
),
);
} else if (result.error) {
console.error("Calendar API error:", result.error);
log.error("Calendar API error", { error: result.error });
}
} catch (e) {
console.error("Failed to load Google events:", e);
log.error("Failed to load Google events", { error: e });
}
isLoadingGoogle = false;
}
@@ -139,36 +459,49 @@
<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"
<h1 class="flex-1 font-heading text-h1 text-white">
{m.calendar_title()}
</h1>
<Button size="md" onclick={() => handleDateClick(new Date())}
>{m.btn_new()}</Button
>
<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>
<ContextMenu
items={[
...(isOrgCalendarConnected
? [
{
label: m.calendar_subscribe(),
icon: "add",
onclick: subscribeToCalendar,
},
]
: []),
{
label: m.calendar_refresh(),
icon: "refresh",
onclick: () => {
loadGoogleCalendarEvents();
},
},
...(isAdmin
? [
{
label: "",
icon: "",
onclick: () => {},
divider: true,
},
{
label: m.calendar_settings(),
icon: "settings",
onclick: () => {
window.location.href = `/${data.org.slug}/settings?tab=integrations`;
},
},
]
: []),
]}
/>
</header>
<!-- Calendar Grid -->
@@ -270,18 +603,22 @@
<span class="text-light/60 text-sm">
{selectedEvent.id.startsWith("google-")
? "Google Calendar Event"
: "Local Event"}
: selectedEvent.google_event_id
? "Synced to Google Calendar"
: "Local Event"}
</span>
</div>
<!-- Google Calendar link -->
{#if selectedEvent.id.startsWith("google-") && orgCalendarId}
{#if (selectedEvent.id.startsWith("google-") ? selectedEvent.id.replace("google-", "") : selectedEvent.google_event_id) && orgCalendarId}
{@const googleId = selectedEvent.id.startsWith("google-")
? selectedEvent.id.replace("google-", "")
: selectedEvent.google_event_id}
<div class="pt-3 border-t border-light/10">
<a
href="https://calendar.google.com/calendar/u/0/r/eventedit/{selectedEvent.id.replace(
'google-',
'',
)}?cid={encodeURIComponent(orgCalendarId)}"
href="https://calendar.google.com/calendar/u/0/r/eventedit/{googleId}?cid={encodeURIComponent(
orgCalendarId,
)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-500/20 text-blue-400 rounded-lg hover:bg-blue-500/30 transition-colors"
@@ -306,15 +643,14 @@
</svg>
Open in Google Calendar
</a>
<p class="text-xs text-light/40 mt-2">
Edit this event directly in Google Calendar
</p>
</div>
{/if}
<!-- Delete local event -->
<!-- Edit/Delete local event -->
{#if !selectedEvent.id.startsWith("google-")}
<div class="pt-3 border-t border-light/10">
<div
class="pt-3 border-t border-light/10 flex items-center justify-between"
>
<Button
variant="danger"
onclick={handleDeleteEvent}
@@ -322,8 +658,137 @@
>
Delete Event
</Button>
<Button onclick={openEditEvent}>Edit Event</Button>
</div>
{/if}
</div>
{/if}
</Modal>
<!-- Event Create/Edit Form Modal -->
<Modal
isOpen={showEventFormModal}
onClose={() => (showEventFormModal = false)}
title={eventFormMode === "edit"
? m.calendar_edit_event()
: m.calendar_create_event()}
>
<div class="space-y-4">
<Input
label={m.calendar_event_title()}
bind:value={eventTitle}
placeholder={m.calendar_event_title_placeholder()}
/>
<Input
type="date"
label={m.calendar_event_date()}
bind:value={eventDate}
/>
<label
class="flex items-center gap-2 text-sm text-light cursor-pointer"
>
<input type="checkbox" bind:checked={eventAllDay} class="rounded" />
All day event
</label>
{#if !eventAllDay}
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<span class="px-3 font-bold font-body text-body text-white"
>Start</span
>
<input
type="time"
class="w-full p-3 bg-background text-white rounded-[32px] font-medium font-input text-body focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={eventStartTime}
/>
</div>
<div class="flex flex-col gap-1">
<span class="px-3 font-bold font-body text-body text-white"
>End</span
>
<input
type="time"
class="w-full p-3 bg-background text-white rounded-[32px] font-medium font-input text-body focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={eventEndTime}
/>
</div>
</div>
{/if}
<Textarea
label={m.calendar_event_desc()}
bind:value={eventDescription}
placeholder="Add a description..."
rows={3}
/>
<div>
<span class="block text-sm font-medium text-light mb-2">Color</span>
<div class="flex gap-2">
{#each EVENT_COLORS as color}
<button
type="button"
class="w-7 h-7 rounded-full transition-transform {eventColor ===
color
? 'ring-2 ring-white scale-110'
: ''}"
style="background-color: {color}"
onclick={() => (eventColor = color)}
></button>
{/each}
</div>
</div>
{#if isOrgCalendarConnected && eventFormMode === "create"}
<label
class="flex items-center gap-3 text-sm text-light cursor-pointer p-3 rounded-lg bg-light/5 border border-light/10"
>
<input
type="checkbox"
bind:checked={syncToGoogleCal}
class="rounded"
/>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-blue-400" 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>
<span>Sync to Google Calendar</span>
</div>
</label>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showEventFormModal = false)}
>{m.btn_cancel()}</Button
>
<Button
onclick={handleSaveEvent}
loading={isSavingEvent}
disabled={!eventTitle.trim() || !eventDate}
>{eventFormMode === "edit"
? m.btn_save()
: m.btn_create()}</Button
>
</div>
</div>
</Modal>

View File

@@ -1,15 +1,16 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.documents');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { org } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { data: documents, error } = await supabase
.from('documents')
.select('*')
.select('id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id')
.eq('org_id', org.id)
.order('name');

View File

@@ -1,11 +1,12 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
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 { org } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { id } = params;

View File

@@ -1,11 +1,12 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
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 { org, user } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { id } = params;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from "svelte";
import { Button, Modal, Input } from "$lib/components/ui";
import { Button, Modal, Input, ContextMenu } from "$lib/components/ui";
import { DocumentViewer } from "$lib/components/documents";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
@@ -197,9 +197,11 @@
$effect(() => {
if (!kanbanBoard) return;
const colIds = kanbanBoard.columns.map((c) => c.id);
const channel = subscribeToBoard(
supabase,
kanbanBoard.id,
colIds,
() => loadKanbanBoard(),
() => loadKanbanBoard(),
);
@@ -212,15 +214,34 @@
};
});
// Reliable lock release via sendBeacon (survives page unload)
function beaconReleaseLock() {
if (hasLock && data.user) {
navigator.sendBeacon(
"/api/release-lock",
JSON.stringify({ documentId: data.document.id }),
);
}
}
onMount(() => {
window.addEventListener("beforeunload", beaconReleaseLock);
return () =>
window.removeEventListener("beforeunload", beaconReleaseLock);
});
onDestroy(() => {
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
// Release document lock
// Release document lock (SPA navigation — sendBeacon as fallback)
if (hasLock && data.user) {
stopHeartbeat?.();
releaseLock(supabase, data.document.id, data.user.id);
}
if (typeof window !== "undefined") {
window.removeEventListener("beforeunload", beaconReleaseLock);
}
});
async function handleCardMove(
@@ -441,23 +462,27 @@
<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>
<ContextMenu
items={[
{
label: "Import JSON",
icon: "upload",
onclick: triggerImport,
},
{
label: "Export JSON",
icon: "download",
onclick: handleExportJson,
},
{ divider: true, label: "", icon: "", onclick: () => {} },
{
label: "Rename Board",
icon: "edit",
onclick: () =>
toasts.info("Rename from the documents list."),
},
]}
/>
</header>
<div class="flex-1 overflow-auto min-h-0">

View File

@@ -1,11 +1,12 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
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 { org, user } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { id } = params;
@@ -13,7 +14,7 @@ export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { data: document, error: docError } = await supabase
.from('documents')
.select('*')
.select('id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id')
.eq('org_id', org.id)
.eq('id', id)
.single();
@@ -31,7 +32,7 @@ export const load: PageServerLoad = async ({ parent, locals, params }) => {
// Load all documents in this org (for breadcrumb building and file listing)
const { data: allDocuments } = await supabase
.from('documents')
.select('*')
.select('id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id')
.eq('org_id', org.id)
.order('name');

View File

@@ -1,10 +1,11 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.kanban');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { org } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { data: boards, error } = await supabase

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, onDestroy } from "svelte";
import { getContext, onDestroy, untrack } from "svelte";
import {
Button,
Card,
@@ -8,6 +8,7 @@
Avatar,
IconButton,
Icon,
ContextMenu,
} from "$lib/components/ui";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
@@ -16,26 +17,44 @@
moveCard,
subscribeToBoard,
} from "$lib/api/kanban";
import type { RealtimeChangePayload } from "$lib/api/kanban";
import type { RealtimeChannel } from "@supabase/supabase-js";
import type {
KanbanBoard as KanbanBoardType,
KanbanCard,
KanbanColumn,
} from "$lib/supabase/types";
import type { BoardWithColumns } from "$lib/api/kanban";
import type { BoardWithColumns, ColumnWithCards } from "$lib/api/kanban";
import { createLogger } from "$lib/utils/logger";
import * as m from "$lib/paraglide/messages";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Member {
id: string;
user_id: string;
role: string;
profiles: {
id: string;
full_name: string | null;
email: string;
avatar_url: string | null;
};
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
boards: KanbanBoardType[];
user: { id: string } | null;
members: Member[];
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.kanban");
let boards = $state(data.boards);
$effect(() => {
@@ -44,6 +63,8 @@
let selectedBoard = $state<BoardWithColumns | null>(null);
let showCreateBoardModal = $state(false);
let showEditBoardModal = $state(false);
let isRenamingBoard = $state(false);
let renameBoardValue = $state("");
let showCardDetailModal = $state(false);
let selectedCard = $state<KanbanCard | null>(null);
let newBoardName = $state("");
@@ -56,23 +77,125 @@
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
}
// Incremental realtime handlers
function handleColumnRealtime(
payload: RealtimeChangePayload<KanbanColumn>,
) {
if (!selectedBoard) return;
const { event } = payload;
if (event === "INSERT") {
const col: ColumnWithCards = { ...payload.new, cards: [] };
selectedBoard = {
...selectedBoard,
columns: [...selectedBoard.columns, col].sort(
(a, b) => a.position - b.position,
),
};
} else if (event === "UPDATE") {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns
.map((c) =>
c.id === payload.new.id ? { ...c, ...payload.new } : c,
)
.sort((a, b) => a.position - b.position),
};
} else if (event === "DELETE") {
const deletedId = payload.old.id;
if (deletedId) {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.filter(
(c) => c.id !== deletedId,
),
};
}
}
}
function handleCardRealtime(payload: RealtimeChangePayload<KanbanCard>) {
if (!selectedBoard) return;
const { event } = payload;
if (event === "INSERT") {
const card = payload.new;
if (!card.column_id) return;
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) =>
col.id === card.column_id
? {
...col,
cards: [...col.cards, card].sort(
(a, b) => a.position - b.position,
),
}
: col,
),
};
} else if (event === "UPDATE") {
const card = payload.new;
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) => {
if (col.id === card.column_id) {
// Target column: update existing or add new
const exists = col.cards.some((c) => c.id === card.id);
const updatedCards = exists
? col.cards.map((c) =>
c.id === card.id ? { ...c, ...card } : c,
)
: [...col.cards, card];
return {
...col,
cards: updatedCards.sort(
(a, b) => a.position - b.position,
),
};
}
// All other columns: remove the card if present (handles cross-column moves)
return {
...col,
cards: col.cards.filter((c) => c.id !== card.id),
};
}),
};
} else if (event === "DELETE") {
const deletedId = payload.old.id;
if (deletedId) {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) => ({
...col,
cards: col.cards.filter((c) => c.id !== deletedId),
})),
};
}
}
}
// Track board ID separately so the realtime subscription only re-runs
// when the board changes, not on every column/card update
let currentBoardId = $derived(selectedBoard?.id ?? null);
// Realtime subscription with proper cleanup
$effect(() => {
const board = selectedBoard;
if (!board) return;
const boardId = currentBoardId;
if (!boardId) return;
// Subscribe to realtime changes for this board
// Read column IDs without creating a reactive dependency on selectedBoard
// (the effect should only re-run when boardId changes)
const colIds = (untrack(() => selectedBoard)?.columns ?? []).map(
(c) => c.id,
);
const channel = subscribeToBoard(
supabase,
board.id,
() => {
// Column changed - reload board data
loadBoard(board.id);
},
() => {
// Card changed - reload board data
loadBoard(board.id);
},
boardId,
colIds,
handleColumnRealtime,
handleCardRealtime,
);
realtimeChannel = channel;
@@ -111,6 +234,48 @@
showEditBoardModal = true;
}
function startBoardRename() {
if (!selectedBoard) return;
renameBoardValue = selectedBoard.name;
isRenamingBoard = true;
}
async function confirmBoardRename() {
if (!selectedBoard || !renameBoardValue.trim()) {
isRenamingBoard = false;
return;
}
const newName = renameBoardValue.trim();
if (newName === selectedBoard.name) {
isRenamingBoard = false;
return;
}
const boardId = selectedBoard.id;
const { error } = await supabase
.from("kanban_boards")
.update({ name: newName })
.eq("id", boardId);
await supabase
.from("documents")
.update({ name: newName })
.eq("id", boardId);
if (!error) {
selectedBoard = { ...selectedBoard, name: newName };
boards = boards.map((b) =>
b.id === boardId ? { ...b, name: newName } : b,
);
}
isRenamingBoard = false;
}
function cancelBoardRename() {
isRenamingBoard = false;
renameBoardValue = "";
}
async function handleEditBoard() {
if (!editingBoardId || !editBoardName.trim()) return;
@@ -119,6 +284,12 @@
.update({ name: editBoardName })
.eq("id", editingBoardId);
// Also update the linked document entry
await supabase
.from("documents")
.update({ name: editBoardName })
.eq("id", editingBoardId);
if (!error) {
if (selectedBoard?.id === editingBoardId) {
selectedBoard = { ...selectedBoard, name: editBoardName };
@@ -131,8 +302,11 @@
editingBoardId = null;
}
async function handleDeleteBoard(e: MouseEvent, board: KanbanBoardType) {
e.stopPropagation();
async function handleDeleteBoard(
e: MouseEvent | null,
board: KanbanBoardType,
) {
e?.stopPropagation();
if (!confirm(`Delete "${board.name}" and all its cards?`)) return;
const { error } = await supabase
@@ -140,6 +314,9 @@
.delete()
.eq("id", board.id);
// Also delete the corresponding document entry
await supabase.from("documents").delete().eq("id", board.id);
if (!error) {
boards = boards.filter((b) => b.id !== board.id);
if (selectedBoard?.id === board.id) {
@@ -217,6 +394,24 @@
}
}
async function handleRenameColumn(columnId: string, newName: string) {
if (!selectedBoard) return;
const { error } = await supabase
.from("kanban_columns")
.update({ name: newName })
.eq("id", columnId);
if (!error) {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((c) =>
c.id === columnId ? { ...c, name: newName } : c,
),
};
}
}
async function handleCardMove(
cardId: string,
toColumnId: string,
@@ -244,7 +439,7 @@
// Persist to database in background
moveCard(supabase, cardId, toColumnId, toPosition).catch((err) => {
console.error("Failed to persist card move:", err);
log.error("Failed to persist card move", { error: err });
// Reload to sync state on error
loadBoard(selectedBoard!.id);
});
@@ -302,17 +497,48 @@
<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>
{#if isRenamingBoard && selectedBoard}
<input
type="text"
class="flex-1 bg-dark border border-primary rounded-lg px-3 py-1 text-white font-heading text-h1 focus:outline-none"
bind:value={renameBoardValue}
onkeydown={(e) => {
if (e.key === "Enter") confirmBoardRename();
if (e.key === "Escape") cancelBoardRename();
}}
onblur={confirmBoardRename}
autofocus
/>
{:else}
<h1 class="flex-1 font-heading text-h1 text-white">
{selectedBoard ? selectedBoard.name : m.kanban_title()}
</h1>
{/if}
<Button size="md" onclick={() => (showCreateBoardModal = true)}
>+ New</Button
>{m.btn_new()}</Button
>
<IconButton
title="More options"
onclick={() => selectedBoard && openEditBoardModal(selectedBoard)}
>
<Icon name="more_horiz" size={24} />
</IconButton>
<ContextMenu
items={[
...(selectedBoard
? [
{
label: m.kanban_rename_board(),
icon: "edit",
onclick: () => startBoardRename(),
},
{
label: m.kanban_delete_board(),
icon: "delete",
onclick: () => {
if (selectedBoard)
handleDeleteBoard(null, selectedBoard);
},
danger: true,
},
]
: []),
]}
/>
</header>
<!-- Board selector (compact) -->
@@ -344,6 +570,7 @@
onAddColumn={handleAddColumn}
onDeleteCard={handleCardDelete}
onDeleteColumn={handleDeleteColumn}
onRenameColumn={handleRenameColumn}
/>
{:else if boards.length === 0}
<div class="h-full flex items-center justify-center text-light/40">
@@ -354,18 +581,18 @@
>
view_kanban
</span>
<p class="mb-4">Kanban boards are now managed in Files</p>
<p class="mb-4">{m.kanban_empty()}</p>
<Button
onclick={() =>
(window.location.href = `/${data.org.slug}/documents`)}
>
Go to Files
{m.kanban_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>
<p>{m.kanban_select_board()}</p>
</div>
{/if}
</div>
@@ -374,21 +601,22 @@
<Modal
isOpen={showCreateBoardModal}
onClose={() => (showCreateBoardModal = false)}
title="Create Board"
title={m.kanban_create_board()}
>
<div class="space-y-4">
<Input
label="Board Name"
label={m.kanban_board_name_label()}
bind:value={newBoardName}
placeholder="e.g. Sprint 1"
placeholder={m.kanban_board_name_placeholder()}
/>
<div class="flex justify-end gap-2">
<Button
variant="tertiary"
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
onclick={() => (showCreateBoardModal = false)}
>{m.btn_cancel()}</Button
>
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
>Create</Button
>{m.btn_create()}</Button
>
</div>
</div>
@@ -397,22 +625,34 @@
<Modal
isOpen={showEditBoardModal}
onClose={() => (showEditBoardModal = false)}
title="Edit Board"
title={m.kanban_edit_board()}
>
<div class="space-y-4">
<Input
label="Board Name"
label={m.kanban_board_name_label()}
bind:value={editBoardName}
placeholder="Board name"
placeholder={m.kanban_board_name_placeholder()}
/>
<div class="flex justify-end gap-2">
<div class="flex items-center justify-between gap-2">
<Button
variant="tertiary"
onclick={() => (showEditBoardModal = false)}>Cancel</Button
>
<Button onclick={handleEditBoard} disabled={!editBoardName.trim()}
>Save</Button
variant="danger"
onclick={() => {
showEditBoardModal = false;
const board = boards.find((b) => b.id === editingBoardId);
if (board) handleDeleteBoard(null, board);
}}>{m.kanban_delete_board()}</Button
>
<div class="flex gap-2">
<Button
variant="tertiary"
onclick={() => (showEditBoardModal = false)}
>{m.btn_cancel()}</Button
>
<Button
onclick={handleEditBoard}
disabled={!editBoardName.trim()}>{m.btn_save()}</Button
>
</div>
</div>
</div>
</Modal>
@@ -420,22 +660,23 @@
<Modal
isOpen={showAddColumnModal}
onClose={() => (showAddColumnModal = false)}
title="Add Column"
title={m.kanban_add_column()}
>
<div class="space-y-4">
<Input
label="Column Name"
label={m.kanban_column_name_label()}
bind:value={newColumnName}
placeholder="e.g. To Do, In Progress, Done"
placeholder={m.kanban_column_name_placeholder()}
/>
<div class="flex justify-end gap-2">
<Button
variant="tertiary"
onclick={() => (showAddColumnModal = false)}>Cancel</Button
onclick={() => (showAddColumnModal = false)}
>{m.btn_cancel()}</Button
>
<Button
onclick={handleCreateColumn}
disabled={!newColumnName.trim()}>Create</Button
disabled={!newColumnName.trim()}>{m.btn_create()}</Button
>
</div>
</div>
@@ -448,6 +689,8 @@
showCardDetailModal = false;
selectedCard = null;
targetColumnId = null;
// Reload board to reflect tag/checklist/assignee changes made in modal
if (selectedBoard) loadBoard(selectedBoard.id);
}}
onUpdate={handleCardUpdate}
onDelete={handleCardDelete}
@@ -456,4 +699,5 @@
userId={data.user?.id}
orgId={data.org.id}
onCreate={handleCardCreated}
members={data.members ?? []}
/>

View File

@@ -1,11 +1,14 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
import { getServiceAccountEmail } from '$lib/api/google-calendar-push';
import { env } from '$env/dynamic/private';
const log = createLogger('page.settings');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org, userRole } = await parent() as { org: { id: string; slug: string }; userRole: string };
const { org, userRole } = await parent() as OrgLayoutData;
// Only admins and owners can access settings
if (userRole !== 'owner' && userRole !== 'admin') {
@@ -16,22 +19,10 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
// Fetch all settings data in parallel
const [membersResult, rolesResult, invitesResult, calendarResult] = await Promise.all([
// Get org members with profiles
// Get org members (without embedded profile join — FK points to auth.users, not profiles)
locals.supabase
.from('org_members')
.select(`
id,
user_id,
role,
role_id,
created_at,
profiles:user_id (
id,
email,
full_name,
avatar_url
)
`)
.select('id, user_id, role, role_id, invited_at')
.eq('org_id', orgId),
// Get org roles
locals.supabase
@@ -54,11 +45,41 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
.single()
]);
if (membersResult.error) {
log.error('Failed to fetch members', { error: membersResult.error, data: { orgId } });
}
// Fetch profiles separately since org_members.user_id FK points to auth.users, not profiles
const rawMembers = membersResult.data ?? [];
const userIds = rawMembers.map(m => m.user_id).filter((id): id is string => id !== null);
let profilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null }> = {};
if (userIds.length > 0) {
const { data: profiles } = await locals.supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.in('id', userIds);
if (profiles) {
profilesMap = Object.fromEntries(profiles.map(p => [p.id, p]));
}
}
const members = rawMembers.map(m => ({
...m,
profiles: (m.user_id ? profilesMap[m.user_id] : null) ?? null
}));
const serviceAccountEmail = env.GOOGLE_SERVICE_ACCOUNT_KEY
? getServiceAccountEmail(env.GOOGLE_SERVICE_ACCOUNT_KEY)
: null;
return {
members: membersResult.data ?? [],
members,
roles: rolesResult.data ?? [],
invites: invitesResult.data ?? [],
orgCalendar: calendarResult.data,
serviceAccountEmail,
userRole
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { GOOGLE_API_KEY } from '$env/static/private';
import { env } from '$env/dynamic/private';
import { fetchPublicCalendarEvents } from '$lib/api/google-calendar';
import { fetchCalendarEventsViaServiceAccount } from '$lib/api/google-calendar-push';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api:google-calendar');
@@ -30,8 +31,11 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return json({ error: 'Forbidden' }, { status: 403 });
}
if (!GOOGLE_API_KEY) {
return json({ error: 'Google API key not configured' }, { status: 500 });
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
const apiKey = env.GOOGLE_API_KEY;
if (!serviceKey && !apiKey) {
return json({ error: 'No Google credentials configured' }, { status: 500 });
}
try {
@@ -53,17 +57,28 @@ export const GET: RequestHandler = async ({ url, locals }) => {
log.debug('Fetching events for calendar', { data: { calendarId: orgCal.calendar_id } });
// Fetch events for the next 3 months
const now = new Date();
const timeMin = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0);
const events = await fetchPublicCalendarEvents(
orgCal.calendar_id,
GOOGLE_API_KEY,
timeMin,
timeMax
);
let events: unknown[];
// Prefer service account (works with private calendars) over public API key
if (serviceKey) {
events = await fetchCalendarEventsViaServiceAccount(
serviceKey,
orgCal.calendar_id,
timeMin,
timeMax
);
} else {
events = await fetchPublicCalendarEvents(
orgCal.calendar_id,
apiKey!,
timeMin,
timeMax
);
}
log.debug('Fetched events', { data: { count: events.length } });
@@ -74,6 +89,6 @@ export const GET: RequestHandler = async ({ url, locals }) => {
});
} catch (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 });
return json({ error: 'Failed to fetch events. Make sure the calendar is shared with the service account.', events: [] }, { status: 500 });
}
};

View File

@@ -0,0 +1,191 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import {
pushEventToGoogle,
updateGoogleEvent,
deleteGoogleEvent,
type GoogleEventPayload,
} from '$lib/api/google-calendar-push';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.google-calendar-push');
/**
* Shared auth + org membership check
*/
async function authorize(locals: App.Locals, orgId: string): Promise<{ user: { id: string }; error?: never } | { error: Response; user?: never }> {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
return { error: 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 { error: json({ error: 'Forbidden' }, { status: 403 }) };
}
return { user };
}
/**
* Get the org's connected Google Calendar ID
*/
async function getOrgCalendarId(locals: App.Locals, orgId: string): Promise<string | null> {
const { data } = await locals.supabase
.from('org_google_calendars')
.select('calendar_id')
.eq('org_id', orgId)
.single();
return data?.calendar_id ?? null;
}
/**
* Build a Google Calendar event payload from our app's event data
*/
function buildGooglePayload(body: {
title: string;
description?: string | null;
start_time: string;
end_time: string;
all_day?: boolean;
color_id?: string;
}): GoogleEventPayload {
const base = {
summary: body.title,
description: body.description ?? undefined,
colorId: body.color_id ?? undefined,
};
if (body.all_day) {
const startDate = body.start_time.split('T')[0];
const endDateObj = new Date(body.end_time.split('T')[0]);
endDateObj.setDate(endDateObj.getDate() + 1);
const endDate = endDateObj.toISOString().split('T')[0];
return {
...base,
start: { date: startDate },
end: { date: endDate },
};
}
return {
...base,
start: { dateTime: new Date(body.start_time).toISOString() },
end: { dateTime: new Date(body.end_time).toISOString() },
};
}
/**
* POST /api/google-calendar/push
* Create an event in Google Calendar and return the google_event_id
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
if (!serviceKey) {
return json({ error: 'Google service account not configured' }, { status: 500 });
}
const body = await request.json();
const { org_id, title, description, start_time, end_time, all_day, color_id } = body;
if (!org_id || !title || !start_time || !end_time) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const auth = await authorize(locals, org_id);
if (auth.error) return auth.error;
const calendarId = await getOrgCalendarId(locals, org_id);
if (!calendarId) {
return json({ error: 'No Google Calendar connected' }, { status: 404 });
}
try {
const payload = buildGooglePayload({ title, description, start_time, end_time, all_day, color_id });
const googleEventId = await pushEventToGoogle(serviceKey, calendarId, payload);
return json({ google_event_id: googleEventId });
} catch (err) {
log.error('Failed to push event to Google Calendar', { error: err, data: { org_id } });
return json({ error: 'Failed to create event in Google Calendar' }, { status: 500 });
}
};
/**
* PUT /api/google-calendar/push
* Update an existing event in Google Calendar
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
if (!serviceKey) {
return json({ error: 'Google service account not configured' }, { status: 500 });
}
const body = await request.json();
const { org_id, google_event_id, title, description, start_time, end_time, all_day, color_id } = body;
if (!org_id || !google_event_id || !title || !start_time || !end_time) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const auth = await authorize(locals, org_id);
if (auth.error) return auth.error;
const calendarId = await getOrgCalendarId(locals, org_id);
if (!calendarId) {
return json({ error: 'No Google Calendar connected' }, { status: 404 });
}
try {
const payload = buildGooglePayload({ title, description, start_time, end_time, all_day, color_id });
await updateGoogleEvent(serviceKey, calendarId, google_event_id, payload);
return json({ ok: true });
} catch (err) {
log.error('Failed to update Google Calendar event', { error: err, data: { org_id, google_event_id } });
return json({ error: 'Failed to update event in Google Calendar' }, { status: 500 });
}
};
/**
* DELETE /api/google-calendar/push
* Delete an event from Google Calendar
*/
export const DELETE: RequestHandler = async ({ request, locals }) => {
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
if (!serviceKey) {
return json({ error: 'Google service account not configured' }, { status: 500 });
}
const body = await request.json();
const { org_id, google_event_id } = body;
if (!org_id || !google_event_id) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const auth = await authorize(locals, org_id);
if (auth.error) return auth.error;
const calendarId = await getOrgCalendarId(locals, org_id);
if (!calendarId) {
return json({ error: 'No Google Calendar connected' }, { status: 404 });
}
try {
await deleteGoogleEvent(serviceKey, calendarId, google_event_id);
return json({ ok: true });
} catch (err) {
log.error('Failed to delete Google Calendar event', { error: err, data: { org_id, google_event_id } });
return json({ error: 'Failed to delete event from Google Calendar' }, { status: 500 });
}
};

View File

@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.release-lock');
/**
* POST /api/release-lock
* Called via navigator.sendBeacon() when the user navigates away from a document.
* Releases the document lock so other users can edit immediately.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { documentId } = await request.json();
if (!documentId) {
return json({ error: 'Missing documentId' }, { status: 400 });
}
// Only allow releasing your own lock
const { error } = await locals.supabase
.from('document_locks')
.delete()
.eq('document_id', documentId)
.eq('user_id', user.id);
if (error) {
log.error('Failed to release lock', { error, data: { documentId, userId: user.id } });
return json({ error: 'Failed to release lock' }, { status: 500 });
}
return json({ ok: true });
} catch (e) {
log.error('release-lock request failed', { error: e });
return json({ error: 'Invalid request' }, { status: 400 });
}
};

View File

@@ -13,6 +13,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
if (code) {
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);
if (!error) {
// Sync avatar from OAuth provider metadata into profiles
const { data: { user } } = await locals.supabase.auth.getUser();
if (user) {
const avatarUrl = user.user_metadata?.avatar_url || user.user_metadata?.picture || null;
const fullName = user.user_metadata?.full_name || user.user_metadata?.name || null;
if (avatarUrl || fullName) {
const updates: Record<string, string> = {};
if (avatarUrl) updates.avatar_url = avatarUrl;
if (fullName) updates.full_name = fullName;
await locals.supabase
.from('profiles')
.update(updates)
.eq('id', user.id);
}
}
redirect(303, next);
}
}

View File

@@ -32,12 +32,14 @@ export const load: PageServerLoad = async ({ params, locals }) => {
// Get current user
const { data: { user } } = await locals.supabase.auth.getUser();
const org = (invite as Record<string, unknown>).organizations as { id: string; name: string; slug: string } | null;
return {
invite: {
id: invite.id,
email: invite.email,
role: invite.role,
org: (invite as any).organizations // join not typed
org,
},
user,
token

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { Button, Card } from "$lib/components/ui";
import { createLogger } from "$lib/utils/logger";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
@@ -20,6 +21,7 @@
let { data }: Props = $props();
const supabase = getContext<SupabaseClient>("supabase");
const log = createLogger("page.invite");
let isAccepting = $state(false);
let error = $state(data.error || "");
@@ -55,7 +57,9 @@
error = "You're already a member of this organization.";
} else {
error = "Failed to join organization. Please try again.";
console.error(memberError);
log.error("Failed to join organization", {
error: memberError,
});
}
isAccepting = false;
return;
@@ -71,7 +75,7 @@
goto(`/${data.invite.org.slug}`);
} catch (e) {
error = "Something went wrong. Please try again.";
console.error(e);
log.error("Invite acceptance failed", { error: e });
isAccepting = false;
}
}

View File

@@ -1,5 +1,5 @@
@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 url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,0,0&display=swap');
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@@ -68,6 +68,12 @@
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; }
button, [role="button"] { @apply cursor-pointer; }
button:disabled, [role="button"][aria-disabled="true"] { @apply cursor-not-allowed; }
a { @apply cursor-pointer; }
[draggable="true"] { @apply cursor-grab; }
[draggable="true"]:active { @apply cursor-grabbing; }
}
/* Scrollbar — no Tailwind equivalent for pseudo-elements */

View File

@@ -3,6 +3,7 @@
import { createClient } from "$lib/supabase";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import * as m from "$lib/paraglide/messages";
let email = $state($page.url.searchParams.get("email") || "");
let password = $state("");
@@ -93,8 +94,8 @@
<div class="min-h-screen bg-dark flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary mb-2">Root</h1>
<p class="text-light/60">Team collaboration, reimagined</p>
<h1 class="text-3xl font-bold text-primary mb-2">{m.app_name()}</h1>
<p class="text-light/60">{m.login_subtitle()}</p>
</div>
<Card variant="elevated" padding="lg">
@@ -111,12 +112,10 @@
</span>
</div>
<h2 class="text-xl font-semibold text-light mb-2">
Check your email
{m.login_signup_success_title()}
</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.
{m.login_signup_success_text({ email })}
</p>
<Button
variant="tertiary"
@@ -125,12 +124,14 @@
mode = "login";
}}
>
Back to Login
{m.login_tab_login()}
</Button>
</div>
{:else}
<h2 class="text-xl font-semibold text-light mb-6">
{mode === "login" ? "Welcome back" : "Create your account"}
{mode === "login"
? m.login_tab_login()
: m.login_tab_signup()}
</h2>
{#if error}
@@ -150,28 +151,32 @@
>
<Input
type="email"
label="Email"
placeholder="you@example.com"
label={m.login_email_label()}
placeholder={m.login_email_placeholder()}
bind:value={email}
required
/>
<Input
type="password"
label="Password"
placeholder="••••••••"
label={m.login_password_label()}
placeholder={m.login_password_placeholder()}
bind:value={password}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{mode === "login" ? "Log In" : "Sign Up"}
{mode === "login"
? m.login_btn_login()
: m.login_btn_signup()}
</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>
<span class="text-light/40 text-sm"
>{m.login_or_continue()}</span
>
<div class="flex-1 h-px bg-light/10"></div>
</div>
@@ -198,25 +203,25 @@
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
{m.login_google()}
</Button>
<p class="mt-6 text-center text-light/60 text-sm">
{#if mode === "login"}
Don't have an account?
{m.login_signup_prompt()}
<button
class="text-primary hover:underline"
onclick={() => (mode = "signup")}
>
Sign up
{m.login_tab_signup()}
</button>
{:else}
Already have an account?
{m.login_login_prompt()}
<button
class="text-primary hover:underline"
onclick={() => (mode = "login")}
>
Log in
{m.login_tab_login()}
</button>
{/if}
</p>