First commit
This commit is contained in:
20
src/app.d.ts
vendored
Normal file
20
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { SupabaseClient, Session, User } from '@supabase/supabase-js';
|
||||
import type { Database } from '$lib/supabase/types';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
supabase: SupabaseClient<Database>;
|
||||
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
|
||||
}
|
||||
interface PageData {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
}
|
||||
// interface Error {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export { };
|
||||
11
src/app.html
Normal file
11
src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
7
src/demo.spec.ts
Normal file
7
src/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
45
src/hooks.server.ts
Normal file
45
src/hooks.server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.supabase = createServerClient(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: '/' });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
event.locals.safeGetSession = async () => {
|
||||
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();
|
||||
|
||||
if (error) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
return { session, user };
|
||||
};
|
||||
|
||||
return resolve(event, {
|
||||
filterSerializedResponseHeaders(name) {
|
||||
return name === 'content-range' || name === 'x-supabase-api-version';
|
||||
}
|
||||
});
|
||||
};
|
||||
118
src/lib/api/calendar.ts
Normal file
118
src/lib/api/calendar.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database, CalendarEvent } from '$lib/supabase/types';
|
||||
|
||||
export async function fetchEvents(
|
||||
supabase: SupabaseClient<Database>,
|
||||
orgId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<CalendarEvent[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('calendar_events')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.gte('start_time', startDate.toISOString())
|
||||
.lte('end_time', endDate.toISOString())
|
||||
.order('start_time');
|
||||
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function createEvent(
|
||||
supabase: SupabaseClient<Database>,
|
||||
orgId: string,
|
||||
event: {
|
||||
title: string;
|
||||
description?: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
all_day?: boolean;
|
||||
color?: string;
|
||||
},
|
||||
userId: string
|
||||
): Promise<CalendarEvent> {
|
||||
const { data, error } = await supabase
|
||||
.from('calendar_events')
|
||||
.insert({
|
||||
org_id: orgId,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
all_day: event.all_day ?? false,
|
||||
color: event.color,
|
||||
created_by: userId
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateEvent(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string,
|
||||
updates: Partial<Pick<CalendarEvent, 'title' | 'description' | 'start_time' | 'end_time' | 'all_day' | 'color'>>
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('calendar_events').update(updates).eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function deleteEvent(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('calendar_events').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export function subscribeToEvents(
|
||||
supabase: SupabaseClient<Database>,
|
||||
orgId: string,
|
||||
onChange: () => void
|
||||
) {
|
||||
return supabase
|
||||
.channel(`calendar:${orgId}`)
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'calendar_events', filter: `org_id=eq.${orgId}` }, onChange)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
// Calendar utility functions
|
||||
export function getMonthDays(year: number, month: number): Date[] {
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const days: Date[] = [];
|
||||
|
||||
// Add days from previous month to fill first week
|
||||
const startDayOfWeek = firstDay.getDay();
|
||||
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
||||
days.push(new Date(year, month, -i));
|
||||
}
|
||||
|
||||
// Add days of current month
|
||||
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||
days.push(new Date(year, month, i));
|
||||
}
|
||||
|
||||
// Add days from next month to fill last week
|
||||
const remainingDays = 42 - days.length; // 6 weeks * 7 days
|
||||
for (let i = 1; i <= remainingDays; i++) {
|
||||
days.push(new Date(year, month + 1, i));
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
132
src/lib/api/documents.ts
Normal file
132
src/lib/api/documents.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database, Document } from '$lib/supabase/types';
|
||||
|
||||
export interface DocumentWithChildren extends Document {
|
||||
children?: DocumentWithChildren[];
|
||||
}
|
||||
|
||||
export async function fetchDocuments(
|
||||
supabase: SupabaseClient<Database>,
|
||||
orgId: string
|
||||
): Promise<Document[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.order('type', { ascending: false }) // folders first
|
||||
.order('name');
|
||||
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function createDocument(
|
||||
supabase: SupabaseClient<Database>,
|
||||
orgId: string,
|
||||
name: string,
|
||||
type: 'folder' | 'document',
|
||||
parentId: string | null = null,
|
||||
userId: string
|
||||
): Promise<Document> {
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.insert({
|
||||
org_id: orgId,
|
||||
name,
|
||||
type,
|
||||
parent_id: parentId,
|
||||
created_by: userId,
|
||||
content: type === 'document' ? { type: 'doc', content: [] } : null
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateDocument(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string,
|
||||
updates: Partial<Pick<Document, 'name' | 'content' | 'parent_id'>>
|
||||
): Promise<Document> {
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.update({ ...updates, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteDocument(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('documents').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function moveDocument(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string,
|
||||
newParentId: string | null
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('documents')
|
||||
.update({ parent_id: newParentId, updated_at: new Date().toISOString() })
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export function buildDocumentTree(documents: Document[]): DocumentWithChildren[] {
|
||||
const map = new Map<string, DocumentWithChildren>();
|
||||
const roots: DocumentWithChildren[] = [];
|
||||
|
||||
// First pass: create map
|
||||
documents.forEach((doc) => {
|
||||
map.set(doc.id, { ...doc, children: [] });
|
||||
});
|
||||
|
||||
// Second pass: build tree
|
||||
documents.forEach((doc) => {
|
||||
const node = map.get(doc.id)!;
|
||||
if (doc.parent_id && map.has(doc.parent_id)) {
|
||||
map.get(doc.parent_id)!.children!.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function subscribeToDocuments(
|
||||
supabase: SupabaseClient<Database>,
|
||||
orgId: string,
|
||||
onInsert: (doc: Document) => void,
|
||||
onUpdate: (doc: Document) => void,
|
||||
onDelete: (id: string) => void
|
||||
) {
|
||||
return supabase
|
||||
.channel(`documents:${orgId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'documents', filter: `org_id=eq.${orgId}` },
|
||||
(payload) => onInsert(payload.new as Document)
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'public', table: 'documents', filter: `org_id=eq.${orgId}` },
|
||||
(payload) => onUpdate(payload.new as Document)
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'DELETE', schema: 'public', table: 'documents', filter: `org_id=eq.${orgId}` },
|
||||
(payload) => onDelete((payload.old as { id: string }).id)
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
264
src/lib/api/google-calendar.ts
Normal file
264
src/lib/api/google-calendar.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database } from '$lib/supabase/types';
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||
const GOOGLE_CLIENT_SECRET = import.meta.env.VITE_GOOGLE_CLIENT_SECRET;
|
||||
|
||||
interface GoogleTokens {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface GoogleCalendarEvent {
|
||||
id: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
start: { dateTime?: string; date?: string };
|
||||
end: { dateTime?: string; date?: string };
|
||||
colorId?: string;
|
||||
}
|
||||
|
||||
export function getGoogleAuthUrl(redirectUri: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state
|
||||
});
|
||||
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForTokens(code: string, redirectUri: string): Promise<GoogleTokens> {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to exchange code for tokens');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<GoogleTokens> {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
grant_type: 'refresh_token'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to refresh access token');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getValidAccessToken(
|
||||
supabase: SupabaseClient<Database>,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const { data: connection } = await supabase
|
||||
.from('google_calendar_connections')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (!connection) return null;
|
||||
|
||||
const expiresAt = new Date(connection.token_expires_at);
|
||||
const now = new Date();
|
||||
|
||||
// Refresh if expires within 5 minutes
|
||||
if (expiresAt.getTime() - now.getTime() < 5 * 60 * 1000) {
|
||||
try {
|
||||
const tokens = await refreshAccessToken(connection.refresh_token);
|
||||
const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
|
||||
await supabase
|
||||
.from('google_calendar_connections')
|
||||
.update({
|
||||
access_token: tokens.access_token,
|
||||
token_expires_at: newExpiresAt.toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('user_id', userId);
|
||||
|
||||
return tokens.access_token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return connection.access_token;
|
||||
}
|
||||
|
||||
export async function fetchGoogleCalendarEvents(
|
||||
accessToken: string,
|
||||
calendarId: string = 'primary',
|
||||
timeMin: Date,
|
||||
timeMax: Date
|
||||
): Promise<GoogleCalendarEvent[]> {
|
||||
const params = new URLSearchParams({
|
||||
timeMin: timeMin.toISOString(),
|
||||
timeMax: timeMax.toISOString(),
|
||||
singleEvents: 'true',
|
||||
orderBy: 'startTime'
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch Google Calendar events');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function createGoogleCalendarEvent(
|
||||
accessToken: string,
|
||||
calendarId: string = 'primary',
|
||||
event: {
|
||||
title: string;
|
||||
description?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
allDay?: boolean;
|
||||
}
|
||||
): Promise<GoogleCalendarEvent> {
|
||||
const body: Record<string, unknown> = {
|
||||
summary: event.title,
|
||||
description: event.description
|
||||
};
|
||||
|
||||
if (event.allDay) {
|
||||
const startDate = event.startTime.split('T')[0];
|
||||
const endDate = event.endTime.split('T')[0];
|
||||
body.start = { date: startDate };
|
||||
body.end = { date: endDate };
|
||||
} else {
|
||||
body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
||||
body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create Google Calendar event');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateGoogleCalendarEvent(
|
||||
accessToken: string,
|
||||
calendarId: string = 'primary',
|
||||
eventId: string,
|
||||
event: {
|
||||
title: string;
|
||||
description?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
allDay?: boolean;
|
||||
}
|
||||
): Promise<GoogleCalendarEvent> {
|
||||
const body: Record<string, unknown> = {
|
||||
summary: event.title,
|
||||
description: event.description
|
||||
};
|
||||
|
||||
if (event.allDay) {
|
||||
const startDate = event.startTime.split('T')[0];
|
||||
const endDate = event.endTime.split('T')[0];
|
||||
body.start = { date: startDate };
|
||||
body.end = { date: endDate };
|
||||
} else {
|
||||
body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
||||
body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update Google Calendar event');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteGoogleCalendarEvent(
|
||||
accessToken: string,
|
||||
calendarId: string = 'primary',
|
||||
eventId: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error('Failed to delete Google Calendar event');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGoogleCalendars(accessToken: string): Promise<{ id: string; summary: string }[]> {
|
||||
const response = await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to list calendars');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return (data.items ?? []).map((cal: { id: string; summary: string }) => ({
|
||||
id: cal.id,
|
||||
summary: cal.summary
|
||||
}));
|
||||
}
|
||||
215
src/lib/api/kanban.ts
Normal file
215
src/lib/api/kanban.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database, KanbanBoard, KanbanColumn, KanbanCard } from '$lib/supabase/types';
|
||||
|
||||
export interface ColumnWithCards extends KanbanColumn {
|
||||
cards: KanbanCard[];
|
||||
}
|
||||
|
||||
export interface BoardWithColumns extends KanbanBoard {
|
||||
columns: ColumnWithCards[];
|
||||
}
|
||||
|
||||
export async function fetchBoards(
|
||||
supabase: SupabaseClient<Database>,
|
||||
orgId: string
|
||||
): Promise<KanbanBoard[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('kanban_boards')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.order('created_at');
|
||||
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (boardError) throw boardError;
|
||||
if (!board) return null;
|
||||
|
||||
const { data: columns, error: colError } = await supabase
|
||||
.from('kanban_columns')
|
||||
.select('*')
|
||||
.eq('board_id', boardId)
|
||||
.order('position');
|
||||
|
||||
if (colError) throw colError;
|
||||
|
||||
const { data: cards, error: cardError } = await supabase
|
||||
.from('kanban_cards')
|
||||
.select('*')
|
||||
.in('column_id', (columns ?? []).map((c) => c.id))
|
||||
.order('position');
|
||||
|
||||
if (cardError) throw cardError;
|
||||
|
||||
const cardsByColumn = new Map<string, KanbanCard[]>();
|
||||
(cards ?? []).forEach((card) => {
|
||||
if (!cardsByColumn.has(card.column_id)) {
|
||||
cardsByColumn.set(card.column_id, []);
|
||||
}
|
||||
cardsByColumn.get(card.column_id)!.push(card);
|
||||
});
|
||||
|
||||
return {
|
||||
...board,
|
||||
columns: (columns ?? []).map((col) => ({
|
||||
...col,
|
||||
cards: cardsByColumn.get(col.id) ?? []
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBoard(
|
||||
supabase: SupabaseClient<Database>,
|
||||
orgId: string,
|
||||
name: string
|
||||
): Promise<KanbanBoard> {
|
||||
const { data, error } = await supabase
|
||||
.from('kanban_boards')
|
||||
.insert({ org_id: orgId, name })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Create default columns
|
||||
const defaultColumns = ['To Do', 'In Progress', 'Done'];
|
||||
await supabase.from('kanban_columns').insert(
|
||||
defaultColumns.map((name, index) => ({
|
||||
board_id: data.id,
|
||||
name,
|
||||
position: index
|
||||
}))
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateBoard(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('kanban_boards').update({ name }).eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function deleteBoard(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('kanban_boards').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function createColumn(
|
||||
supabase: SupabaseClient<Database>,
|
||||
boardId: string,
|
||||
name: string,
|
||||
position: number
|
||||
): Promise<KanbanColumn> {
|
||||
const { data, error } = await supabase
|
||||
.from('kanban_columns')
|
||||
.insert({ board_id: boardId, name, position })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateColumn(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string,
|
||||
updates: Partial<Pick<KanbanColumn, 'name' | 'position' | 'color'>>
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('kanban_columns').update(updates).eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function deleteColumn(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('kanban_columns').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function createCard(
|
||||
supabase: SupabaseClient<Database>,
|
||||
columnId: string,
|
||||
title: string,
|
||||
position: number,
|
||||
userId: string
|
||||
): Promise<KanbanCard> {
|
||||
const { data, error } = await supabase
|
||||
.from('kanban_cards')
|
||||
.insert({
|
||||
column_id: columnId,
|
||||
title,
|
||||
position,
|
||||
created_by: userId
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateCard(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string,
|
||||
updates: Partial<Pick<KanbanCard, 'title' | 'description' | 'column_id' | 'position' | 'due_date' | 'color'>>
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('kanban_cards').update(updates).eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function deleteCard(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('kanban_cards').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function moveCard(
|
||||
supabase: SupabaseClient<Database>,
|
||||
cardId: string,
|
||||
newColumnId: string,
|
||||
newPosition: number
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('kanban_cards')
|
||||
.update({ column_id: newColumnId, position: newPosition })
|
||||
.eq('id', cardId);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export function subscribeToBoard(
|
||||
supabase: SupabaseClient<Database>,
|
||||
boardId: string,
|
||||
onColumnChange: () => void,
|
||||
onCardChange: () => void
|
||||
) {
|
||||
const channel = supabase.channel(`kanban:${boardId}`);
|
||||
|
||||
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)
|
||||
.subscribe();
|
||||
|
||||
return channel;
|
||||
}
|
||||
164
src/lib/api/organizations.ts
Normal file
164
src/lib/api/organizations.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database, Organization, MemberRole } from '$lib/supabase/types';
|
||||
import type { OrgWithRole } from '$lib/stores/organizations.svelte';
|
||||
|
||||
export async function fetchUserOrganizations(
|
||||
supabase: SupabaseClient<Database>
|
||||
): Promise<OrgWithRole[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('org_members')
|
||||
.select(`
|
||||
role,
|
||||
organizations (
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
avatar_url,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
`)
|
||||
.not('joined_at', 'is', null);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return (data ?? [])
|
||||
.filter((item) => item.organizations)
|
||||
.map((item) => ({
|
||||
...(item.organizations as Organization),
|
||||
role: item.role as MemberRole
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createOrganization(
|
||||
supabase: SupabaseClient<Database>,
|
||||
name: string,
|
||||
slug: string
|
||||
): Promise<Organization> {
|
||||
const { data, error } = await supabase
|
||||
.from('organizations')
|
||||
.insert({ name, slug })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateOrganization(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string,
|
||||
updates: Partial<Pick<Organization, 'name' | 'slug' | 'avatar_url'>>
|
||||
): Promise<Organization> {
|
||||
const { data, error } = await supabase
|
||||
.from('organizations')
|
||||
.update({ ...updates, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteOrganization(
|
||||
supabase: SupabaseClient<Database>,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('organizations').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function fetchOrgMembers(
|
||||
supabase: SupabaseClient<Database>,
|
||||
orgId: string
|
||||
) {
|
||||
const { data, error } = await supabase
|
||||
.from('org_members')
|
||||
.select(`
|
||||
id,
|
||||
org_id,
|
||||
user_id,
|
||||
role,
|
||||
invited_at,
|
||||
joined_at,
|
||||
profiles (
|
||||
email,
|
||||
full_name,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('org_id', orgId);
|
||||
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function inviteMember(
|
||||
supabase: SupabaseClient<Database>,
|
||||
orgId: string,
|
||||
email: string,
|
||||
role: MemberRole = 'viewer'
|
||||
): Promise<void> {
|
||||
// First, find user by email
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('email', email)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile) {
|
||||
throw new Error('User not found. They need to sign up first.');
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const { data: existing } = await supabase
|
||||
.from('org_members')
|
||||
.select('id')
|
||||
.eq('org_id', orgId)
|
||||
.eq('user_id', profile.id)
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
throw new Error('User is already a member of this organization.');
|
||||
}
|
||||
|
||||
// Create invitation
|
||||
const { error } = await supabase.from('org_members').insert({
|
||||
org_id: orgId,
|
||||
user_id: profile.id,
|
||||
role,
|
||||
joined_at: new Date().toISOString() // Auto-join for now
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function updateMemberRole(
|
||||
supabase: SupabaseClient<Database>,
|
||||
memberId: string,
|
||||
role: MemberRole
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('org_members')
|
||||
.update({ role })
|
||||
.eq('id', memberId);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function removeMember(
|
||||
supabase: SupabaseClient<Database>,
|
||||
memberId: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('org_members').delete().eq('id', memberId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 50);
|
||||
}
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
124
src/lib/components/calendar/Calendar.svelte
Normal file
124
src/lib/components/calendar/Calendar.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import type { CalendarEvent } from '$lib/supabase/types';
|
||||
import { getMonthDays, isSameDay } from '$lib/api/calendar';
|
||||
|
||||
interface Props {
|
||||
events: CalendarEvent[];
|
||||
onDateClick?: (date: Date) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { events, onDateClick, onEventClick }: Props = $props();
|
||||
|
||||
let currentDate = $state(new Date());
|
||||
const today = new Date();
|
||||
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
const days = $derived(getMonthDays(currentDate.getFullYear(), currentDate.getMonth()));
|
||||
|
||||
function prevMonth() {
|
||||
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1);
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
currentDate = new Date();
|
||||
}
|
||||
|
||||
function getEventsForDay(date: Date): CalendarEvent[] {
|
||||
return events.filter((event) => {
|
||||
const eventStart = new Date(event.start_time);
|
||||
return isSameDay(eventStart, date);
|
||||
});
|
||||
}
|
||||
|
||||
function isCurrentMonth(date: Date): boolean {
|
||||
return date.getMonth() === currentDate.getMonth();
|
||||
}
|
||||
|
||||
const monthYear = $derived(
|
||||
currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="bg-surface rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-light">{monthYear}</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={goToToday}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={prevMonth}
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={nextMonth}
|
||||
aria-label="Next month"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden">
|
||||
{#each weekDays as day}
|
||||
<div class="bg-dark px-2 py-2 text-center text-sm font-medium text-light/50">
|
||||
{day}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each days as day}
|
||||
{@const dayEvents = getEventsForDay(day)}
|
||||
{@const isToday = isSameDay(day, today)}
|
||||
{@const inMonth = isCurrentMonth(day)}
|
||||
<button
|
||||
class="bg-dark min-h-[80px] p-1 text-left transition-colors hover:bg-light/5"
|
||||
class:opacity-40={!inMonth}
|
||||
onclick={() => onDateClick?.(day)}
|
||||
>
|
||||
<div class="flex items-center justify-center w-7 h-7 mb-1">
|
||||
<span
|
||||
class="text-sm {isToday
|
||||
? 'bg-primary text-white rounded-full w-7 h-7 flex items-center justify-center'
|
||||
: 'text-light/80'}"
|
||||
>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#each dayEvents.slice(0, 3) as event}
|
||||
<button
|
||||
class="w-full text-xs px-1 py-0.5 rounded truncate text-left"
|
||||
style="background-color: {event.color ?? '#6366f1'}20; color: {event.color ?? '#6366f1'}"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick?.(event);
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
{#if dayEvents.length > 3}
|
||||
<p class="text-xs text-light/40 px-1">+{dayEvents.length - 3} more</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
1
src/lib/components/calendar/index.ts
Normal file
1
src/lib/components/calendar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Calendar } from './Calendar.svelte';
|
||||
201
src/lib/components/documents/Editor.svelte
Normal file
201
src/lib/components/documents/Editor.svelte
Normal file
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
|
||||
interface Props {
|
||||
content?: object | null;
|
||||
editable?: boolean;
|
||||
placeholder?: string;
|
||||
onUpdate?: (content: object) => void;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
content = null,
|
||||
editable = true,
|
||||
placeholder = 'Start writing...',
|
||||
onUpdate,
|
||||
onSave
|
||||
}: Props = $props();
|
||||
|
||||
let element: HTMLDivElement;
|
||||
let editor: Editor | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({ placeholder })
|
||||
],
|
||||
content: content ?? undefined,
|
||||
editable,
|
||||
onUpdate: ({ editor }) => {
|
||||
onUpdate?.(editor.getJSON());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-invert max-w-none focus:outline-none min-h-[200px] p-4'
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
onSave?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
export function setContent(newContent: object | null) {
|
||||
if (editor && newContent) {
|
||||
editor.commands.setContent(newContent);
|
||||
}
|
||||
}
|
||||
|
||||
export function getContent() {
|
||||
return editor?.getJSON() ?? null;
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
editor?.commands.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-surface rounded-xl border border-light/10 overflow-hidden">
|
||||
{#if editable}
|
||||
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-light/10 bg-dark/50">
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
class:text-primary={editor?.isActive('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
class:text-primary={editor?.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="19" y1="4" x2="10" y2="4" />
|
||||
<line x1="14" y1="20" x2="5" y2="20" />
|
||||
<line x1="15" y1="4" x2="9" y2="20" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
class:text-primary={editor?.isActive('strike')}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4H9a3 3 0 0 0-2.83 4" />
|
||||
<path d="M14 12a4 4 0 0 1 0 8H6" />
|
||||
<line x1="4" y1="12" x2="20" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-light/20 mx-1"></div>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
class:text-primary={editor?.isActive('heading', { level: 1 })}
|
||||
title="Heading 1"
|
||||
>
|
||||
<span class="text-xs font-bold">H1</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
class:text-primary={editor?.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<span class="text-xs font-bold">H2</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
class:text-primary={editor?.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
<span class="text-xs font-bold">H3</span>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-light/20 mx-1"></div>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
class:text-primary={editor?.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="4" cy="6" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="18" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
class:text-primary={editor?.isActive('orderedList')}
|
||||
title="Numbered List"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="10" y1="6" x2="21" y2="6" />
|
||||
<line x1="10" y1="12" x2="21" y2="12" />
|
||||
<line x1="10" y1="18" x2="21" y2="18" />
|
||||
<text x="3" y="8" font-size="8" fill="currentColor">1</text>
|
||||
<text x="3" y="14" font-size="8" fill="currentColor">2</text>
|
||||
<text x="3" y="20" font-size="8" fill="currentColor">3</text>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
class:text-primary={editor?.isActive('blockquote')}
|
||||
title="Quote"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
class:text-primary={editor?.isActive('codeBlock')}
|
||||
title="Code Block"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="16,18 22,12 16,6" />
|
||||
<polyline points="8,6 2,12 8,18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div bind:this={element}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-light) / 0.3);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
192
src/lib/components/documents/FileTree.svelte
Normal file
192
src/lib/components/documents/FileTree.svelte
Normal file
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import type { DocumentWithChildren } from "$lib/api/documents";
|
||||
|
||||
interface Props {
|
||||
items: DocumentWithChildren[];
|
||||
selectedId?: string | null;
|
||||
onSelect: (doc: DocumentWithChildren) => void;
|
||||
onAdd?: (parentId: string | null) => void;
|
||||
onMove?: (docId: string, newParentId: string | null) => void;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
selectedId = null,
|
||||
onSelect,
|
||||
onAdd,
|
||||
onMove,
|
||||
level = 0,
|
||||
}: Props = $props();
|
||||
|
||||
let expandedFolders = $state<Set<string>>(new Set());
|
||||
let dragOverId = $state<string | null>(null);
|
||||
|
||||
function toggleFolder(id: string, e?: MouseEvent) {
|
||||
e?.stopPropagation();
|
||||
const newSet = new Set(expandedFolders);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
expandedFolders = newSet;
|
||||
}
|
||||
|
||||
function handleSelect(doc: DocumentWithChildren) {
|
||||
onSelect(doc);
|
||||
}
|
||||
|
||||
function handleAdd(e: MouseEvent, parentId: string | null) {
|
||||
e.stopPropagation();
|
||||
onAdd?.(parentId);
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent, doc: DocumentWithChildren) {
|
||||
if (!e.dataTransfer) return;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", doc.id);
|
||||
}
|
||||
|
||||
function handleDragOver(
|
||||
e: DragEvent,
|
||||
targetId: string | null,
|
||||
isFolder: boolean,
|
||||
) {
|
||||
if (!isFolder && targetId !== null) return;
|
||||
e.preventDefault();
|
||||
dragOverId = targetId;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverId = null;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, targetFolderId: string | null) {
|
||||
e.preventDefault();
|
||||
dragOverId = null;
|
||||
const docId = e.dataTransfer?.getData("text/plain");
|
||||
if (docId && docId !== targetFolderId) {
|
||||
onMove?.(docId, targetFolderId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="space-y-0.5"
|
||||
ondragover={(e) => level === 0 && handleDragOver(e, null, true)}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => level === 0 && handleDrop(e, null)}
|
||||
role="tree"
|
||||
>
|
||||
{#each items as item}
|
||||
<div role="treeitem">
|
||||
<div
|
||||
class="group w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors cursor-pointer
|
||||
{selectedId === item.id
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-light/80 hover:bg-light/5'}
|
||||
{dragOverId === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
||||
onclick={() => handleSelect(item)}
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleDragStart(e, item)}
|
||||
ondragover={(e) =>
|
||||
handleDragOver(e, item.id, item.type === "folder")}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => item.type === "folder" && handleDrop(e, item.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if item.type === "folder"}
|
||||
<button
|
||||
class="p-0.5 hover:bg-light/10 rounded"
|
||||
onclick={(e) => toggleFolder(item.id, e)}
|
||||
aria-label="Toggle folder"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform {expandedFolders.has(
|
||||
item.id,
|
||||
)
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
class="w-4 h-4 text-warning"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H12L10 5H5C3.89543 5 3 5.89543 3 7Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<div class="w-5"></div>
|
||||
<svg
|
||||
class="w-4 h-4 text-light/50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="flex-1 truncate text-sm">{item.name}</span>
|
||||
|
||||
{#if item.type === "folder" && onAdd}
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 p-1 hover:bg-light/10 rounded transition-opacity"
|
||||
onclick={(e) => handleAdd(e, item.id)}
|
||||
aria-label="Add to folder"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if item.type === "folder" && expandedFolders.has(item.id)}
|
||||
<div class="ml-4 border-l border-light/10 pl-2">
|
||||
{#if item.children?.length}
|
||||
<svelte:self
|
||||
items={item.children}
|
||||
{selectedId}
|
||||
{onSelect}
|
||||
{onAdd}
|
||||
{onMove}
|
||||
level={level + 1}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-light/30 text-xs px-3 py-2 italic">
|
||||
Empty folder
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if items.length === 0 && level === 0}
|
||||
<p class="text-light/40 text-sm px-3 py-2">No documents yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
2
src/lib/components/documents/index.ts
Normal file
2
src/lib/components/documents/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as FileTree } from './FileTree.svelte';
|
||||
export { default as Editor } from './Editor.svelte';
|
||||
231
src/lib/components/kanban/CardDetailModal.svelte
Normal file
231
src/lib/components/kanban/CardDetailModal.svelte
Normal file
@@ -0,0 +1,231 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { Modal, Button, Input, Textarea } from '$lib/components/ui';
|
||||
import type { KanbanCard } from '$lib/supabase/types';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database } from '$lib/supabase/types';
|
||||
|
||||
interface ChecklistItem {
|
||||
id: string;
|
||||
card_id: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
card: KanbanCard | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUpdate: (card: KanbanCard) => void;
|
||||
onDelete: (cardId: string) => void;
|
||||
}
|
||||
|
||||
let { card, isOpen, onClose, onUpdate, onDelete }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>('supabase');
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let checklist = $state<ChecklistItem[]>([]);
|
||||
let newItemTitle = $state('');
|
||||
let isLoading = $state(false);
|
||||
let isSaving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (card && isOpen) {
|
||||
title = card.title;
|
||||
description = card.description ?? '';
|
||||
loadChecklist();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadChecklist() {
|
||||
if (!card) return;
|
||||
isLoading = true;
|
||||
|
||||
const { data } = await supabase
|
||||
.from('checklist_items')
|
||||
.select('*')
|
||||
.eq('card_id', card.id)
|
||||
.order('position');
|
||||
|
||||
checklist = (data ?? []) as ChecklistItem[];
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!card) return;
|
||||
isSaving = true;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('kanban_cards')
|
||||
.update({
|
||||
title,
|
||||
description: description || null
|
||||
})
|
||||
.eq('id', card.id);
|
||||
|
||||
if (!error) {
|
||||
onUpdate({ ...card, title, description: description || null });
|
||||
}
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
async function handleAddItem() {
|
||||
if (!card || !newItemTitle.trim()) return;
|
||||
|
||||
const position = checklist.length;
|
||||
const { data, error } = await supabase
|
||||
.from('checklist_items')
|
||||
.insert({
|
||||
card_id: card.id,
|
||||
title: newItemTitle,
|
||||
position,
|
||||
completed: false
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && data) {
|
||||
checklist = [...checklist, data as ChecklistItem];
|
||||
newItemTitle = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleItem(item: ChecklistItem) {
|
||||
const { error } = await supabase
|
||||
.from('checklist_items')
|
||||
.update({ completed: !item.completed })
|
||||
.eq('id', item.id);
|
||||
|
||||
if (!error) {
|
||||
checklist = checklist.map(i =>
|
||||
i.id === item.id ? { ...i, completed: !i.completed } : i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(itemId: string) {
|
||||
const { error } = await supabase
|
||||
.from('checklist_items')
|
||||
.delete()
|
||||
.eq('id', itemId);
|
||||
|
||||
if (!error) {
|
||||
checklist = checklist.filter(i => i.id !== itemId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!card || !confirm('Delete this card?')) return;
|
||||
|
||||
await supabase
|
||||
.from('kanban_cards')
|
||||
.delete()
|
||||
.eq('id', card.id);
|
||||
|
||||
onDelete(card.id);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const completedCount = $derived(checklist.filter(i => i.completed).length);
|
||||
const progress = $derived(checklist.length > 0 ? (completedCount / checklist.length) * 100 : 0);
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} {onClose} title="Card Details" size="lg">
|
||||
{#if card}
|
||||
<div class="space-y-5">
|
||||
<Input
|
||||
label="Title"
|
||||
bind:value={title}
|
||||
placeholder="Card title"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
bind:value={description}
|
||||
placeholder="Add a more detailed description..."
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="text-sm font-medium text-light">Checklist</label>
|
||||
{#if checklist.length > 0}
|
||||
<span class="text-xs text-light/50">{completedCount}/{checklist.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if checklist.length > 0}
|
||||
<div class="mb-3 h-1.5 bg-light/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-success transition-all duration-300"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="text-light/50 text-sm py-2">Loading...</div>
|
||||
{:else}
|
||||
<div class="space-y-2 mb-3">
|
||||
{#each checklist as item}
|
||||
<div class="flex items-center gap-3 group">
|
||||
<button
|
||||
class="w-5 h-5 rounded border flex items-center justify-center transition-colors
|
||||
{item.completed ? 'bg-success border-success' : 'border-light/30 hover:border-light/50'}"
|
||||
onclick={() => toggleItem(item)}
|
||||
>
|
||||
{#if item.completed}
|
||||
<svg class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="flex-1 text-sm {item.completed ? 'line-through text-light/40' : 'text-light'}">
|
||||
{item.title}
|
||||
</span>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all"
|
||||
onclick={() => deleteItem(item.id)}
|
||||
aria-label="Delete item"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
|
||||
placeholder="Add an item..."
|
||||
bind:value={newItemTitle}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleAddItem()}
|
||||
/>
|
||||
<Button size="sm" onclick={handleAddItem} disabled={!newItemTitle.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-3 border-t border-light/10">
|
||||
<Button variant="danger" onclick={handleDelete}>
|
||||
Delete Card
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" onclick={onClose}>Cancel</Button>
|
||||
<Button onclick={handleSave} loading={isSaving}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
162
src/lib/components/kanban/KanbanBoard.svelte
Normal file
162
src/lib/components/kanban/KanbanBoard.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { ColumnWithCards } from '$lib/api/kanban';
|
||||
import type { KanbanCard } from '$lib/supabase/types';
|
||||
import { Button, Card, Badge } from '$lib/components/ui';
|
||||
|
||||
interface Props {
|
||||
columns: ColumnWithCards[];
|
||||
onCardClick?: (card: KanbanCard) => void;
|
||||
onCardMove?: (cardId: string, toColumnId: string, toPosition: number) => void;
|
||||
onAddCard?: (columnId: string) => void;
|
||||
onAddColumn?: () => void;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
columns,
|
||||
onCardClick,
|
||||
onCardMove,
|
||||
onAddCard,
|
||||
onAddColumn,
|
||||
canEdit = true
|
||||
}: Props = $props();
|
||||
|
||||
let draggedCard = $state<KanbanCard | null>(null);
|
||||
let dragOverColumn = $state<string | null>(null);
|
||||
|
||||
function handleDragStart(e: DragEvent, card: KanbanCard) {
|
||||
draggedCard = card;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', card.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent, columnId: string) {
|
||||
e.preventDefault();
|
||||
dragOverColumn = columnId;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverColumn = null;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, columnId: string) {
|
||||
e.preventDefault();
|
||||
dragOverColumn = null;
|
||||
|
||||
if (draggedCard && draggedCard.column_id !== columnId) {
|
||||
const column = columns.find((c) => c.id === columnId);
|
||||
const newPosition = column?.cards.length ?? 0;
|
||||
onCardMove?.(draggedCard.id, columnId, newPosition);
|
||||
}
|
||||
draggedCard = null;
|
||||
}
|
||||
|
||||
function formatDueDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 0) return 'Overdue';
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return 'Tomorrow';
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function getDueDateColor(dateStr: string | null): 'error' | 'warning' | 'default' {
|
||||
if (!dateStr) return 'default';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 0) return 'error';
|
||||
if (days <= 2) return 'warning';
|
||||
return 'default';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-4 overflow-x-auto pb-4 min-h-[500px]">
|
||||
{#each columns as column}
|
||||
<div
|
||||
class="flex-shrink-0 w-72 bg-surface rounded-xl p-3 flex flex-col max-h-[calc(100vh-200px)]"
|
||||
class:ring-2={dragOverColumn === column.id}
|
||||
class:ring-primary={dragOverColumn === column.id}
|
||||
ondragover={(e) => handleDragOver(e, column.id)}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => handleDrop(e, column.id)}
|
||||
role="list"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3 px-1">
|
||||
<h3 class="font-medium text-light flex items-center gap-2">
|
||||
{column.name}
|
||||
<span class="text-xs text-light/50 bg-light/10 px-1.5 py-0.5 rounded">
|
||||
{column.cards.length}
|
||||
</span>
|
||||
</h3>
|
||||
{#if column.color}
|
||||
<div class="w-3 h-3 rounded-full" style="background-color: {column.color}"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-2">
|
||||
{#each column.cards as card}
|
||||
<div
|
||||
class="bg-dark rounded-lg p-3 cursor-pointer hover:ring-1 hover:ring-light/20 transition-all"
|
||||
class:opacity-50={draggedCard?.id === card.id}
|
||||
draggable={canEdit}
|
||||
ondragstart={(e) => handleDragStart(e, card)}
|
||||
onclick={() => onCardClick?.(card)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onCardClick?.(card)}
|
||||
role="listitem"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if card.color}
|
||||
<div class="w-full h-1 rounded-full mb-2" style="background-color: {card.color}"></div>
|
||||
{/if}
|
||||
<p class="text-sm text-light">{card.title}</p>
|
||||
{#if card.description}
|
||||
<p class="text-xs text-light/50 mt-1 line-clamp-2">{card.description}</p>
|
||||
{/if}
|
||||
{#if card.due_date}
|
||||
<div class="mt-2">
|
||||
<Badge size="sm" variant={getDueDateColor(card.due_date)}>
|
||||
{formatDueDate(card.due_date)}
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if canEdit}
|
||||
<button
|
||||
class="mt-2 w-full py-2 text-sm text-light/50 hover:text-light hover:bg-light/5 rounded-lg transition-colors flex items-center justify-center gap-1"
|
||||
onclick={() => onAddCard?.(column.id)}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Add card
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if canEdit}
|
||||
<button
|
||||
class="flex-shrink-0 w-72 h-12 bg-light/5 hover:bg-light/10 rounded-xl flex items-center justify-center gap-2 text-light/50 hover:text-light transition-colors"
|
||||
onclick={() => onAddColumn?.()}
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Add column
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
2
src/lib/components/kanban/index.ts
Normal file
2
src/lib/components/kanban/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as KanbanBoard } from './KanbanBoard.svelte';
|
||||
export { default as CardDetailModal } from './CardDetailModal.svelte';
|
||||
92
src/lib/components/ui/Avatar.svelte
Normal file
92
src/lib/components/ui/Avatar.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
src?: string | null;
|
||||
name?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
status?: 'online' | 'offline' | 'away' | 'busy' | null;
|
||||
}
|
||||
|
||||
let { src = null, name = '?', size = 'md', status = null }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6 text-xs',
|
||||
sm: 'w-8 h-8 text-sm',
|
||||
md: 'w-10 h-10 text-base',
|
||||
lg: 'w-12 h-12 text-lg',
|
||||
xl: 'w-16 h-16 text-xl',
|
||||
'2xl': 'w-20 h-20 text-2xl'
|
||||
};
|
||||
|
||||
const statusSizes = {
|
||||
xs: 'w-2 h-2',
|
||||
sm: 'w-2.5 h-2.5',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-3.5 h-3.5',
|
||||
xl: 'w-4 h-4',
|
||||
'2xl': 'w-5 h-5'
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
online: 'bg-success',
|
||||
offline: 'bg-light/30',
|
||||
away: 'bg-warning',
|
||||
busy: 'bg-error'
|
||||
};
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
function getColorFromName(name: string): string {
|
||||
const colors = [
|
||||
'bg-red-500',
|
||||
'bg-orange-500',
|
||||
'bg-amber-500',
|
||||
'bg-yellow-500',
|
||||
'bg-lime-500',
|
||||
'bg-green-500',
|
||||
'bg-emerald-500',
|
||||
'bg-teal-500',
|
||||
'bg-cyan-500',
|
||||
'bg-sky-500',
|
||||
'bg-blue-500',
|
||||
'bg-indigo-500',
|
||||
'bg-violet-500',
|
||||
'bg-purple-500',
|
||||
'bg-fuchsia-500',
|
||||
'bg-pink-500'
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<div
|
||||
class="rounded-full flex items-center justify-center font-medium text-white overflow-hidden {sizeClasses[
|
||||
size
|
||||
]} {!src ? getColorFromName(name) : 'bg-surface'}"
|
||||
>
|
||||
{#if src}
|
||||
<img {src} alt={name} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
{getInitials(name)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if status}
|
||||
<div
|
||||
class="absolute bottom-0 right-0 rounded-full border-2 border-dark {statusSizes[size]} {statusColors[
|
||||
status
|
||||
]}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
30
src/lib/components/ui/Badge.svelte
Normal file
30
src/lib/components/ui/Badge.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'default', size = 'md', children }: Props = $props();
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-light/10 text-light',
|
||||
primary: 'bg-primary/20 text-primary',
|
||||
success: 'bg-success/20 text-success',
|
||||
warning: 'bg-warning/20 text-warning',
|
||||
error: 'bg-error/20 text-error',
|
||||
info: 'bg-info/20 text-info'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-1.5 py-0.5 text-xs',
|
||||
md: 'px-2 py-0.5 text-sm',
|
||||
lg: 'px-2.5 py-1 text-sm'
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="inline-flex items-center font-medium rounded-full {variantClasses[variant]} {sizeClasses[size]}">
|
||||
{@render children()}
|
||||
</span>
|
||||
71
src/lib/components/ui/Button.svelte
Normal file
71
src/lib/components/ui/Button.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
fullWidth?: boolean;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
type = 'button',
|
||||
fullWidth = false,
|
||||
onclick,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const baseClasses =
|
||||
'inline-flex items-center justify-center font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary text-white hover:bg-primary/90 focus:ring-primary rounded-xl',
|
||||
secondary:
|
||||
'bg-surface text-light border border-light/20 hover:bg-light/5 focus:ring-light/50 rounded-xl',
|
||||
ghost: 'bg-transparent text-light hover:bg-light/10 focus:ring-light/50 rounded-xl',
|
||||
danger: 'bg-error text-white hover:bg-error/90 focus:ring-error rounded-xl',
|
||||
success: 'bg-success text-white hover:bg-success/90 focus:ring-success rounded-xl'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'px-2 py-1 text-xs gap-1',
|
||||
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
||||
md: 'px-4 py-2 text-sm gap-2',
|
||||
lg: 'px-6 py-3 text-base gap-2.5'
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
class="{baseClasses} {variantClasses[variant]} {sizeClasses[size]}"
|
||||
class:w-full={fullWidth}
|
||||
disabled={disabled || loading}
|
||||
{onclick}
|
||||
>
|
||||
{#if loading}
|
||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</button>
|
||||
28
src/lib/components/ui/Card.svelte
Normal file
28
src/lib/components/ui/Card.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'default' | 'elevated' | 'outlined';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'default', padding = 'md', children }: Props = $props();
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-surface',
|
||||
elevated: 'bg-surface shadow-lg shadow-black/20',
|
||||
outlined: 'bg-surface border border-light/10'
|
||||
};
|
||||
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl {variantClasses[variant]} {paddingClasses[padding]}">
|
||||
{@render children()}
|
||||
</div>
|
||||
66
src/lib/components/ui/Input.svelte
Normal file
66
src/lib/components/ui/Input.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
type?: "text" | "password" | "email" | "url" | "search" | "number";
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
autocomplete?: AutoFill;
|
||||
oninput?: (e: Event) => void;
|
||||
onkeydown?: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
type = "text",
|
||||
value = $bindable(""),
|
||||
placeholder = "",
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
disabled = false,
|
||||
required = false,
|
||||
autocomplete,
|
||||
oninput,
|
||||
onkeydown,
|
||||
}: Props = $props();
|
||||
|
||||
const inputId = `input-${crypto.randomUUID().slice(0, 8)}`;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#if label}
|
||||
<label for={inputId} class="text-sm font-medium text-light/80">
|
||||
{label}
|
||||
{#if required}<span class="text-primary">*</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
{type}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{required}
|
||||
{autocomplete}
|
||||
{oninput}
|
||||
{onkeydown}
|
||||
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
|
||||
placeholder:text-light/40
|
||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
class:border-error={error}
|
||||
class:focus:border-error={error}
|
||||
class:focus:ring-error={error}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-error">{error}</p>
|
||||
{:else if hint}
|
||||
<p class="text-sm text-light/50">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
70
src/lib/components/ui/Modal.svelte
Normal file
70
src/lib/components/ui/Modal.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, title, size = 'md', children }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl'
|
||||
};
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'modal-title' : undefined}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[size]} shadow-xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
>
|
||||
{#if title}
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-light/10">
|
||||
<h2 id="modal-title" class="text-lg font-semibold text-light">{title}</h2>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-6">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
68
src/lib/components/ui/Select.svelte
Normal file
68
src/lib/components/ui/Select.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
options: Option[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(""),
|
||||
options,
|
||||
label,
|
||||
placeholder = "Select...",
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
}: Props = $props();
|
||||
|
||||
const inputId = `select-${crypto.randomUUID().slice(0, 8)}`;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#if label}
|
||||
<label for={inputId} class="text-sm font-medium text-light/80">
|
||||
{label}
|
||||
{#if required}<span class="text-primary">*</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<select
|
||||
id={inputId}
|
||||
bind:value
|
||||
{disabled}
|
||||
{required}
|
||||
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
|
||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors appearance-none cursor-pointer"
|
||||
class:border-error={error}
|
||||
class:placeholder-shown={!value}
|
||||
>
|
||||
<option value="" disabled>{placeholder}</option>
|
||||
{#each options as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-error">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 40px;
|
||||
}
|
||||
</style>
|
||||
34
src/lib/components/ui/Spinner.svelte
Normal file
34
src/lib/components/ui/Spinner.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
color?: 'primary' | 'light' | 'current';
|
||||
}
|
||||
|
||||
let { size = 'md', color = 'primary' }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'text-primary',
|
||||
light: 'text-light',
|
||||
current: 'text-current'
|
||||
};
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="animate-spin {sizeClasses[size]} {colorClasses[color]}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
66
src/lib/components/ui/Textarea.svelte
Normal file
66
src/lib/components/ui/Textarea.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
rows?: number;
|
||||
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder = '',
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
disabled = false,
|
||||
required = false,
|
||||
rows = 3,
|
||||
resize = 'vertical'
|
||||
}: Props = $props();
|
||||
|
||||
const inputId = `textarea-${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
const resizeClasses = {
|
||||
none: 'resize-none',
|
||||
vertical: 'resize-y',
|
||||
horizontal: 'resize-x',
|
||||
both: 'resize'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#if label}
|
||||
<label for={inputId} class="text-sm font-medium text-light/80">
|
||||
{label}
|
||||
{#if required}<span class="text-primary">*</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<textarea
|
||||
id={inputId}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{required}
|
||||
{rows}
|
||||
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
|
||||
placeholder:text-light/40
|
||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors {resizeClasses[resize]}"
|
||||
class:border-error={error}
|
||||
class:focus:border-error={error}
|
||||
class:focus:ring-error={error}
|
||||
></textarea>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-error">{error}</p>
|
||||
{:else if hint}
|
||||
<p class="text-sm text-light/50">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
40
src/lib/components/ui/Toggle.svelte
Normal file
40
src/lib/components/ui/Toggle.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
onchange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
let { checked = $bindable(false), disabled = false, size = 'md', onchange }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { track: 'w-8 h-4', thumb: 'w-3 h-3', translate: 'translate-x-4' },
|
||||
md: { track: 'w-10 h-5', thumb: 'w-4 h-4', translate: 'translate-x-5' },
|
||||
lg: { track: 'w-12 h-6', thumb: 'w-5 h-5', translate: 'translate-x-6' }
|
||||
};
|
||||
|
||||
function handleClick() {
|
||||
if (!disabled) {
|
||||
checked = !checked;
|
||||
onchange?.(checked);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
{disabled}
|
||||
onclick={handleClick}
|
||||
class="relative inline-flex items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-dark disabled:opacity-50 disabled:cursor-not-allowed {sizeClasses[
|
||||
size
|
||||
].track} {checked ? 'bg-primary' : 'bg-light/20'}"
|
||||
>
|
||||
<span
|
||||
class="inline-block rounded-full bg-white shadow-sm transition-transform duration-200 {sizeClasses[
|
||||
size
|
||||
].thumb} {checked ? sizeClasses[size].translate : 'translate-x-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
10
src/lib/components/ui/index.ts
Normal file
10
src/lib/components/ui/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as Button } from './Button.svelte';
|
||||
export { default as Input } from './Input.svelte';
|
||||
export { default as Textarea } from './Textarea.svelte';
|
||||
export { default as Select } from './Select.svelte';
|
||||
export { default as Avatar } from './Avatar.svelte';
|
||||
export { default as Badge } from './Badge.svelte';
|
||||
export { default as Card } from './Card.svelte';
|
||||
export { default as Modal } from './Modal.svelte';
|
||||
export { default as Spinner } from './Spinner.svelte';
|
||||
export { default as Toggle } from './Toggle.svelte';
|
||||
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
27
src/lib/stores/auth.svelte.ts
Normal file
27
src/lib/stores/auth.svelte.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
|
||||
class AuthStore {
|
||||
session = $state<Session | null>(null);
|
||||
user = $state<User | null>(null);
|
||||
isLoading = $state(true);
|
||||
|
||||
setSession(session: Session | null, user: User | null) {
|
||||
this.session = session;
|
||||
this.user = user;
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
get isAuthenticated() {
|
||||
return !!this.session && !!this.user;
|
||||
}
|
||||
|
||||
get userId() {
|
||||
return this.user?.id ?? null;
|
||||
}
|
||||
|
||||
get email() {
|
||||
return this.user?.email ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = new AuthStore();
|
||||
52
src/lib/stores/documents.svelte.ts
Normal file
52
src/lib/stores/documents.svelte.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Document } from '$lib/supabase/types';
|
||||
import type { DocumentWithChildren } from '$lib/api/documents';
|
||||
import { buildDocumentTree } from '$lib/api/documents';
|
||||
|
||||
class DocumentsStore {
|
||||
documents = $state<Document[]>([]);
|
||||
currentDocument = $state<Document | null>(null);
|
||||
isLoading = $state(false);
|
||||
isSaving = $state(false);
|
||||
|
||||
setDocuments(docs: Document[]) {
|
||||
this.documents = docs;
|
||||
}
|
||||
|
||||
setCurrentDocument(doc: Document | null) {
|
||||
this.currentDocument = doc;
|
||||
}
|
||||
|
||||
addDocument(doc: Document) {
|
||||
this.documents = [...this.documents, doc];
|
||||
}
|
||||
|
||||
updateDocument(id: string, updates: Partial<Document>) {
|
||||
this.documents = this.documents.map((doc) =>
|
||||
doc.id === id ? { ...doc, ...updates } : doc
|
||||
);
|
||||
if (this.currentDocument?.id === id) {
|
||||
this.currentDocument = { ...this.currentDocument, ...updates };
|
||||
}
|
||||
}
|
||||
|
||||
removeDocument(id: string) {
|
||||
this.documents = this.documents.filter((doc) => doc.id !== id);
|
||||
if (this.currentDocument?.id === id) {
|
||||
this.currentDocument = null;
|
||||
}
|
||||
}
|
||||
|
||||
get tree(): DocumentWithChildren[] {
|
||||
return buildDocumentTree(this.documents);
|
||||
}
|
||||
|
||||
get folders() {
|
||||
return this.documents.filter((doc) => doc.type === 'folder');
|
||||
}
|
||||
|
||||
get files() {
|
||||
return this.documents.filter((doc) => doc.type === 'document');
|
||||
}
|
||||
}
|
||||
|
||||
export const docs = new DocumentsStore();
|
||||
2
src/lib/stores/index.ts
Normal file
2
src/lib/stores/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { auth } from './auth.svelte';
|
||||
export { orgs, type OrgWithRole } from './organizations.svelte';
|
||||
59
src/lib/stores/organizations.svelte.ts
Normal file
59
src/lib/stores/organizations.svelte.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Organization, OrgMember, MemberRole } from '$lib/supabase/types';
|
||||
|
||||
export interface OrgWithRole extends Organization {
|
||||
role: MemberRole;
|
||||
memberCount?: number;
|
||||
}
|
||||
|
||||
class OrganizationsStore {
|
||||
organizations = $state<OrgWithRole[]>([]);
|
||||
currentOrg = $state<OrgWithRole | null>(null);
|
||||
members = $state<(OrgMember & { profile?: { email: string; full_name: string | null; avatar_url: string | null } })[]>([]);
|
||||
isLoading = $state(false);
|
||||
|
||||
setOrganizations(orgs: OrgWithRole[]) {
|
||||
this.organizations = orgs;
|
||||
}
|
||||
|
||||
setCurrentOrg(org: OrgWithRole | null) {
|
||||
this.currentOrg = org;
|
||||
}
|
||||
|
||||
setMembers(members: typeof this.members) {
|
||||
this.members = members;
|
||||
}
|
||||
|
||||
addOrganization(org: OrgWithRole) {
|
||||
this.organizations = [...this.organizations, org];
|
||||
}
|
||||
|
||||
updateOrganization(id: string, updates: Partial<Organization>) {
|
||||
this.organizations = this.organizations.map((org) =>
|
||||
org.id === id ? { ...org, ...updates } : org
|
||||
);
|
||||
if (this.currentOrg?.id === id) {
|
||||
this.currentOrg = { ...this.currentOrg, ...updates };
|
||||
}
|
||||
}
|
||||
|
||||
removeOrganization(id: string) {
|
||||
this.organizations = this.organizations.filter((org) => org.id !== id);
|
||||
if (this.currentOrg?.id === id) {
|
||||
this.currentOrg = null;
|
||||
}
|
||||
}
|
||||
|
||||
get hasOrganizations() {
|
||||
return this.organizations.length > 0;
|
||||
}
|
||||
|
||||
get isOwnerOrAdmin() {
|
||||
return this.currentOrg?.role === 'owner' || this.currentOrg?.role === 'admin';
|
||||
}
|
||||
|
||||
get canEdit() {
|
||||
return ['owner', 'admin', 'editor'].includes(this.currentOrg?.role ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
export const orgs = new OrganizationsStore();
|
||||
7
src/lib/supabase/client.ts
Normal file
7
src/lib/supabase/client.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createBrowserClient } from '@supabase/ssr';
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
import type { Database } from './types';
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
|
||||
}
|
||||
3
src/lib/supabase/index.ts
Normal file
3
src/lib/supabase/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { createClient } from './client';
|
||||
export { createClient as createServerClient } from './server';
|
||||
export type * from './types';
|
||||
19
src/lib/supabase/server.ts
Normal file
19
src/lib/supabase/server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
import type { Database } from './types';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
|
||||
export function createClient(cookies: Cookies) {
|
||||
return createServerClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
cookies.set(name, value, { ...options, path: '/' });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
288
src/lib/supabase/types.ts
Normal file
288
src/lib/supabase/types.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
organizations: {
|
||||
Row: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
avatar_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
avatar_url?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
avatar_url?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
org_members: {
|
||||
Row: {
|
||||
id: string;
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
role: 'owner' | 'admin' | 'editor' | 'viewer';
|
||||
invited_at: string;
|
||||
joined_at: string | null;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
role: 'owner' | 'admin' | 'editor' | 'viewer';
|
||||
invited_at?: string;
|
||||
joined_at?: string | null;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
org_id?: string;
|
||||
user_id?: string;
|
||||
role?: 'owner' | 'admin' | 'editor' | 'viewer';
|
||||
invited_at?: string;
|
||||
joined_at?: string | null;
|
||||
};
|
||||
};
|
||||
documents: {
|
||||
Row: {
|
||||
id: string;
|
||||
org_id: string;
|
||||
parent_id: string | null;
|
||||
type: 'folder' | 'document';
|
||||
name: string;
|
||||
content: Json | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
org_id: string;
|
||||
parent_id?: string | null;
|
||||
type: 'folder' | 'document';
|
||||
name: string;
|
||||
content?: Json | null;
|
||||
created_by: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
org_id?: string;
|
||||
parent_id?: string | null;
|
||||
type?: 'folder' | 'document';
|
||||
name?: string;
|
||||
content?: Json | null;
|
||||
created_by?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
kanban_boards: {
|
||||
Row: {
|
||||
id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
org_id?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
kanban_columns: {
|
||||
Row: {
|
||||
id: string;
|
||||
board_id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
color: string | null;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
board_id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
color?: string | null;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
board_id?: string;
|
||||
name?: string;
|
||||
position?: number;
|
||||
color?: string | null;
|
||||
};
|
||||
};
|
||||
kanban_cards: {
|
||||
Row: {
|
||||
id: string;
|
||||
column_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
position: number;
|
||||
due_date: string | null;
|
||||
color: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
column_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
position: number;
|
||||
due_date?: string | null;
|
||||
color?: string | null;
|
||||
created_by: string;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
column_id?: string;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
position?: number;
|
||||
due_date?: string | null;
|
||||
color?: string | null;
|
||||
created_by?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
card_assignees: {
|
||||
Row: {
|
||||
card_id: string;
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
card_id: string;
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
card_id?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
};
|
||||
calendar_events: {
|
||||
Row: {
|
||||
id: string;
|
||||
org_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
all_day: boolean;
|
||||
color: string | null;
|
||||
recurrence: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
org_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
all_day?: boolean;
|
||||
color?: string | null;
|
||||
recurrence?: string | null;
|
||||
created_by: string;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
org_id?: string;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
all_day?: boolean;
|
||||
color?: string | null;
|
||||
recurrence?: string | null;
|
||||
created_by?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
event_attendees: {
|
||||
Row: {
|
||||
event_id: string;
|
||||
user_id: string;
|
||||
status: 'pending' | 'accepted' | 'declined';
|
||||
};
|
||||
Insert: {
|
||||
event_id: string;
|
||||
user_id: string;
|
||||
status?: 'pending' | 'accepted' | 'declined';
|
||||
};
|
||||
Update: {
|
||||
event_id?: string;
|
||||
user_id?: string;
|
||||
status?: 'pending' | 'accepted' | 'declined';
|
||||
};
|
||||
};
|
||||
profiles: {
|
||||
Row: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name?: string | null;
|
||||
avatar_url?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
full_name?: string | null;
|
||||
avatar_url?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
Views: Record<string, never>;
|
||||
Functions: Record<string, never>;
|
||||
Enums: {
|
||||
member_role: 'owner' | 'admin' | 'editor' | 'viewer';
|
||||
attendee_status: 'pending' | 'accepted' | 'declined';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Convenience types
|
||||
export type Organization = Database['public']['Tables']['organizations']['Row'];
|
||||
export type OrgMember = Database['public']['Tables']['org_members']['Row'];
|
||||
export type Document = Database['public']['Tables']['documents']['Row'];
|
||||
export type KanbanBoard = Database['public']['Tables']['kanban_boards']['Row'];
|
||||
export type KanbanColumn = Database['public']['Tables']['kanban_columns']['Row'];
|
||||
export type KanbanCard = Database['public']['Tables']['kanban_cards']['Row'];
|
||||
export type CalendarEvent = Database['public']['Tables']['calendar_events']['Row'];
|
||||
export type Profile = Database['public']['Tables']['profiles']['Row'];
|
||||
export type MemberRole = Database['public']['Enums']['member_role'];
|
||||
6
src/routes/+layout.server.ts
Normal file
6
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
const { session, user } = await locals.safeGetSession();
|
||||
return { session, user };
|
||||
};
|
||||
14
src/routes/+layout.svelte
Normal file
14
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.svg";
|
||||
import { createClient } from "$lib/supabase";
|
||||
import { setContext } from "svelte";
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
const supabase = createClient();
|
||||
setContext("supabase", supabase);
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
{@render children()}
|
||||
27
src/routes/+page.server.ts
Normal file
27
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { session, user } = await locals.safeGetSession();
|
||||
|
||||
if (!session || !user) {
|
||||
redirect(303, '/login');
|
||||
}
|
||||
|
||||
const { data: memberships } = await locals.supabase
|
||||
.from('org_members')
|
||||
.select(`
|
||||
role,
|
||||
organization:organizations(*)
|
||||
`)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
const organizations = (memberships ?? []).map((m) => ({
|
||||
...m.organization,
|
||||
role: m.role
|
||||
}));
|
||||
|
||||
return {
|
||||
organizations
|
||||
};
|
||||
};
|
||||
190
src/routes/+page.svelte
Normal file
190
src/routes/+page.svelte
Normal file
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { Button, Card, Modal, Input } from "$lib/components/ui";
|
||||
import { createOrganization, generateSlug } from "$lib/api/organizations";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
|
||||
interface OrgWithRole {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
organizations: OrgWithRole[];
|
||||
user: any;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let organizations = $state(data.organizations);
|
||||
let showCreateModal = $state(false);
|
||||
let newOrgName = $state("");
|
||||
let creating = $state(false);
|
||||
|
||||
async function handleCreateOrg() {
|
||||
if (!newOrgName.trim() || creating) return;
|
||||
|
||||
creating = true;
|
||||
try {
|
||||
const slug = generateSlug(newOrgName);
|
||||
|
||||
const newOrg = await createOrganization(supabase, newOrgName, slug);
|
||||
organizations = [...organizations, { ...newOrg, role: "owner" }];
|
||||
|
||||
showCreateModal = false;
|
||||
newOrgName = "";
|
||||
} catch (error) {
|
||||
console.error("Failed to create organization:", error);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-dark">
|
||||
<header class="border-b border-light/10 bg-surface">
|
||||
<div
|
||||
class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between"
|
||||
>
|
||||
<h1 class="text-xl font-bold text-light">Root Org</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/style" class="text-sm text-light/60 hover:text-light"
|
||||
>Style Guide</a
|
||||
>
|
||||
<form method="POST" action="/auth/logout">
|
||||
<Button variant="ghost" size="sm" type="submit"
|
||||
>Sign Out</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-light">
|
||||
Your Organizations
|
||||
</h2>
|
||||
<p class="text-light/50 mt-1">
|
||||
Select an organization to get started
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
New Organization
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if organizations.length === 0}
|
||||
<Card>
|
||||
<div class="p-12 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto mb-4 text-light/30"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-light mb-2">
|
||||
No organizations yet
|
||||
</h3>
|
||||
<p class="text-light/50 mb-6">
|
||||
Create your first organization to start collaborating
|
||||
</p>
|
||||
<Button onclick={() => (showCreateModal = true)}
|
||||
>Create Organization</Button
|
||||
>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each organizations as org}
|
||||
<a href="/{org.slug}" class="block group">
|
||||
<Card
|
||||
class="h-full hover:ring-1 hover:ring-primary/50 transition-all"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div
|
||||
class="flex items-start justify-between mb-4"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 bg-primary/20 rounded-xl flex items-center justify-center text-primary font-bold text-lg"
|
||||
>
|
||||
{org.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span
|
||||
class="text-xs px-2 py-1 bg-light/10 rounded text-light/60 capitalize"
|
||||
>
|
||||
{org.role}
|
||||
</span>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg font-semibold text-light group-hover:text-primary transition-colors"
|
||||
>
|
||||
{org.name}
|
||||
</h3>
|
||||
<p class="text-sm text-light/40 mt-1">
|
||||
/{org.slug}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => (showCreateModal = false)}
|
||||
title="Create Organization"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
label="Organization Name"
|
||||
bind:value={newOrgName}
|
||||
placeholder="e.g. Acme Inc"
|
||||
/>
|
||||
{#if newOrgName}
|
||||
<p class="text-sm text-light/50">
|
||||
URL: <span class="text-light/70"
|
||||
>/{generateSlug(newOrgName)}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onclick={() => (showCreateModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
onclick={handleCreateOrg}
|
||||
disabled={!newOrgName.trim() || creating}
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
36
src/routes/[orgSlug]/+layout.server.ts
Normal file
36
src/routes/[orgSlug]/+layout.server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ params, locals }) => {
|
||||
const { session, user } = await locals.safeGetSession();
|
||||
|
||||
if (!session || !user) {
|
||||
error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const { data: org, error: orgError } = await locals.supabase
|
||||
.from('organizations')
|
||||
.select('*')
|
||||
.eq('slug', params.orgSlug)
|
||||
.single();
|
||||
|
||||
if (orgError || !org) {
|
||||
error(404, 'Organization not found');
|
||||
}
|
||||
|
||||
const { data: membership } = await locals.supabase
|
||||
.from('org_members')
|
||||
.select('role')
|
||||
.eq('org_id', org.id)
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (!membership) {
|
||||
error(403, 'You are not a member of this organization');
|
||||
}
|
||||
|
||||
return {
|
||||
org,
|
||||
role: membership.role
|
||||
};
|
||||
};
|
||||
151
src/routes/[orgSlug]/+layout.svelte
Normal file
151
src/routes/[orgSlug]/+layout.svelte
Normal file
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
role: string;
|
||||
};
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { data, children }: Props = $props();
|
||||
|
||||
const navItems = [
|
||||
{ href: `/${data.org.slug}`, label: "Overview", icon: "home" },
|
||||
{
|
||||
href: `/${data.org.slug}/documents`,
|
||||
label: "Documents",
|
||||
icon: "file",
|
||||
},
|
||||
{ href: `/${data.org.slug}/kanban`, label: "Kanban", icon: "kanban" },
|
||||
{
|
||||
href: `/${data.org.slug}/calendar`,
|
||||
label: "Calendar",
|
||||
icon: "calendar",
|
||||
},
|
||||
{
|
||||
href: `/${data.org.slug}/settings`,
|
||||
label: "Settings",
|
||||
icon: "settings",
|
||||
},
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return $page.url.pathname === href;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen bg-dark">
|
||||
<aside class="w-64 bg-surface border-r border-light/10 flex flex-col">
|
||||
<div class="p-4 border-b border-light/10">
|
||||
<h1 class="text-lg font-semibold text-light truncate">
|
||||
{data.org.name}
|
||||
</h1>
|
||||
<p class="text-xs text-light/50 capitalize">{data.role}</p>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-2 space-y-1">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors {isActive(
|
||||
item.href,
|
||||
)
|
||||
? 'bg-primary text-white'
|
||||
: 'text-light/70 hover:bg-light/5 hover:text-light'}"
|
||||
>
|
||||
{#if item.icon === "home"}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
<polyline points="9,22 9,12 15,12 15,22" />
|
||||
</svg>
|
||||
{:else if item.icon === "file"}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
</svg>
|
||||
{:else if item.icon === "kanban"}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
{:else if item.icon === "calendar"}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{:else if item.icon === "settings"}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M12 1v2m0 18v2M4.2 4.2l1.4 1.4m12.8 12.8l1.4 1.4M1 12h2m18 0h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-light/10">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 text-sm text-light/50 hover:text-light transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
All Organizations
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 overflow-auto">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
68
src/routes/[orgSlug]/+page.svelte
Normal file
68
src/routes/[orgSlug]/+page.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { Card } from '$lib/components/ui';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const quickLinks = [
|
||||
{ href: `/${data.org.slug}/documents`, label: 'Documents', description: 'Collaborative docs and files', icon: 'file' },
|
||||
{ href: `/${data.org.slug}/kanban`, label: 'Kanban', description: 'Track tasks and projects', icon: 'kanban' },
|
||||
{ href: `/${data.org.slug}/calendar`, label: 'Calendar', description: 'Schedule events and meetings', icon: 'calendar' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="p-8">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-light">{data.org.name}</h1>
|
||||
<p class="text-light/50 mt-1">Organization Overview</p>
|
||||
</header>
|
||||
|
||||
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
{#each quickLinks as link}
|
||||
<a href={link.href} class="block group">
|
||||
<Card class="h-full hover:ring-1 hover:ring-primary/50 transition-all">
|
||||
<div class="p-6">
|
||||
<div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
||||
{#if link.icon === 'file'}
|
||||
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
</svg>
|
||||
{:else if link.icon === 'kanban'}
|
||||
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
{:else if link.icon === 'calendar'}
|
||||
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-light mb-1">{link.label}</h3>
|
||||
<p class="text-sm text-light/50">{link.description}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold text-light mb-4">Recent Activity</h2>
|
||||
<Card>
|
||||
<div class="p-6 text-center text-light/50">
|
||||
<p>No recent activity to show</p>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
23
src/routes/[orgSlug]/calendar/+page.server.ts
Normal file
23
src/routes/[orgSlug]/calendar/+page.server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { org } = await parent();
|
||||
const { supabase } = locals;
|
||||
|
||||
// Fetch events for current month ± 1 month
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 2, 0);
|
||||
|
||||
const { data: events } = await supabase
|
||||
.from('calendar_events')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.gte('start_time', startDate.toISOString())
|
||||
.lte('end_time', endDate.toISOString())
|
||||
.order('start_time');
|
||||
|
||||
return {
|
||||
events: events ?? []
|
||||
};
|
||||
};
|
||||
239
src/routes/[orgSlug]/calendar/+page.svelte
Normal file
239
src/routes/[orgSlug]/calendar/+page.svelte
Normal file
@@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { Button, Modal, Input, Textarea } from "$lib/components/ui";
|
||||
import { Calendar } from "$lib/components/calendar";
|
||||
import { createEvent } from "$lib/api/calendar";
|
||||
import type { CalendarEvent } from "$lib/supabase/types";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
events: CalendarEvent[];
|
||||
user: { id: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let events = $state(data.events);
|
||||
let showCreateModal = $state(false);
|
||||
let showEventModal = $state(false);
|
||||
let selectedEvent = $state<CalendarEvent | null>(null);
|
||||
let selectedDate = $state<Date | null>(null);
|
||||
|
||||
let newEvent = $state({
|
||||
title: "",
|
||||
description: "",
|
||||
date: "",
|
||||
startTime: "09:00",
|
||||
endTime: "10:00",
|
||||
allDay: false,
|
||||
color: "#6366f1",
|
||||
});
|
||||
|
||||
const colorOptions = [
|
||||
{ value: "#6366f1", label: "Indigo" },
|
||||
{ value: "#ec4899", label: "Pink" },
|
||||
{ value: "#10b981", label: "Green" },
|
||||
{ value: "#f59e0b", label: "Amber" },
|
||||
{ value: "#ef4444", label: "Red" },
|
||||
{ value: "#8b5cf6", label: "Purple" },
|
||||
];
|
||||
|
||||
function handleDateClick(date: Date) {
|
||||
selectedDate = date;
|
||||
newEvent.date = date.toISOString().split("T")[0];
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
function handleEventClick(event: CalendarEvent) {
|
||||
selectedEvent = event;
|
||||
showEventModal = true;
|
||||
}
|
||||
|
||||
async function handleCreateEvent() {
|
||||
if (!newEvent.title.trim() || !newEvent.date || !data.user) return;
|
||||
|
||||
const startTime = newEvent.allDay
|
||||
? `${newEvent.date}T00:00:00`
|
||||
: `${newEvent.date}T${newEvent.startTime}:00`;
|
||||
const endTime = newEvent.allDay
|
||||
? `${newEvent.date}T23:59:59`
|
||||
: `${newEvent.date}T${newEvent.endTime}:00`;
|
||||
|
||||
const created = await createEvent(
|
||||
supabase,
|
||||
data.org.id,
|
||||
{
|
||||
title: newEvent.title,
|
||||
description: newEvent.description || undefined,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
all_day: newEvent.allDay,
|
||||
color: newEvent.color,
|
||||
},
|
||||
data.user.id,
|
||||
);
|
||||
|
||||
events = [...events, created];
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
showCreateModal = false;
|
||||
newEvent = {
|
||||
title: "",
|
||||
description: "",
|
||||
date: "",
|
||||
startTime: "09:00",
|
||||
endTime: "10:00",
|
||||
allDay: false,
|
||||
color: "#6366f1",
|
||||
};
|
||||
selectedDate = null;
|
||||
}
|
||||
|
||||
function formatEventTime(event: CalendarEvent): string {
|
||||
if (event.all_day) return "All day";
|
||||
const start = new Date(event.start_time);
|
||||
const end = new Date(event.end_time);
|
||||
return `${start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 h-full overflow-auto">
|
||||
<header class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-light">Calendar</h1>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
New Event
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Calendar
|
||||
{events}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showCreateModal} onClose={resetForm} title="Create Event">
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
label="Title"
|
||||
bind:value={newEvent.title}
|
||||
placeholder="Event title"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
bind:value={newEvent.description}
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Input label="Date" type="date" bind:value={newEvent.date} />
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-light">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={newEvent.allDay}
|
||||
class="rounded"
|
||||
/>
|
||||
All day event
|
||||
</label>
|
||||
|
||||
{#if !newEvent.allDay}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Start Time"
|
||||
type="time"
|
||||
bind:value={newEvent.startTime}
|
||||
/>
|
||||
<Input
|
||||
label="End Time"
|
||||
type="time"
|
||||
bind:value={newEvent.endTime}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-light mb-2"
|
||||
>Color</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
{#each colorOptions as color}
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 rounded-full transition-transform"
|
||||
class:ring-2={newEvent.color === color.value}
|
||||
class:ring-white={newEvent.color === color.value}
|
||||
class:scale-110={newEvent.color === color.value}
|
||||
style="background-color: {color.value}"
|
||||
onclick={() => (newEvent.color = color.value)}
|
||||
aria-label={color.label}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onclick={resetForm}>Cancel</Button>
|
||||
<Button
|
||||
onclick={handleCreateEvent}
|
||||
disabled={!newEvent.title.trim() || !newEvent.date}
|
||||
>Create</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showEventModal}
|
||||
onClose={() => (showEventModal = false)}
|
||||
title={selectedEvent?.title ?? "Event"}
|
||||
>
|
||||
{#if selectedEvent}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: {selectedEvent.color ?? '#6366f1'}"
|
||||
></div>
|
||||
<span class="text-light/70"
|
||||
>{formatEventTime(selectedEvent)}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if selectedEvent.description}
|
||||
<p class="text-light/80">{selectedEvent.description}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-light/40">
|
||||
{new Date(selectedEvent.start_time).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
16
src/routes/[orgSlug]/documents/+page.server.ts
Normal file
16
src/routes/[orgSlug]/documents/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { org } = await parent();
|
||||
const { supabase } = locals;
|
||||
|
||||
const { data: documents } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.order('name');
|
||||
|
||||
return {
|
||||
documents: documents ?? []
|
||||
};
|
||||
};
|
||||
212
src/routes/[orgSlug]/documents/+page.svelte
Normal file
212
src/routes/[orgSlug]/documents/+page.svelte
Normal file
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { Button, Modal, Input } from "$lib/components/ui";
|
||||
import { FileTree, Editor } from "$lib/components/documents";
|
||||
import { buildDocumentTree } from "$lib/api/documents";
|
||||
import type { Document } from "$lib/supabase/types";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
documents: Document[];
|
||||
user: { id: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let documents = $state(data.documents);
|
||||
let selectedDoc = $state<Document | null>(null);
|
||||
let showCreateModal = $state(false);
|
||||
let newDocName = $state("");
|
||||
let newDocType = $state<"folder" | "document">("document");
|
||||
let parentFolderId = $state<string | null>(null);
|
||||
|
||||
const documentTree = $derived(buildDocumentTree(documents));
|
||||
|
||||
function handleSelect(doc: Document) {
|
||||
if (doc.type === "document") {
|
||||
selectedDoc = doc;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd(folderId: string | null) {
|
||||
parentFolderId = folderId;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
async function handleMove(docId: string, newParentId: string | null) {
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
parent_id: newParentId,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", docId);
|
||||
|
||||
if (!error) {
|
||||
documents = documents.map((d) =>
|
||||
d.id === docId ? { ...d, parent_id: newParentId } : d,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newDocName.trim() || !data.user) return;
|
||||
|
||||
const { data: newDoc, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
org_id: data.org.id,
|
||||
name: newDocName,
|
||||
type: newDocType,
|
||||
parent_id: parentFolderId,
|
||||
created_by: data.user.id,
|
||||
content:
|
||||
newDocType === "document"
|
||||
? { type: "doc", content: [] }
|
||||
: null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && newDoc) {
|
||||
documents = [...documents, newDoc];
|
||||
if (newDocType === "document") {
|
||||
selectedDoc = newDoc;
|
||||
}
|
||||
}
|
||||
|
||||
showCreateModal = false;
|
||||
newDocName = "";
|
||||
newDocType = "document";
|
||||
parentFolderId = null;
|
||||
}
|
||||
|
||||
async function handleSave(content: unknown) {
|
||||
if (!selectedDoc) return;
|
||||
|
||||
await supabase
|
||||
.from("documents")
|
||||
.update({ content, updated_at: new Date().toISOString() })
|
||||
.eq("id", selectedDoc.id);
|
||||
|
||||
documents = documents.map((d) =>
|
||||
d.id === selectedDoc!.id ? { ...d, content } : d,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
<aside class="w-72 border-r border-light/10 flex flex-col">
|
||||
<div
|
||||
class="p-4 border-b border-light/10 flex items-center justify-between"
|
||||
>
|
||||
<h2 class="font-semibold text-light">Documents</h2>
|
||||
<Button size="sm" onclick={() => (showCreateModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4 mr-1"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
{#if documentTree.length === 0}
|
||||
<div class="text-center text-light/40 py-8 text-sm">
|
||||
<p>No documents yet</p>
|
||||
<p class="mt-1">Create your first document</p>
|
||||
</div>
|
||||
{:else}
|
||||
<FileTree
|
||||
items={documentTree}
|
||||
selectedId={selectedDoc?.id ?? null}
|
||||
onSelect={handleSelect}
|
||||
onAdd={handleAdd}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 overflow-hidden">
|
||||
{#if selectedDoc}
|
||||
<Editor document={selectedDoc} onSave={handleSave} />
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-light/40">
|
||||
<div class="text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto mb-4 opacity-50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
</svg>
|
||||
<p>Select a document to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => (showCreateModal = false)}
|
||||
title="Create New"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
|
||||
'document'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-light/20'}"
|
||||
onclick={() => (newDocType = "document")}
|
||||
>
|
||||
Document
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
|
||||
'folder'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-light/20'}"
|
||||
onclick={() => (newDocType = "folder")}
|
||||
>
|
||||
Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={newDocName}
|
||||
placeholder={newDocType === "folder"
|
||||
? "Folder name"
|
||||
: "Document name"}
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onclick={() => (showCreateModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleCreate} disabled={!newDocName.trim()}
|
||||
>Create</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
16
src/routes/[orgSlug]/kanban/+page.server.ts
Normal file
16
src/routes/[orgSlug]/kanban/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { org } = await parent();
|
||||
const { supabase } = locals;
|
||||
|
||||
const { data: boards } = await supabase
|
||||
.from('kanban_boards')
|
||||
.select('*')
|
||||
.eq('org_id', org.id)
|
||||
.order('created_at');
|
||||
|
||||
return {
|
||||
boards: boards ?? []
|
||||
};
|
||||
};
|
||||
256
src/routes/[orgSlug]/kanban/+page.svelte
Normal file
256
src/routes/[orgSlug]/kanban/+page.svelte
Normal file
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { Button, Card, Modal, Input } from "$lib/components/ui";
|
||||
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
|
||||
import {
|
||||
fetchBoardWithColumns,
|
||||
createBoard,
|
||||
createCard,
|
||||
moveCard,
|
||||
} from "$lib/api/kanban";
|
||||
import type {
|
||||
KanbanBoard as KanbanBoardType,
|
||||
KanbanCard,
|
||||
} from "$lib/supabase/types";
|
||||
import type { BoardWithColumns } from "$lib/api/kanban";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
boards: KanbanBoardType[];
|
||||
user: { id: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let boards = $state(data.boards);
|
||||
let selectedBoard = $state<BoardWithColumns | null>(null);
|
||||
let showCreateBoardModal = $state(false);
|
||||
let showCreateCardModal = $state(false);
|
||||
let showCardDetailModal = $state(false);
|
||||
let selectedCard = $state<KanbanCard | null>(null);
|
||||
let newBoardName = $state("");
|
||||
let newCardTitle = $state("");
|
||||
let targetColumnId = $state<string | null>(null);
|
||||
|
||||
async function loadBoard(boardId: string) {
|
||||
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
|
||||
}
|
||||
|
||||
async function handleCreateBoard() {
|
||||
if (!newBoardName.trim()) return;
|
||||
|
||||
const newBoard = await createBoard(supabase, data.org.id, newBoardName);
|
||||
boards = [...boards, newBoard];
|
||||
await loadBoard(newBoard.id);
|
||||
|
||||
showCreateBoardModal = false;
|
||||
newBoardName = "";
|
||||
}
|
||||
|
||||
async function handleAddCard(columnId: string) {
|
||||
targetColumnId = columnId;
|
||||
showCreateCardModal = true;
|
||||
}
|
||||
|
||||
async function handleCreateCard() {
|
||||
if (
|
||||
!newCardTitle.trim() ||
|
||||
!targetColumnId ||
|
||||
!selectedBoard ||
|
||||
!data.user
|
||||
)
|
||||
return;
|
||||
|
||||
const column = selectedBoard.columns.find(
|
||||
(c) => c.id === targetColumnId,
|
||||
);
|
||||
const position = column?.cards.length ?? 0;
|
||||
|
||||
await createCard(
|
||||
supabase,
|
||||
targetColumnId,
|
||||
newCardTitle,
|
||||
position,
|
||||
data.user.id,
|
||||
);
|
||||
await loadBoard(selectedBoard.id);
|
||||
|
||||
showCreateCardModal = false;
|
||||
newCardTitle = "";
|
||||
targetColumnId = null;
|
||||
}
|
||||
|
||||
async function handleCardMove(
|
||||
cardId: string,
|
||||
toColumnId: string,
|
||||
toPosition: number,
|
||||
) {
|
||||
if (!selectedBoard) return;
|
||||
|
||||
await moveCard(supabase, cardId, toColumnId, toPosition);
|
||||
await loadBoard(selectedBoard.id);
|
||||
}
|
||||
|
||||
function handleCardClick(card: KanbanCard) {
|
||||
selectedCard = card;
|
||||
showCardDetailModal = true;
|
||||
}
|
||||
|
||||
function handleCardUpdate(updatedCard: KanbanCard) {
|
||||
if (!selectedBoard) return;
|
||||
selectedBoard = {
|
||||
...selectedBoard,
|
||||
columns: selectedBoard.columns.map((col) => ({
|
||||
...col,
|
||||
cards: col.cards.map((c) =>
|
||||
c.id === updatedCard.id ? updatedCard : c,
|
||||
),
|
||||
})),
|
||||
};
|
||||
selectedCard = updatedCard;
|
||||
}
|
||||
|
||||
async function handleCardDelete(cardId: string) {
|
||||
if (!selectedBoard) return;
|
||||
await loadBoard(selectedBoard.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
<aside class="w-64 border-r border-light/10 flex flex-col">
|
||||
<div
|
||||
class="p-4 border-b border-light/10 flex items-center justify-between"
|
||||
>
|
||||
<h2 class="font-semibold text-light">Boards</h2>
|
||||
<Button size="sm" onclick={() => (showCreateBoardModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{#if boards.length === 0}
|
||||
<div class="text-center text-light/40 py-8 text-sm">
|
||||
<p>No boards yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each boards as board}
|
||||
<button
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {selectedBoard?.id ===
|
||||
board.id
|
||||
? 'bg-primary text-white'
|
||||
: 'text-light/70 hover:bg-light/5'}"
|
||||
onclick={() => loadBoard(board.id)}
|
||||
>
|
||||
{board.name}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 overflow-hidden p-6">
|
||||
{#if selectedBoard}
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-light">
|
||||
{selectedBoard.name}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<KanbanBoard
|
||||
columns={selectedBoard.columns}
|
||||
onCardClick={handleCardClick}
|
||||
onCardMove={handleCardMove}
|
||||
onAddCard={handleAddCard}
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-light/40">
|
||||
<div class="text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto mb-4 opacity-50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
<p>Select a board or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showCreateBoardModal}
|
||||
onClose={() => (showCreateBoardModal = false)}
|
||||
title="Create Board"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
label="Board Name"
|
||||
bind:value={newBoardName}
|
||||
placeholder="e.g. Sprint 1"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
|
||||
>Create</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showCreateCardModal}
|
||||
onClose={() => (showCreateCardModal = false)}
|
||||
title="Add Card"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
label="Title"
|
||||
bind:value={newCardTitle}
|
||||
placeholder="Card title"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => (showCreateCardModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button onclick={handleCreateCard} disabled={!newCardTitle.trim()}
|
||||
>Add</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<CardDetailModal
|
||||
card={selectedCard}
|
||||
isOpen={showCardDetailModal}
|
||||
onClose={() => {
|
||||
showCardDetailModal = false;
|
||||
selectedCard = null;
|
||||
}}
|
||||
onUpdate={handleCardUpdate}
|
||||
onDelete={handleCardDelete}
|
||||
/>
|
||||
43
src/routes/api/google-calendar/callback/+server.ts
Normal file
43
src/routes/api/google-calendar/callback/+server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { exchangeCodeForTokens } from '$lib/api/google-calendar';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const code = url.searchParams.get('code');
|
||||
const stateParam = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error || !code || !stateParam) {
|
||||
redirect(303, '/?error=google_auth_failed');
|
||||
}
|
||||
|
||||
let state: { orgSlug: string; userId: string };
|
||||
try {
|
||||
state = JSON.parse(decodeURIComponent(stateParam));
|
||||
} catch {
|
||||
redirect(303, '/?error=invalid_state');
|
||||
}
|
||||
|
||||
const redirectUri = `${url.origin}/api/google-calendar/callback`;
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForTokens(code, redirectUri);
|
||||
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
|
||||
// Store tokens in database
|
||||
await locals.supabase
|
||||
.from('google_calendar_connections')
|
||||
.upsert({
|
||||
user_id: state.userId,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_expires_at: expiresAt.toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}, { onConflict: 'user_id' });
|
||||
|
||||
redirect(303, `/${state.orgSlug}/calendar?connected=true`);
|
||||
} catch (err) {
|
||||
console.error('Google Calendar OAuth error:', err);
|
||||
redirect(303, `/${state.orgSlug}/calendar?error=token_exchange_failed`);
|
||||
}
|
||||
};
|
||||
22
src/routes/api/google-calendar/connect/+server.ts
Normal file
22
src/routes/api/google-calendar/connect/+server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGoogleAuthUrl } from '$lib/api/google-calendar';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const { session } = await locals.safeGetSession();
|
||||
|
||||
if (!session) {
|
||||
redirect(303, '/login');
|
||||
}
|
||||
|
||||
const orgSlug = url.searchParams.get('org');
|
||||
if (!orgSlug) {
|
||||
redirect(303, '/');
|
||||
}
|
||||
|
||||
const redirectUri = `${url.origin}/api/google-calendar/callback`;
|
||||
const state = JSON.stringify({ orgSlug, userId: session.user.id });
|
||||
const authUrl = getGoogleAuthUrl(redirectUri, encodeURIComponent(state));
|
||||
|
||||
redirect(303, authUrl);
|
||||
};
|
||||
16
src/routes/auth/callback/+server.ts
Normal file
16
src/routes/auth/callback/+server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const code = url.searchParams.get('code');
|
||||
const next = url.searchParams.get('next') ?? '/';
|
||||
|
||||
if (code) {
|
||||
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);
|
||||
if (!error) {
|
||||
redirect(303, next);
|
||||
}
|
||||
}
|
||||
|
||||
redirect(303, '/login?error=auth_callback_error');
|
||||
};
|
||||
8
src/routes/auth/logout/+server.ts
Normal file
8
src/routes/auth/logout/+server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ locals }) => {
|
||||
const { supabase } = locals;
|
||||
await supabase.auth.signOut();
|
||||
redirect(303, '/login');
|
||||
};
|
||||
9
src/routes/health/+server.ts
Normal file
9
src/routes/health/+server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
return json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
49
src/routes/layout.css
Normal file
49
src/routes/layout.css
Normal file
@@ -0,0 +1,49 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@theme {
|
||||
/* Colors - Dark theme */
|
||||
--color-dark: #0a0a0f;
|
||||
--color-surface: #14141f;
|
||||
--color-light: #f0f0f5;
|
||||
|
||||
/* Brand */
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-hover: #4f46e5;
|
||||
|
||||
/* Status */
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #3b82f6;
|
||||
|
||||
/* Font */
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
background-color: var(--color-dark);
|
||||
color: var(--color-light);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-dark);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-light) / 0.2;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-light) / 0.3;
|
||||
}
|
||||
167
src/routes/login/+page.svelte
Normal file
167
src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { Button, Input, Card } from "$lib/components/ui";
|
||||
import { createClient } from "$lib/supabase";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
let isLoading = $state(false);
|
||||
let error = $state("");
|
||||
let mode = $state<"login" | "signup">("login");
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !password) {
|
||||
error = "Please fill in all fields";
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
error = "";
|
||||
|
||||
try {
|
||||
if (mode === "login") {
|
||||
const { error: authError } =
|
||||
await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (authError) throw authError;
|
||||
} else {
|
||||
const { error: authError } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
if (authError) throw authError;
|
||||
}
|
||||
goto("/");
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : "An error occurred";
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuth(provider: "google" | "github") {
|
||||
const { error: authError } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
if (authError) {
|
||||
error = authError.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{mode === "login" ? "Log In" : "Sign Up"} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Card variant="elevated" padding="lg">
|
||||
<h2 class="text-xl font-semibold text-light mb-6">
|
||||
{mode === "login" ? "Welcome back" : "Create your account"}
|
||||
</h2>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder="you@example.com"
|
||||
bind:value={email}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth loading={isLoading}>
|
||||
{mode === "login" ? "Log In" : "Sign Up"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div class="my-6 flex items-center gap-3">
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
<span class="text-light/40 text-sm">or continue with</span>
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onclick={() => handleOAuth("google")}
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<p class="mt-6 text-center text-light/60 text-sm">
|
||||
{#if mode === "login"}
|
||||
Don't have an account?
|
||||
<button
|
||||
class="text-primary hover:underline"
|
||||
onclick={() => (mode = "signup")}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
{:else}
|
||||
Already have an account?
|
||||
<button
|
||||
class="text-primary hover:underline"
|
||||
onclick={() => (mode = "login")}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
{/if}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
13
src/routes/page.svelte.spec.ts
Normal file
13
src/routes/page.svelte.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { page } from 'vitest/browser';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render h1', async () => {
|
||||
render(Page);
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 });
|
||||
await expect.element(heading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
371
src/routes/style/+page.svelte
Normal file
371
src/routes/style/+page.svelte
Normal file
@@ -0,0 +1,371 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
Avatar,
|
||||
Badge,
|
||||
Card,
|
||||
Modal,
|
||||
Spinner,
|
||||
Toggle
|
||||
} from '$lib/components/ui';
|
||||
|
||||
let inputValue = $state('');
|
||||
let textareaValue = $state('');
|
||||
let selectValue = $state('');
|
||||
let toggleChecked = $state(false);
|
||||
let modalOpen = $state(false);
|
||||
|
||||
const selectOptions = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
{ value: 'option3', label: 'Option 3' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Style Guide | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-dark p-8">
|
||||
<div class="max-w-6xl mx-auto space-y-12">
|
||||
<!-- Header -->
|
||||
<header class="text-center space-y-4">
|
||||
<h1 class="text-4xl font-bold text-light">Root Style Guide</h1>
|
||||
<p class="text-light/60">All UI components and their variants</p>
|
||||
</header>
|
||||
|
||||
<!-- Colors -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Colors</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-xl bg-dark border border-light/20"></div>
|
||||
<p class="text-sm text-light/60">Dark</p>
|
||||
<code class="text-xs text-light/40">#0a0a0f</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-xl bg-surface"></div>
|
||||
<p class="text-sm text-light/60">Surface</p>
|
||||
<code class="text-xs text-light/40">#14141f</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-xl bg-light"></div>
|
||||
<p class="text-sm text-light/60">Light</p>
|
||||
<code class="text-xs text-light/40">#f0f0f5</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-xl bg-primary"></div>
|
||||
<p class="text-sm text-light/60">Primary</p>
|
||||
<code class="text-xs text-light/40">#6366f1</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-xl bg-success"></div>
|
||||
<p class="text-sm text-light/60">Success</p>
|
||||
<code class="text-xs text-light/40">#22c55e</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-xl bg-warning"></div>
|
||||
<p class="text-sm text-light/60">Warning</p>
|
||||
<code class="text-xs text-light/40">#f59e0b</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-xl bg-error"></div>
|
||||
<p class="text-sm text-light/60">Error</p>
|
||||
<code class="text-xs text-light/40">#ef4444</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-xl bg-info"></div>
|
||||
<p class="text-sm text-light/60">Info</p>
|
||||
<code class="text-xs text-light/40">#3b82f6</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Buttons -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Buttons</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">Variants</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="danger">Danger</Button>
|
||||
<Button variant="success">Success</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button size="xs">Extra Small</Button>
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="md">Medium</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">States</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button>Normal</Button>
|
||||
<Button disabled>Disabled</Button>
|
||||
<Button loading>Loading</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">Full Width</h3>
|
||||
<div class="max-w-sm">
|
||||
<Button fullWidth>Full Width Button</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Inputs -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Inputs</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<Input label="Default Input" placeholder="Enter text..." bind:value={inputValue} />
|
||||
<Input label="Required Field" placeholder="Required..." required />
|
||||
<Input label="With Hint" placeholder="Email..." hint="We'll never share your email" />
|
||||
<Input label="With Error" placeholder="Password..." error="Password is too short" />
|
||||
<Input label="Disabled" placeholder="Can't edit this" disabled />
|
||||
<Input type="password" label="Password" placeholder="••••••••" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Textarea -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Textarea</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<Textarea label="Default Textarea" placeholder="Enter description..." bind:value={textareaValue} />
|
||||
<Textarea label="With Error" placeholder="Description..." error="Description is required" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Select -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Select</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<Select label="Default Select" options={selectOptions} bind:value={selectValue} />
|
||||
<Select label="With Error" options={selectOptions} error="Please select an option" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Avatars -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Avatars</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
|
||||
<div class="flex items-end gap-4">
|
||||
<Avatar name="John Doe" size="xs" />
|
||||
<Avatar name="John Doe" size="sm" />
|
||||
<Avatar name="John Doe" size="md" />
|
||||
<Avatar name="John Doe" size="lg" />
|
||||
<Avatar name="John Doe" size="xl" />
|
||||
<Avatar name="John Doe" size="2xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">With Status</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar name="Online User" size="lg" status="online" />
|
||||
<Avatar name="Away User" size="lg" status="away" />
|
||||
<Avatar name="Busy User" size="lg" status="busy" />
|
||||
<Avatar name="Offline User" size="lg" status="offline" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">Different Names (Color Generation)</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar name="Alice" size="lg" />
|
||||
<Avatar name="Bob" size="lg" />
|
||||
<Avatar name="Charlie" size="lg" />
|
||||
<Avatar name="Diana" size="lg" />
|
||||
<Avatar name="Eve" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Badges -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Badges</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">Variants</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Badge variant="default">Default</Badge>
|
||||
<Badge variant="primary">Primary</Badge>
|
||||
<Badge variant="success">Success</Badge>
|
||||
<Badge variant="warning">Warning</Badge>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<Badge variant="info">Info</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Badge size="sm">Small</Badge>
|
||||
<Badge size="md">Medium</Badge>
|
||||
<Badge size="lg">Large</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cards -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Cards</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<Card variant="default">
|
||||
<h3 class="font-semibold text-light mb-2">Default Card</h3>
|
||||
<p class="text-light/60 text-sm">This is a default card with medium padding.</p>
|
||||
</Card>
|
||||
<Card variant="elevated">
|
||||
<h3 class="font-semibold text-light mb-2">Elevated Card</h3>
|
||||
<p class="text-light/60 text-sm">This card has a shadow for elevation.</p>
|
||||
</Card>
|
||||
<Card variant="outlined">
|
||||
<h3 class="font-semibold text-light mb-2">Outlined Card</h3>
|
||||
<p class="text-light/60 text-sm">This card has a subtle border.</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Toggle -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Toggle</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle size="sm" />
|
||||
<span class="text-light/60 text-sm">Small</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle size="md" bind:checked={toggleChecked} />
|
||||
<span class="text-light/60 text-sm">Medium</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle size="lg" checked />
|
||||
<span class="text-light/60 text-sm">Large</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">States</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle />
|
||||
<span class="text-light/60 text-sm">Off</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle checked />
|
||||
<span class="text-light/60 text-sm">On</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle disabled />
|
||||
<span class="text-light/60 text-sm">Disabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Spinners -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Spinners</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<Spinner size="sm" />
|
||||
<Spinner size="md" />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">Colors</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<Spinner color="primary" />
|
||||
<Spinner color="light" />
|
||||
<div class="text-success">
|
||||
<Spinner color="current" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Modal</h2>
|
||||
|
||||
<div>
|
||||
<Button onclick={() => (modalOpen = true)}>Open Modal</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal isOpen={modalOpen} onClose={() => (modalOpen = false)} title="Example Modal">
|
||||
<p class="text-light/70 mb-4">
|
||||
This is an example modal dialog. You can put any content here.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="secondary" onclick={() => (modalOpen = false)}>Cancel</Button>
|
||||
<Button onclick={() => (modalOpen = false)}>Confirm</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Typography -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Typography</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-4xl font-bold text-light">Heading 1 (4xl bold)</h1>
|
||||
<h2 class="text-3xl font-bold text-light">Heading 2 (3xl bold)</h2>
|
||||
<h3 class="text-2xl font-semibold text-light">Heading 3 (2xl semibold)</h3>
|
||||
<h4 class="text-xl font-semibold text-light">Heading 4 (xl semibold)</h4>
|
||||
<h5 class="text-lg font-medium text-light">Heading 5 (lg medium)</h5>
|
||||
<h6 class="text-base font-medium text-light">Heading 6 (base medium)</h6>
|
||||
<p class="text-base text-light/80">
|
||||
Body text (base, 80% opacity) - Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
</p>
|
||||
<p class="text-sm text-light/60">
|
||||
Small text (sm, 60% opacity) - Used for secondary information and hints.
|
||||
</p>
|
||||
<p class="text-xs text-light/40">
|
||||
Extra small text (xs, 40% opacity) - Used for metadata and timestamps.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="text-center py-8 border-t border-light/10">
|
||||
<p class="text-light/40 text-sm">Root Organization Platform - Style Guide</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user