First commit

master
AlacrisDevs 2 days ago
commit cfec43f7ef
  1. 16
      .dockerignore
  2. 6
      .env.example
  3. 91
      .gitignore
  4. 1
      .npmrc
  5. 5
      .vscode/settings.json
  6. 44
      Dockerfile
  7. 18
      Dockerfile.dev
  8. 124
      README.md
  9. 37
      docker-compose.yml
  10. 3885
      package-lock.json
  11. 41
      package.json
  12. 20
      src/app.d.ts
  13. 11
      src/app.html
  14. 7
      src/demo.spec.ts
  15. 45
      src/hooks.server.ts
  16. 118
      src/lib/api/calendar.ts
  17. 132
      src/lib/api/documents.ts
  18. 264
      src/lib/api/google-calendar.ts
  19. 215
      src/lib/api/kanban.ts
  20. 164
      src/lib/api/organizations.ts
  21. 1
      src/lib/assets/favicon.svg
  22. 124
      src/lib/components/calendar/Calendar.svelte
  23. 1
      src/lib/components/calendar/index.ts
  24. 201
      src/lib/components/documents/Editor.svelte
  25. 192
      src/lib/components/documents/FileTree.svelte
  26. 2
      src/lib/components/documents/index.ts
  27. 231
      src/lib/components/kanban/CardDetailModal.svelte
  28. 162
      src/lib/components/kanban/KanbanBoard.svelte
  29. 2
      src/lib/components/kanban/index.ts
  30. 92
      src/lib/components/ui/Avatar.svelte
  31. 30
      src/lib/components/ui/Badge.svelte
  32. 71
      src/lib/components/ui/Button.svelte
  33. 28
      src/lib/components/ui/Card.svelte
  34. 66
      src/lib/components/ui/Input.svelte
  35. 70
      src/lib/components/ui/Modal.svelte
  36. 68
      src/lib/components/ui/Select.svelte
  37. 34
      src/lib/components/ui/Spinner.svelte
  38. 66
      src/lib/components/ui/Textarea.svelte
  39. 40
      src/lib/components/ui/Toggle.svelte
  40. 10
      src/lib/components/ui/index.ts
  41. 1
      src/lib/index.ts
  42. 27
      src/lib/stores/auth.svelte.ts
  43. 52
      src/lib/stores/documents.svelte.ts
  44. 2
      src/lib/stores/index.ts
  45. 59
      src/lib/stores/organizations.svelte.ts
  46. 7
      src/lib/supabase/client.ts
  47. 3
      src/lib/supabase/index.ts
  48. 19
      src/lib/supabase/server.ts
  49. 288
      src/lib/supabase/types.ts
  50. 6
      src/routes/+layout.server.ts
  51. 14
      src/routes/+layout.svelte
  52. 27
      src/routes/+page.server.ts
  53. 190
      src/routes/+page.svelte
  54. 36
      src/routes/[orgSlug]/+layout.server.ts
  55. 151
      src/routes/[orgSlug]/+layout.svelte
  56. 68
      src/routes/[orgSlug]/+page.svelte
  57. 23
      src/routes/[orgSlug]/calendar/+page.server.ts
  58. 239
      src/routes/[orgSlug]/calendar/+page.svelte
  59. 16
      src/routes/[orgSlug]/documents/+page.server.ts
  60. 212
      src/routes/[orgSlug]/documents/+page.svelte
  61. 16
      src/routes/[orgSlug]/kanban/+page.server.ts
  62. 256
      src/routes/[orgSlug]/kanban/+page.svelte
  63. 43
      src/routes/api/google-calendar/callback/+server.ts
  64. 22
      src/routes/api/google-calendar/connect/+server.ts
  65. 16
      src/routes/auth/callback/+server.ts
  66. 8
      src/routes/auth/logout/+server.ts
  67. 9
      src/routes/health/+server.ts
  68. 49
      src/routes/layout.css
  69. 167
      src/routes/login/+page.svelte
  70. 13
      src/routes/page.svelte.spec.ts
  71. 371
      src/routes/style/+page.svelte
  72. 3
      static/robots.txt
  73. 224
      supabase/migrations/001_initial_schema.sql
  74. 44
      supabase/migrations/002_card_checklists.sql
  75. 31
      supabase/migrations/003_google_calendar.sql
  76. 6
      svelte.config.js
  77. 20
      tsconfig.json
  78. 36
      vite.config.ts

@ -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

91
.gitignore vendored

@ -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 @@
engine-strict=true

@ -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

3885
package-lock.json generated

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"
}
}

20
src/app.d.ts vendored

@ -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);
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -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…
Cancel
Save