commit
cfec43f7ef
78 changed files with 9509 additions and 0 deletions
@ -0,0 +1,16 @@ |
||||
Dockerfile |
||||
Dockerfile.dev |
||||
docker-compose.yml |
||||
.dockerignore |
||||
.git |
||||
.gitignore |
||||
.gitattributes |
||||
README.md |
||||
.svelte-kit |
||||
.vscode |
||||
node_modules |
||||
build |
||||
**/.env |
||||
**/.env.* |
||||
*.log |
||||
.DS_Store |
||||
@ -0,0 +1,6 @@ |
||||
PUBLIC_SUPABASE_URL=https://your-project.supabase.co |
||||
PUBLIC_SUPABASE_ANON_KEY=your-anon-key |
||||
|
||||
# Google Calendar Integration (optional) |
||||
VITE_GOOGLE_CLIENT_ID=your-google-client-id |
||||
VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret |
||||
@ -0,0 +1,91 @@ |
||||
# Dependencies |
||||
node_modules |
||||
.pnpm-store |
||||
.yarn |
||||
|
||||
# Build output |
||||
.output |
||||
.vercel |
||||
.netlify |
||||
.wrangler |
||||
/.svelte-kit |
||||
/build |
||||
/dist |
||||
|
||||
# Package manager locks (keep only one) |
||||
# package-lock.json |
||||
# yarn.lock |
||||
# pnpm-lock.yaml |
||||
|
||||
# OS files |
||||
.DS_Store |
||||
.DS_Store? |
||||
._* |
||||
.Spotlight-V100 |
||||
.Trashes |
||||
ehthumbs.db |
||||
Thumbs.db |
||||
Desktop.ini |
||||
|
||||
# IDE / Editors |
||||
.idea |
||||
.vscode/* |
||||
!.vscode/extensions.json |
||||
!.vscode/settings.json |
||||
*.swp |
||||
*.swo |
||||
*.sublime-workspace |
||||
*.sublime-project |
||||
.project |
||||
.classpath |
||||
.settings/ |
||||
*.code-workspace |
||||
|
||||
# Environment variables |
||||
.env |
||||
.env.* |
||||
!.env.example |
||||
!.env.test |
||||
|
||||
# Vite |
||||
vite.config.js.timestamp-* |
||||
vite.config.ts.timestamp-* |
||||
|
||||
# Logs |
||||
logs |
||||
*.log |
||||
npm-debug.log* |
||||
yarn-debug.log* |
||||
yarn-error.log* |
||||
pnpm-debug.log* |
||||
lerna-debug.log* |
||||
|
||||
# Testing |
||||
coverage |
||||
.nyc_output |
||||
*.lcov |
||||
|
||||
# Typescript |
||||
*.tsbuildinfo |
||||
|
||||
# Docker |
||||
.docker |
||||
|
||||
# Supabase |
||||
supabase/.branches |
||||
supabase/.temp |
||||
|
||||
# Misc |
||||
*.pem |
||||
*.local |
||||
.cache |
||||
.turbo |
||||
*.pid |
||||
*.seed |
||||
*.pid.lock |
||||
|
||||
# Debug |
||||
.debug |
||||
|
||||
# Sentry |
||||
.sentryclirc |
||||
@ -0,0 +1,5 @@ |
||||
{ |
||||
"files.associations": { |
||||
"*.css": "tailwindcss" |
||||
} |
||||
} |
||||
@ -0,0 +1,44 @@ |
||||
# Build stage |
||||
FROM node:22-alpine AS builder |
||||
|
||||
WORKDIR /app |
||||
|
||||
# Copy package files |
||||
COPY package*.json ./ |
||||
|
||||
# Install all dependencies (including dev) |
||||
RUN npm ci |
||||
|
||||
# Copy source files |
||||
COPY . . |
||||
|
||||
# Build the application |
||||
RUN npm run build |
||||
|
||||
# Prune dev dependencies |
||||
RUN npm prune --production |
||||
|
||||
# Production stage |
||||
FROM node:22-alpine |
||||
|
||||
WORKDIR /app |
||||
|
||||
# Copy built application |
||||
COPY --from=builder /app/build build/ |
||||
COPY --from=builder /app/node_modules node_modules/ |
||||
COPY package.json . |
||||
|
||||
# Expose port |
||||
EXPOSE 3000 |
||||
|
||||
# Set environment |
||||
ENV NODE_ENV=production |
||||
ENV PORT=3000 |
||||
ENV HOST=0.0.0.0 |
||||
|
||||
# Health check |
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ |
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 |
||||
|
||||
# Run the application |
||||
CMD ["node", "build"] |
||||
@ -0,0 +1,18 @@ |
||||
FROM node:22-alpine |
||||
|
||||
WORKDIR /app |
||||
|
||||
# Copy package files |
||||
COPY package*.json ./ |
||||
|
||||
# Install dependencies |
||||
RUN npm ci |
||||
|
||||
# Copy source (will be overwritten by volume mount) |
||||
COPY . . |
||||
|
||||
# Expose Vite dev server port |
||||
EXPOSE 5173 |
||||
|
||||
# Run dev server |
||||
CMD ["npm", "run", "dev", "--", "--host"] |
||||
@ -0,0 +1,124 @@ |
||||
# Root Organization Platform |
||||
|
||||
Team collaboration platform with Documents, Kanban, and Calendar - no messaging. |
||||
|
||||
## Tech Stack |
||||
|
||||
- **Frontend**: SvelteKit 2 + Svelte 5 + TailwindCSS 4 |
||||
- **Backend**: Supabase (PostgreSQL + Auth + Realtime) |
||||
- **Deployment**: Docker + adapter-node |
||||
|
||||
## Features |
||||
|
||||
- 📁 **Documents** - Folders and rich text documents with collaborative editing |
||||
- 📋 **Kanban** - Visual task boards with drag-drop and real-time sync |
||||
- 📅 **Calendar** - Team scheduling with events and attendees |
||||
- 👥 **Teams** - Organizations, members, roles (Owner/Admin/Editor/Viewer) |
||||
|
||||
## Quick Start |
||||
|
||||
### 1. Install dependencies |
||||
|
||||
```bash |
||||
npm install |
||||
``` |
||||
|
||||
### 2. Set up Supabase |
||||
|
||||
1. Create a project at [supabase.com](https://supabase.com) |
||||
2. Copy `.env.example` to `.env` and fill in your credentials: |
||||
|
||||
```bash |
||||
cp .env.example .env |
||||
``` |
||||
|
||||
```env |
||||
PUBLIC_SUPABASE_URL=https://your-project.supabase.co |
||||
PUBLIC_SUPABASE_ANON_KEY=your-anon-key |
||||
``` |
||||
|
||||
3. Run the database migration in Supabase SQL editor: |
||||
- Open `supabase/migrations/001_initial_schema.sql` |
||||
- Execute in your Supabase dashboard |
||||
|
||||
### 3. Run development server |
||||
|
||||
```bash |
||||
npm run dev |
||||
``` |
||||
|
||||
Visit http://localhost:5173 |
||||
|
||||
## Docker |
||||
|
||||
### Production |
||||
|
||||
```bash |
||||
docker-compose up app |
||||
``` |
||||
|
||||
### Development (with hot reload) |
||||
|
||||
```bash |
||||
docker-compose up dev |
||||
``` |
||||
|
||||
## Routes |
||||
|
||||
| Route | Description | |
||||
|-------|-------------| |
||||
| `/` | Organization list (home) | |
||||
| `/login` | Auth (login/signup) | |
||||
| `/style` | UI component showcase | |
||||
| `/health` | Health check endpoint | |
||||
| `/[orgSlug]` | Organization overview | |
||||
| `/[orgSlug]/documents` | Documents with rich text editor | |
||||
| `/[orgSlug]/kanban` | Kanban boards | |
||||
| `/[orgSlug]/calendar` | Calendar events | |
||||
|
||||
## Project Structure |
||||
|
||||
``` |
||||
src/ |
||||
├── lib/ |
||||
│ ├── api/ # Supabase API functions |
||||
│ │ ├── organizations.ts |
||||
│ │ ├── documents.ts |
||||
│ │ ├── kanban.ts |
||||
│ │ └── calendar.ts |
||||
│ ├── components/ |
||||
│ │ ├── ui/ # Reusable UI components |
||||
│ │ ├── documents/ # FileTree, Editor |
||||
│ │ ├── kanban/ # KanbanBoard |
||||
│ │ └── calendar/ # Calendar |
||||
│ ├── stores/ # Svelte 5 state stores |
||||
│ └── supabase/ # Supabase client & types |
||||
├── routes/ |
||||
│ ├── login/ # Auth pages |
||||
│ ├── style/ # Component showcase |
||||
│ ├── health/ # Health endpoint |
||||
│ ├── auth/callback/ # OAuth callback |
||||
│ ├── auth/logout/ # Sign out |
||||
│ └── [orgSlug]/ # Organization routes |
||||
│ ├── documents/ |
||||
│ ├── kanban/ |
||||
│ └── calendar/ |
||||
└── hooks.server.ts # Auth middleware |
||||
``` |
||||
|
||||
## UI Components |
||||
|
||||
All components available in `$lib/components/ui`: |
||||
|
||||
- `Button` - Primary, Secondary, Ghost, Danger, Success variants |
||||
- `Input` - Text input with label, error, hint |
||||
- `Textarea` - Multi-line input |
||||
- `Select` - Dropdown select |
||||
- `Avatar` - User avatars with status |
||||
- `Badge` - Status badges |
||||
- `Card` - Content containers |
||||
- `Modal` - Dialog windows |
||||
- `Spinner` - Loading indicators |
||||
- `Toggle` - On/off switches |
||||
|
||||
Visit `/style` to see all components. |
||||
@ -0,0 +1,37 @@ |
||||
services: |
||||
app: |
||||
build: |
||||
context: . |
||||
dockerfile: Dockerfile |
||||
ports: |
||||
- "3000:3000" |
||||
environment: |
||||
- NODE_ENV=production |
||||
- PORT=3000 |
||||
- HOST=0.0.0.0 |
||||
# Supabase configuration (add your values) |
||||
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL} |
||||
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY} |
||||
restart: unless-stopped |
||||
healthcheck: |
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] |
||||
interval: 30s |
||||
timeout: 3s |
||||
retries: 3 |
||||
start_period: 5s |
||||
|
||||
# Development mode with hot reload |
||||
dev: |
||||
build: |
||||
context: . |
||||
dockerfile: Dockerfile.dev |
||||
ports: |
||||
- "5173:5173" |
||||
volumes: |
||||
- .:/app |
||||
- /app/node_modules |
||||
environment: |
||||
- NODE_ENV=development |
||||
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL} |
||||
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY} |
||||
command: npm run dev -- --host |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,41 @@ |
||||
{ |
||||
"name": "root-org", |
||||
"private": true, |
||||
"version": "0.0.1", |
||||
"type": "module", |
||||
"scripts": { |
||||
"dev": "vite dev", |
||||
"build": "vite build", |
||||
"preview": "vite preview", |
||||
"prepare": "svelte-kit sync || echo ''", |
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", |
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", |
||||
"test:unit": "vitest", |
||||
"test": "npm run test:unit -- --run" |
||||
}, |
||||
"devDependencies": { |
||||
"@sveltejs/adapter-node": "^5.5.2", |
||||
"@sveltejs/kit": "^2.50.1", |
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4", |
||||
"@tailwindcss/forms": "^0.5.11", |
||||
"@tailwindcss/typography": "^0.5.19", |
||||
"@tailwindcss/vite": "^4.1.18", |
||||
"@vitest/browser-playwright": "^4.0.18", |
||||
"playwright": "^1.58.0", |
||||
"svelte": "^5.48.2", |
||||
"svelte-check": "^4.3.5", |
||||
"tailwindcss": "^4.1.18", |
||||
"typescript": "^5.9.3", |
||||
"vite": "^7.3.1", |
||||
"vitest": "^4.0.18", |
||||
"vitest-browser-svelte": "^2.0.2" |
||||
}, |
||||
"dependencies": { |
||||
"@supabase/ssr": "^0.8.0", |
||||
"@supabase/supabase-js": "^2.94.0", |
||||
"@tiptap/core": "^3.19.0", |
||||
"@tiptap/extension-placeholder": "^3.19.0", |
||||
"@tiptap/pm": "^3.19.0", |
||||
"@tiptap/starter-kit": "^3.19.0" |
||||
} |
||||
} |
||||
@ -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 { }; |
||||
@ -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> |
||||
@ -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); |
||||
}); |
||||
}); |
||||
@ -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'; |
||||
} |
||||
}); |
||||
}; |
||||
@ -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' }); |
||||
} |
||||
@ -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(); |
||||
} |
||||
@ -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 |
||||
})); |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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); |
||||
} |
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -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> |
||||
@ -0,0 +1 @@ |
||||
export { default as Calendar } from './Calendar.svelte'; |
||||
@ -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> |
||||
@ -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> |
||||
@ -0,0 +1,2 @@ |
||||
export { default as FileTree } from './FileTree.svelte'; |
||||
export { default as Editor } from './Editor.svelte'; |
||||
@ -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> |
||||
@ -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> |
||||
@ -0,0 +1,2 @@ |
||||
export { default as KanbanBoard } from './KanbanBoard.svelte'; |
||||
export { default as CardDetailModal } from './CardDetailModal.svelte'; |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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} |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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'; |
||||
@ -0,0 +1 @@ |
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@ -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(); |
||||
@ -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(); |
||||
@ -0,0 +1,2 @@ |
||||
export { auth } from './auth.svelte'; |
||||
export { orgs, type OrgWithRole } from './organizations.svelte'; |
||||
@ -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(); |
||||
@ -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); |
||||
} |
||||
@ -0,0 +1,3 @@ |
||||
export { createClient } from './client'; |
||||
export { createClient as createServerClient } from './server'; |
||||
export type * from './types'; |
||||
@ -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: '/' }); |
||||
}); |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
@ -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']; |
||||
@ -0,0 +1,6 @@ |
||||
import type { LayoutServerLoad } from './$types'; |
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => { |
||||
const { session, user } = await locals.safeGetSession(); |
||||
return { session, user }; |
||||
}; |
||||
@ -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()} |
||||
@ -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 |
||||
}; |
||||
}; |
||||
@ -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> |
||||
@ -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 |
||||
}; |
||||
}; |
||||
@ -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> |
||||
@ -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> |
||||
@ -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 ?? [] |
||||
}; |
||||
}; |
||||
@ -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> |
||||
@ -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 ?? [] |
||||
}; |
||||
}; |
||||
@ -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> |
||||
@ -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 ?? [] |
||||
}; |
||||
}; |
||||
@ -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} |
||||
/> |
||||
@ -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`); |
||||
} |
||||
}; |
||||
@ -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); |
||||
}; |
||||
@ -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'); |
||||
}; |
||||
@ -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'); |
||||
}; |
||||
@ -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() |
||||
}); |
||||
}; |
||||
@ -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; |
||||
} |
||||
@ -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> |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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> |
||||
@ -0,0 +1,3 @@ |
||||
# allow crawling everything by default |
||||
User-agent: * |
||||
Disallow: |
||||
@ -0,0 +1,224 @@ |
||||
-- Root Organization Platform - Initial Schema |
||||
-- Run this in your Supabase SQL editor |
||||
|
||||
-- Enable UUID extension |
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; |
||||
|
||||
-- Profiles (synced from auth.users) |
||||
CREATE TABLE profiles ( |
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, |
||||
email TEXT NOT NULL, |
||||
full_name TEXT, |
||||
avatar_url TEXT, |
||||
created_at TIMESTAMPTZ DEFAULT now(), |
||||
updated_at TIMESTAMPTZ DEFAULT now() |
||||
); |
||||
|
||||
-- Organizations (workspaces) |
||||
CREATE TABLE organizations ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
name TEXT NOT NULL, |
||||
slug TEXT UNIQUE NOT NULL, |
||||
avatar_url TEXT, |
||||
created_at TIMESTAMPTZ DEFAULT now(), |
||||
updated_at TIMESTAMPTZ DEFAULT now() |
||||
); |
||||
|
||||
-- Organization Members |
||||
CREATE TABLE org_members ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE, |
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, |
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'editor', 'viewer')), |
||||
invited_at TIMESTAMPTZ DEFAULT now(), |
||||
joined_at TIMESTAMPTZ, |
||||
UNIQUE(org_id, user_id) |
||||
); |
||||
|
||||
-- Documents/Folders |
||||
CREATE TABLE documents ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE, |
||||
parent_id UUID REFERENCES documents(id) ON DELETE CASCADE, |
||||
type TEXT NOT NULL CHECK (type IN ('folder', 'document')), |
||||
name TEXT NOT NULL, |
||||
content JSONB, |
||||
created_by UUID REFERENCES auth.users(id), |
||||
created_at TIMESTAMPTZ DEFAULT now(), |
||||
updated_at TIMESTAMPTZ DEFAULT now() |
||||
); |
||||
|
||||
-- Kanban Boards |
||||
CREATE TABLE kanban_boards ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE, |
||||
name TEXT NOT NULL, |
||||
created_at TIMESTAMPTZ DEFAULT now() |
||||
); |
||||
|
||||
-- Kanban Columns |
||||
CREATE TABLE kanban_columns ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
board_id UUID REFERENCES kanban_boards(id) ON DELETE CASCADE, |
||||
name TEXT NOT NULL, |
||||
position INTEGER NOT NULL, |
||||
color TEXT |
||||
); |
||||
|
||||
-- Kanban Cards |
||||
CREATE TABLE kanban_cards ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
column_id UUID REFERENCES kanban_columns(id) ON DELETE CASCADE, |
||||
title TEXT NOT NULL, |
||||
description TEXT, |
||||
position INTEGER NOT NULL, |
||||
due_date TIMESTAMPTZ, |
||||
color TEXT, |
||||
created_by UUID REFERENCES auth.users(id), |
||||
created_at TIMESTAMPTZ DEFAULT now() |
||||
); |
||||
|
||||
-- Card Assignees |
||||
CREATE TABLE card_assignees ( |
||||
card_id UUID REFERENCES kanban_cards(id) ON DELETE CASCADE, |
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, |
||||
PRIMARY KEY (card_id, user_id) |
||||
); |
||||
|
||||
-- Calendar Events |
||||
CREATE TABLE calendar_events ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE, |
||||
title TEXT NOT NULL, |
||||
description TEXT, |
||||
start_time TIMESTAMPTZ NOT NULL, |
||||
end_time TIMESTAMPTZ NOT NULL, |
||||
all_day BOOLEAN DEFAULT false, |
||||
color TEXT, |
||||
recurrence TEXT, |
||||
created_by UUID REFERENCES auth.users(id), |
||||
created_at TIMESTAMPTZ DEFAULT now() |
||||
); |
||||
|
||||
-- Event Attendees |
||||
CREATE TABLE event_attendees ( |
||||
event_id UUID REFERENCES calendar_events(id) ON DELETE CASCADE, |
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, |
||||
status TEXT CHECK (status IN ('pending', 'accepted', 'declined')) DEFAULT 'pending', |
||||
PRIMARY KEY (event_id, user_id) |
||||
); |
||||
|
||||
-- Indexes for performance |
||||
CREATE INDEX idx_org_members_org ON org_members(org_id); |
||||
CREATE INDEX idx_org_members_user ON org_members(user_id); |
||||
CREATE INDEX idx_documents_org ON documents(org_id); |
||||
CREATE INDEX idx_documents_parent ON documents(parent_id); |
||||
CREATE INDEX idx_kanban_boards_org ON kanban_boards(org_id); |
||||
CREATE INDEX idx_kanban_columns_board ON kanban_columns(board_id); |
||||
CREATE INDEX idx_kanban_cards_column ON kanban_cards(column_id); |
||||
CREATE INDEX idx_calendar_events_org ON calendar_events(org_id); |
||||
CREATE INDEX idx_calendar_events_time ON calendar_events(start_time, end_time); |
||||
|
||||
-- Row Level Security (RLS) |
||||
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; |
||||
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; |
||||
ALTER TABLE org_members ENABLE ROW LEVEL SECURITY; |
||||
ALTER TABLE documents ENABLE ROW LEVEL SECURITY; |
||||
ALTER TABLE kanban_boards ENABLE ROW LEVEL SECURITY; |
||||
ALTER TABLE kanban_columns ENABLE ROW LEVEL SECURITY; |
||||
ALTER TABLE kanban_cards ENABLE ROW LEVEL SECURITY; |
||||
ALTER TABLE card_assignees ENABLE ROW LEVEL SECURITY; |
||||
ALTER TABLE calendar_events ENABLE ROW LEVEL SECURITY; |
||||
ALTER TABLE event_attendees ENABLE ROW LEVEL SECURITY; |
||||
|
||||
-- Profiles: Users can read all profiles, update their own |
||||
CREATE POLICY "Profiles are viewable by everyone" ON profiles FOR SELECT USING (true); |
||||
CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = id); |
||||
|
||||
-- Organizations: Members can view, owners/admins can modify |
||||
CREATE POLICY "Org members can view organizations" ON organizations FOR SELECT |
||||
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = organizations.id AND user_id = auth.uid())); |
||||
|
||||
CREATE POLICY "Org owners/admins can update organizations" ON organizations FOR UPDATE |
||||
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = organizations.id AND user_id = auth.uid() AND role IN ('owner', 'admin'))); |
||||
|
||||
CREATE POLICY "Anyone can create organizations" ON organizations FOR INSERT WITH CHECK (true); |
||||
|
||||
-- Org Members: Members can view, owners/admins can modify |
||||
CREATE POLICY "Org members can view members" ON org_members FOR SELECT |
||||
USING (EXISTS (SELECT 1 FROM org_members om WHERE om.org_id = org_members.org_id AND om.user_id = auth.uid())); |
||||
|
||||
CREATE POLICY "Org owners/admins can manage members" ON org_members FOR ALL |
||||
USING (EXISTS (SELECT 1 FROM org_members om WHERE om.org_id = org_members.org_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin'))); |
||||
|
||||
-- Documents: Based on org membership and role |
||||
CREATE POLICY "Org members can view documents" ON documents FOR SELECT |
||||
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = documents.org_id AND user_id = auth.uid())); |
||||
|
||||
CREATE POLICY "Editors can manage documents" ON documents FOR ALL |
||||
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = documents.org_id AND user_id = auth.uid() AND role IN ('owner', 'admin', 'editor'))); |
||||
|
||||
-- Kanban: Based on org membership |
||||
CREATE POLICY "Org members can view boards" ON kanban_boards FOR SELECT |
||||
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = kanban_boards.org_id AND user_id = auth.uid())); |
||||
|
||||
CREATE POLICY "Editors can manage boards" ON kanban_boards FOR ALL |
||||
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = kanban_boards.org_id AND user_id = auth.uid() AND role IN ('owner', 'admin', 'editor'))); |
||||
|
||||
CREATE POLICY "Org members can view columns" ON kanban_columns FOR SELECT |
||||
USING (EXISTS (SELECT 1 FROM kanban_boards b JOIN org_members om ON b.org_id = om.org_id WHERE b.id = kanban_columns.board_id AND om.user_id = auth.uid())); |
||||
|
||||
CREATE POLICY "Editors can manage columns" ON kanban_columns FOR ALL |
||||
USING (EXISTS (SELECT 1 FROM kanban_boards b JOIN org_members om ON b.org_id = om.org_id WHERE b.id = kanban_columns.board_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor'))); |
||||
|
||||
CREATE POLICY "Org members can view cards" ON kanban_cards FOR SELECT |
||||
USING (EXISTS (SELECT 1 FROM kanban_columns c JOIN kanban_boards b ON c.board_id = b.id JOIN org_members om ON b.org_id = om.org_id WHERE c.id = kanban_cards.column_id AND om.user_id = auth.uid())); |
||||
|
||||
CREATE POLICY "Editors can manage cards" ON kanban_cards FOR ALL |
||||
USING (EXISTS (SELECT 1 FROM kanban_columns c JOIN kanban_boards b ON c.board_id = b.id JOIN org_members om ON b.org_id = om.org_id WHERE c.id = kanban_cards.column_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor'))); |
||||
|
||||
-- Calendar: Based on org membership |
||||
CREATE POLICY "Org members can view events" ON calendar_events FOR SELECT |
||||
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = calendar_events.org_id AND user_id = auth.uid())); |
||||
|
||||
CREATE POLICY "Editors can manage events" ON calendar_events FOR ALL |
||||
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = calendar_events.org_id AND user_id = auth.uid() AND role IN ('owner', 'admin', 'editor'))); |
||||
|
||||
-- Trigger to create profile on user signup |
||||
CREATE OR REPLACE FUNCTION public.handle_new_user() |
||||
RETURNS TRIGGER AS $$ |
||||
BEGIN |
||||
INSERT INTO public.profiles (id, email, full_name, avatar_url) |
||||
VALUES ( |
||||
NEW.id, |
||||
NEW.email, |
||||
NEW.raw_user_meta_data->>'full_name', |
||||
NEW.raw_user_meta_data->>'avatar_url' |
||||
); |
||||
RETURN NEW; |
||||
END; |
||||
$$ LANGUAGE plpgsql SECURITY DEFINER; |
||||
|
||||
CREATE OR REPLACE TRIGGER on_auth_user_created |
||||
AFTER INSERT ON auth.users |
||||
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); |
||||
|
||||
-- Function to add creator as owner when org is created |
||||
CREATE OR REPLACE FUNCTION public.handle_new_org() |
||||
RETURNS TRIGGER AS $$ |
||||
BEGIN |
||||
INSERT INTO public.org_members (org_id, user_id, role, joined_at) |
||||
VALUES (NEW.id, auth.uid(), 'owner', now()); |
||||
RETURN NEW; |
||||
END; |
||||
$$ LANGUAGE plpgsql SECURITY DEFINER; |
||||
|
||||
CREATE OR REPLACE TRIGGER on_org_created |
||||
AFTER INSERT ON organizations |
||||
FOR EACH ROW EXECUTE FUNCTION public.handle_new_org(); |
||||
|
||||
-- Enable realtime for tables that need live updates |
||||
ALTER PUBLICATION supabase_realtime ADD TABLE documents; |
||||
ALTER PUBLICATION supabase_realtime ADD TABLE kanban_columns; |
||||
ALTER PUBLICATION supabase_realtime ADD TABLE kanban_cards; |
||||
ALTER PUBLICATION supabase_realtime ADD TABLE calendar_events; |
||||
@ -0,0 +1,44 @@ |
||||
-- Add description to kanban_cards and create checklist tables |
||||
|
||||
-- Add description column to kanban_cards if not exists |
||||
ALTER TABLE kanban_cards ADD COLUMN IF NOT EXISTS description TEXT; |
||||
|
||||
-- Checklist items for kanban cards |
||||
CREATE TABLE IF NOT EXISTS checklist_items ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
card_id UUID REFERENCES kanban_cards(id) ON DELETE CASCADE, |
||||
title TEXT NOT NULL, |
||||
completed BOOLEAN DEFAULT false, |
||||
position INTEGER NOT NULL, |
||||
created_at TIMESTAMPTZ DEFAULT now() |
||||
); |
||||
|
||||
-- Index for performance |
||||
CREATE INDEX IF NOT EXISTS idx_checklist_items_card ON checklist_items(card_id); |
||||
|
||||
-- RLS |
||||
ALTER TABLE checklist_items ENABLE ROW LEVEL SECURITY; |
||||
|
||||
-- Checklist items inherit card permissions (via board -> org membership) |
||||
CREATE POLICY "Org members can view checklist items" ON checklist_items FOR SELECT |
||||
USING (EXISTS ( |
||||
SELECT 1 FROM kanban_cards c |
||||
JOIN kanban_columns col ON c.column_id = col.id |
||||
JOIN kanban_boards b ON col.board_id = b.id |
||||
JOIN org_members om ON b.org_id = om.org_id |
||||
WHERE c.id = checklist_items.card_id AND om.user_id = auth.uid() |
||||
)); |
||||
|
||||
CREATE POLICY "Editors can manage checklist items" ON checklist_items FOR ALL |
||||
USING (EXISTS ( |
||||
SELECT 1 FROM kanban_cards c |
||||
JOIN kanban_columns col ON c.column_id = col.id |
||||
JOIN kanban_boards b ON col.board_id = b.id |
||||
JOIN org_members om ON b.org_id = om.org_id |
||||
WHERE c.id = checklist_items.card_id |
||||
AND om.user_id = auth.uid() |
||||
AND om.role IN ('owner', 'admin', 'editor') |
||||
)); |
||||
|
||||
-- Enable realtime for checklist items |
||||
ALTER PUBLICATION supabase_realtime ADD TABLE checklist_items; |
||||
@ -0,0 +1,31 @@ |
||||
-- Google Calendar integration |
||||
|
||||
-- Store Google Calendar connection per user |
||||
CREATE TABLE IF NOT EXISTS google_calendar_connections ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE, |
||||
access_token TEXT NOT NULL, |
||||
refresh_token TEXT NOT NULL, |
||||
token_expires_at TIMESTAMPTZ NOT NULL, |
||||
calendar_id TEXT, -- specific calendar to sync with |
||||
created_at TIMESTAMPTZ DEFAULT now(), |
||||
updated_at TIMESTAMPTZ DEFAULT now() |
||||
); |
||||
|
||||
-- Link local events to Google Calendar events |
||||
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS google_event_id TEXT; |
||||
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS synced_at TIMESTAMPTZ; |
||||
|
||||
-- Index |
||||
CREATE INDEX IF NOT EXISTS idx_google_calendar_user ON google_calendar_connections(user_id); |
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_google ON calendar_events(google_event_id) WHERE google_event_id IS NOT NULL; |
||||
|
||||
-- RLS |
||||
ALTER TABLE google_calendar_connections ENABLE ROW LEVEL SECURITY; |
||||
|
||||
-- Users can only see/manage their own connections |
||||
CREATE POLICY "Users can view own Google connection" ON google_calendar_connections |
||||
FOR SELECT USING (auth.uid() = user_id); |
||||
|
||||
CREATE POLICY "Users can manage own Google connection" ON google_calendar_connections |
||||
FOR ALL USING (auth.uid() = user_id); |
||||
@ -0,0 +1,6 @@ |
||||
import adapter from '@sveltejs/adapter-node'; |
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */ |
||||
const config = { kit: { adapter: adapter() } }; |
||||
|
||||
export default config; |
||||
@ -0,0 +1,20 @@ |
||||
{ |
||||
"extends": "./.svelte-kit/tsconfig.json", |
||||
"compilerOptions": { |
||||
"rewriteRelativeImportExtensions": true, |
||||
"allowJs": true, |
||||
"checkJs": true, |
||||
"esModuleInterop": true, |
||||
"forceConsistentCasingInFileNames": true, |
||||
"resolveJsonModule": true, |
||||
"skipLibCheck": true, |
||||
"sourceMap": true, |
||||
"strict": true, |
||||
"moduleResolution": "bundler" |
||||
} |
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias |
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files |
||||
// |
||||
// To make changes to top-level options such as include and exclude, we recommend extending |
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript |
||||
} |
||||
@ -0,0 +1,36 @@ |
||||
import { defineConfig } from 'vitest/config'; |
||||
import { playwright } from '@vitest/browser-playwright'; |
||||
import tailwindcss from '@tailwindcss/vite'; |
||||
import { sveltekit } from '@sveltejs/kit/vite'; |
||||
|
||||
export default defineConfig({ |
||||
plugins: [tailwindcss(), sveltekit()], |
||||
test: { |
||||
expect: { requireAssertions: true }, |
||||
projects: [ |
||||
{ |
||||
extends: './vite.config.ts', |
||||
test: { |
||||
name: 'client', |
||||
browser: { |
||||
enabled: true, |
||||
provider: playwright(), |
||||
instances: [{ browser: 'chromium', headless: true }] |
||||
}, |
||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'], |
||||
exclude: ['src/lib/server/**'] |
||||
} |
||||
}, |
||||
|
||||
{ |
||||
extends: './vite.config.ts', |
||||
test: { |
||||
name: 'server', |
||||
environment: 'node', |
||||
include: ['src/**/*.{test,spec}.{js,ts}'], |
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] |
||||
} |
||||
} |
||||
] |
||||
} |
||||
}); |
||||
Loading…
Reference in new issue