commit
cfec43f7ef
78 changed files with 9509 additions and 0 deletions
@ -0,0 +1,16 @@ |
|||||||
|
Dockerfile |
||||||
|
Dockerfile.dev |
||||||
|
docker-compose.yml |
||||||
|
.dockerignore |
||||||
|
.git |
||||||
|
.gitignore |
||||||
|
.gitattributes |
||||||
|
README.md |
||||||
|
.svelte-kit |
||||||
|
.vscode |
||||||
|
node_modules |
||||||
|
build |
||||||
|
**/.env |
||||||
|
**/.env.* |
||||||
|
*.log |
||||||
|
.DS_Store |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
PUBLIC_SUPABASE_URL=https://your-project.supabase.co |
||||||
|
PUBLIC_SUPABASE_ANON_KEY=your-anon-key |
||||||
|
|
||||||
|
# Google Calendar Integration (optional) |
||||||
|
VITE_GOOGLE_CLIENT_ID=your-google-client-id |
||||||
|
VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret |
||||||
@ -0,0 +1,91 @@ |
|||||||
|
# Dependencies |
||||||
|
node_modules |
||||||
|
.pnpm-store |
||||||
|
.yarn |
||||||
|
|
||||||
|
# Build output |
||||||
|
.output |
||||||
|
.vercel |
||||||
|
.netlify |
||||||
|
.wrangler |
||||||
|
/.svelte-kit |
||||||
|
/build |
||||||
|
/dist |
||||||
|
|
||||||
|
# Package manager locks (keep only one) |
||||||
|
# package-lock.json |
||||||
|
# yarn.lock |
||||||
|
# pnpm-lock.yaml |
||||||
|
|
||||||
|
# OS files |
||||||
|
.DS_Store |
||||||
|
.DS_Store? |
||||||
|
._* |
||||||
|
.Spotlight-V100 |
||||||
|
.Trashes |
||||||
|
ehthumbs.db |
||||||
|
Thumbs.db |
||||||
|
Desktop.ini |
||||||
|
|
||||||
|
# IDE / Editors |
||||||
|
.idea |
||||||
|
.vscode/* |
||||||
|
!.vscode/extensions.json |
||||||
|
!.vscode/settings.json |
||||||
|
*.swp |
||||||
|
*.swo |
||||||
|
*.sublime-workspace |
||||||
|
*.sublime-project |
||||||
|
.project |
||||||
|
.classpath |
||||||
|
.settings/ |
||||||
|
*.code-workspace |
||||||
|
|
||||||
|
# Environment variables |
||||||
|
.env |
||||||
|
.env.* |
||||||
|
!.env.example |
||||||
|
!.env.test |
||||||
|
|
||||||
|
# Vite |
||||||
|
vite.config.js.timestamp-* |
||||||
|
vite.config.ts.timestamp-* |
||||||
|
|
||||||
|
# Logs |
||||||
|
logs |
||||||
|
*.log |
||||||
|
npm-debug.log* |
||||||
|
yarn-debug.log* |
||||||
|
yarn-error.log* |
||||||
|
pnpm-debug.log* |
||||||
|
lerna-debug.log* |
||||||
|
|
||||||
|
# Testing |
||||||
|
coverage |
||||||
|
.nyc_output |
||||||
|
*.lcov |
||||||
|
|
||||||
|
# Typescript |
||||||
|
*.tsbuildinfo |
||||||
|
|
||||||
|
# Docker |
||||||
|
.docker |
||||||
|
|
||||||
|
# Supabase |
||||||
|
supabase/.branches |
||||||
|
supabase/.temp |
||||||
|
|
||||||
|
# Misc |
||||||
|
*.pem |
||||||
|
*.local |
||||||
|
.cache |
||||||
|
.turbo |
||||||
|
*.pid |
||||||
|
*.seed |
||||||
|
*.pid.lock |
||||||
|
|
||||||
|
# Debug |
||||||
|
.debug |
||||||
|
|
||||||
|
# Sentry |
||||||
|
.sentryclirc |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
{ |
||||||
|
"files.associations": { |
||||||
|
"*.css": "tailwindcss" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
# Build stage |
||||||
|
FROM node:22-alpine AS builder |
||||||
|
|
||||||
|
WORKDIR /app |
||||||
|
|
||||||
|
# Copy package files |
||||||
|
COPY package*.json ./ |
||||||
|
|
||||||
|
# Install all dependencies (including dev) |
||||||
|
RUN npm ci |
||||||
|
|
||||||
|
# Copy source files |
||||||
|
COPY . . |
||||||
|
|
||||||
|
# Build the application |
||||||
|
RUN npm run build |
||||||
|
|
||||||
|
# Prune dev dependencies |
||||||
|
RUN npm prune --production |
||||||
|
|
||||||
|
# Production stage |
||||||
|
FROM node:22-alpine |
||||||
|
|
||||||
|
WORKDIR /app |
||||||
|
|
||||||
|
# Copy built application |
||||||
|
COPY --from=builder /app/build build/ |
||||||
|
COPY --from=builder /app/node_modules node_modules/ |
||||||
|
COPY package.json . |
||||||
|
|
||||||
|
# Expose port |
||||||
|
EXPOSE 3000 |
||||||
|
|
||||||
|
# Set environment |
||||||
|
ENV NODE_ENV=production |
||||||
|
ENV PORT=3000 |
||||||
|
ENV HOST=0.0.0.0 |
||||||
|
|
||||||
|
# Health check |
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ |
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 |
||||||
|
|
||||||
|
# Run the application |
||||||
|
CMD ["node", "build"] |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
FROM node:22-alpine |
||||||
|
|
||||||
|
WORKDIR /app |
||||||
|
|
||||||
|
# Copy package files |
||||||
|
COPY package*.json ./ |
||||||
|
|
||||||
|
# Install dependencies |
||||||
|
RUN npm ci |
||||||
|
|
||||||
|
# Copy source (will be overwritten by volume mount) |
||||||
|
COPY . . |
||||||
|
|
||||||
|
# Expose Vite dev server port |
||||||
|
EXPOSE 5173 |
||||||
|
|
||||||
|
# Run dev server |
||||||
|
CMD ["npm", "run", "dev", "--", "--host"] |
||||||
@ -0,0 +1,124 @@ |
|||||||
|
# Root Organization Platform |
||||||
|
|
||||||
|
Team collaboration platform with Documents, Kanban, and Calendar - no messaging. |
||||||
|
|
||||||
|
## Tech Stack |
||||||
|
|
||||||
|
- **Frontend**: SvelteKit 2 + Svelte 5 + TailwindCSS 4 |
||||||
|
- **Backend**: Supabase (PostgreSQL + Auth + Realtime) |
||||||
|
- **Deployment**: Docker + adapter-node |
||||||
|
|
||||||
|
## Features |
||||||
|
|
||||||
|
- 📁 **Documents** - Folders and rich text documents with collaborative editing |
||||||
|
- 📋 **Kanban** - Visual task boards with drag-drop and real-time sync |
||||||
|
- 📅 **Calendar** - Team scheduling with events and attendees |
||||||
|
- 👥 **Teams** - Organizations, members, roles (Owner/Admin/Editor/Viewer) |
||||||
|
|
||||||
|
## Quick Start |
||||||
|
|
||||||
|
### 1. Install dependencies |
||||||
|
|
||||||
|
```bash |
||||||
|
npm install |
||||||
|
``` |
||||||
|
|
||||||
|
### 2. Set up Supabase |
||||||
|
|
||||||
|
1. Create a project at [supabase.com](https://supabase.com) |
||||||
|
2. Copy `.env.example` to `.env` and fill in your credentials: |
||||||
|
|
||||||
|
```bash |
||||||
|
cp .env.example .env |
||||||
|
``` |
||||||
|
|
||||||
|
```env |
||||||
|
PUBLIC_SUPABASE_URL=https://your-project.supabase.co |
||||||
|
PUBLIC_SUPABASE_ANON_KEY=your-anon-key |
||||||
|
``` |
||||||
|
|
||||||
|
3. Run the database migration in Supabase SQL editor: |
||||||
|
- Open `supabase/migrations/001_initial_schema.sql` |
||||||
|
- Execute in your Supabase dashboard |
||||||
|
|
||||||
|
### 3. Run development server |
||||||
|
|
||||||
|
```bash |
||||||
|
npm run dev |
||||||
|
``` |
||||||
|
|
||||||
|
Visit http://localhost:5173 |
||||||
|
|
||||||
|
## Docker |
||||||
|
|
||||||
|
### Production |
||||||
|
|
||||||
|
```bash |
||||||
|
docker-compose up app |
||||||
|
``` |
||||||
|
|
||||||
|
### Development (with hot reload) |
||||||
|
|
||||||
|
```bash |
||||||
|
docker-compose up dev |
||||||
|
``` |
||||||
|
|
||||||
|
## Routes |
||||||
|
|
||||||
|
| Route | Description | |
||||||
|
|-------|-------------| |
||||||
|
| `/` | Organization list (home) | |
||||||
|
| `/login` | Auth (login/signup) | |
||||||
|
| `/style` | UI component showcase | |
||||||
|
| `/health` | Health check endpoint | |
||||||
|
| `/[orgSlug]` | Organization overview | |
||||||
|
| `/[orgSlug]/documents` | Documents with rich text editor | |
||||||
|
| `/[orgSlug]/kanban` | Kanban boards | |
||||||
|
| `/[orgSlug]/calendar` | Calendar events | |
||||||
|
|
||||||
|
## Project Structure |
||||||
|
|
||||||
|
``` |
||||||
|
src/ |
||||||
|
├── lib/ |
||||||
|
│ ├── api/ # Supabase API functions |
||||||
|
│ │ ├── organizations.ts |
||||||
|
│ │ ├── documents.ts |
||||||
|
│ │ ├── kanban.ts |
||||||
|
│ │ └── calendar.ts |
||||||
|
│ ├── components/ |
||||||
|
│ │ ├── ui/ # Reusable UI components |
||||||
|
│ │ ├── documents/ # FileTree, Editor |
||||||
|
│ │ ├── kanban/ # KanbanBoard |
||||||
|
│ │ └── calendar/ # Calendar |
||||||
|
│ ├── stores/ # Svelte 5 state stores |
||||||
|
│ └── supabase/ # Supabase client & types |
||||||
|
├── routes/ |
||||||
|
│ ├── login/ # Auth pages |
||||||
|
│ ├── style/ # Component showcase |
||||||
|
│ ├── health/ # Health endpoint |
||||||
|
│ ├── auth/callback/ # OAuth callback |
||||||
|
│ ├── auth/logout/ # Sign out |
||||||
|
│ └── [orgSlug]/ # Organization routes |
||||||
|
│ ├── documents/ |
||||||
|
│ ├── kanban/ |
||||||
|
│ └── calendar/ |
||||||
|
└── hooks.server.ts # Auth middleware |
||||||
|
``` |
||||||
|
|
||||||
|
## UI Components |
||||||
|
|
||||||
|
All components available in `$lib/components/ui`: |
||||||
|
|
||||||
|
- `Button` - Primary, Secondary, Ghost, Danger, Success variants |
||||||
|
- `Input` - Text input with label, error, hint |
||||||
|
- `Textarea` - Multi-line input |
||||||
|
- `Select` - Dropdown select |
||||||
|
- `Avatar` - User avatars with status |
||||||
|
- `Badge` - Status badges |
||||||
|
- `Card` - Content containers |
||||||
|
- `Modal` - Dialog windows |
||||||
|
- `Spinner` - Loading indicators |
||||||
|
- `Toggle` - On/off switches |
||||||
|
|
||||||
|
Visit `/style` to see all components. |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
services: |
||||||
|
app: |
||||||
|
build: |
||||||
|
context: . |
||||||
|
dockerfile: Dockerfile |
||||||
|
ports: |
||||||
|
- "3000:3000" |
||||||
|
environment: |
||||||
|
- NODE_ENV=production |
||||||
|
- PORT=3000 |
||||||
|
- HOST=0.0.0.0 |
||||||
|
# Supabase configuration (add your values) |
||||||
|
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL} |
||||||
|
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY} |
||||||
|
restart: unless-stopped |
||||||
|
healthcheck: |
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] |
||||||
|
interval: 30s |
||||||
|
timeout: 3s |
||||||
|
retries: 3 |
||||||
|
start_period: 5s |
||||||
|
|
||||||
|
# Development mode with hot reload |
||||||
|
dev: |
||||||
|
build: |
||||||
|
context: . |
||||||
|
dockerfile: Dockerfile.dev |
||||||
|
ports: |
||||||
|
- "5173:5173" |
||||||
|
volumes: |
||||||
|
- .:/app |
||||||
|
- /app/node_modules |
||||||
|
environment: |
||||||
|
- NODE_ENV=development |
||||||
|
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL} |
||||||
|
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY} |
||||||
|
command: npm run dev -- --host |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,41 @@ |
|||||||
|
{ |
||||||
|
"name": "root-org", |
||||||
|
"private": true, |
||||||
|
"version": "0.0.1", |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"dev": "vite dev", |
||||||
|
"build": "vite build", |
||||||
|
"preview": "vite preview", |
||||||
|
"prepare": "svelte-kit sync || echo ''", |
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", |
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", |
||||||
|
"test:unit": "vitest", |
||||||
|
"test": "npm run test:unit -- --run" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@sveltejs/adapter-node": "^5.5.2", |
||||||
|
"@sveltejs/kit": "^2.50.1", |
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4", |
||||||
|
"@tailwindcss/forms": "^0.5.11", |
||||||
|
"@tailwindcss/typography": "^0.5.19", |
||||||
|
"@tailwindcss/vite": "^4.1.18", |
||||||
|
"@vitest/browser-playwright": "^4.0.18", |
||||||
|
"playwright": "^1.58.0", |
||||||
|
"svelte": "^5.48.2", |
||||||
|
"svelte-check": "^4.3.5", |
||||||
|
"tailwindcss": "^4.1.18", |
||||||
|
"typescript": "^5.9.3", |
||||||
|
"vite": "^7.3.1", |
||||||
|
"vitest": "^4.0.18", |
||||||
|
"vitest-browser-svelte": "^2.0.2" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@supabase/ssr": "^0.8.0", |
||||||
|
"@supabase/supabase-js": "^2.94.0", |
||||||
|
"@tiptap/core": "^3.19.0", |
||||||
|
"@tiptap/extension-placeholder": "^3.19.0", |
||||||
|
"@tiptap/pm": "^3.19.0", |
||||||
|
"@tiptap/starter-kit": "^3.19.0" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
import type { SupabaseClient, Session, User } from '@supabase/supabase-js'; |
||||||
|
import type { Database } from '$lib/supabase/types'; |
||||||
|
|
||||||
|
declare global { |
||||||
|
namespace App { |
||||||
|
interface Locals { |
||||||
|
supabase: SupabaseClient<Database>; |
||||||
|
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; |
||||||
|
} |
||||||
|
interface PageData { |
||||||
|
session: Session | null; |
||||||
|
user: User | null; |
||||||
|
} |
||||||
|
// interface Error {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export { }; |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="utf-8" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
||||||
|
%sveltekit.head% |
||||||
|
</head> |
||||||
|
<body data-sveltekit-preload-data="hover"> |
||||||
|
<div style="display: contents">%sveltekit.body%</div> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
import { describe, it, expect } from 'vitest'; |
||||||
|
|
||||||
|
describe('sum test', () => { |
||||||
|
it('adds 1 + 2 to equal 3', () => { |
||||||
|
expect(1 + 2).toBe(3); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
import { createServerClient } from '@supabase/ssr'; |
||||||
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; |
||||||
|
import type { Handle } from '@sveltejs/kit'; |
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => { |
||||||
|
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { |
||||||
|
cookies: { |
||||||
|
getAll() { |
||||||
|
return event.cookies.getAll(); |
||||||
|
}, |
||||||
|
setAll(cookiesToSet) { |
||||||
|
cookiesToSet.forEach(({ name, value, options }) => { |
||||||
|
event.cookies.set(name, value, { ...options, path: '/' }); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
event.locals.safeGetSession = async () => { |
||||||
|
const { |
||||||
|
data: { session } |
||||||
|
} = await event.locals.supabase.auth.getSession(); |
||||||
|
|
||||||
|
if (!session) { |
||||||
|
return { session: null, user: null }; |
||||||
|
} |
||||||
|
|
||||||
|
const { |
||||||
|
data: { user }, |
||||||
|
error |
||||||
|
} = await event.locals.supabase.auth.getUser(); |
||||||
|
|
||||||
|
if (error) { |
||||||
|
return { session: null, user: null }; |
||||||
|
} |
||||||
|
|
||||||
|
return { session, user }; |
||||||
|
}; |
||||||
|
|
||||||
|
return resolve(event, { |
||||||
|
filterSerializedResponseHeaders(name) { |
||||||
|
return name === 'content-range' || name === 'x-supabase-api-version'; |
||||||
|
} |
||||||
|
}); |
||||||
|
}; |
||||||
@ -0,0 +1,118 @@ |
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'; |
||||||
|
import type { Database, CalendarEvent } from '$lib/supabase/types'; |
||||||
|
|
||||||
|
export async function fetchEvents( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
orgId: string, |
||||||
|
startDate: Date, |
||||||
|
endDate: Date |
||||||
|
): Promise<CalendarEvent[]> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('calendar_events') |
||||||
|
.select('*') |
||||||
|
.eq('org_id', orgId) |
||||||
|
.gte('start_time', startDate.toISOString()) |
||||||
|
.lte('end_time', endDate.toISOString()) |
||||||
|
.order('start_time'); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data ?? []; |
||||||
|
} |
||||||
|
|
||||||
|
export async function createEvent( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
orgId: string, |
||||||
|
event: { |
||||||
|
title: string; |
||||||
|
description?: string; |
||||||
|
start_time: string; |
||||||
|
end_time: string; |
||||||
|
all_day?: boolean; |
||||||
|
color?: string; |
||||||
|
}, |
||||||
|
userId: string |
||||||
|
): Promise<CalendarEvent> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('calendar_events') |
||||||
|
.insert({ |
||||||
|
org_id: orgId, |
||||||
|
title: event.title, |
||||||
|
description: event.description, |
||||||
|
start_time: event.start_time, |
||||||
|
end_time: event.end_time, |
||||||
|
all_day: event.all_day ?? false, |
||||||
|
color: event.color, |
||||||
|
created_by: userId |
||||||
|
}) |
||||||
|
.select() |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
export async function updateEvent( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string, |
||||||
|
updates: Partial<Pick<CalendarEvent, 'title' | 'description' | 'start_time' | 'end_time' | 'all_day' | 'color'>> |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('calendar_events').update(updates).eq('id', id); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function deleteEvent( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('calendar_events').delete().eq('id', id); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export function subscribeToEvents( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
orgId: string, |
||||||
|
onChange: () => void |
||||||
|
) { |
||||||
|
return supabase |
||||||
|
.channel(`calendar:${orgId}`) |
||||||
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'calendar_events', filter: `org_id=eq.${orgId}` }, onChange) |
||||||
|
.subscribe(); |
||||||
|
} |
||||||
|
|
||||||
|
// Calendar utility functions
|
||||||
|
export function getMonthDays(year: number, month: number): Date[] { |
||||||
|
const firstDay = new Date(year, month, 1); |
||||||
|
const lastDay = new Date(year, month + 1, 0); |
||||||
|
const days: Date[] = []; |
||||||
|
|
||||||
|
// Add days from previous month to fill first week
|
||||||
|
const startDayOfWeek = firstDay.getDay(); |
||||||
|
for (let i = startDayOfWeek - 1; i >= 0; i--) { |
||||||
|
days.push(new Date(year, month, -i)); |
||||||
|
} |
||||||
|
|
||||||
|
// Add days of current month
|
||||||
|
for (let i = 1; i <= lastDay.getDate(); i++) { |
||||||
|
days.push(new Date(year, month, i)); |
||||||
|
} |
||||||
|
|
||||||
|
// Add days from next month to fill last week
|
||||||
|
const remainingDays = 42 - days.length; // 6 weeks * 7 days
|
||||||
|
for (let i = 1; i <= remainingDays; i++) { |
||||||
|
days.push(new Date(year, month + 1, i)); |
||||||
|
} |
||||||
|
|
||||||
|
return days; |
||||||
|
} |
||||||
|
|
||||||
|
export function isSameDay(a: Date, b: Date): boolean { |
||||||
|
return ( |
||||||
|
a.getFullYear() === b.getFullYear() && |
||||||
|
a.getMonth() === b.getMonth() && |
||||||
|
a.getDate() === b.getDate() |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function formatTime(date: Date): string { |
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); |
||||||
|
} |
||||||
@ -0,0 +1,132 @@ |
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'; |
||||||
|
import type { Database, Document } from '$lib/supabase/types'; |
||||||
|
|
||||||
|
export interface DocumentWithChildren extends Document { |
||||||
|
children?: DocumentWithChildren[]; |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchDocuments( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
orgId: string |
||||||
|
): Promise<Document[]> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('documents') |
||||||
|
.select('*') |
||||||
|
.eq('org_id', orgId) |
||||||
|
.order('type', { ascending: false }) // folders first
|
||||||
|
.order('name'); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data ?? []; |
||||||
|
} |
||||||
|
|
||||||
|
export async function createDocument( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
orgId: string, |
||||||
|
name: string, |
||||||
|
type: 'folder' | 'document', |
||||||
|
parentId: string | null = null, |
||||||
|
userId: string |
||||||
|
): Promise<Document> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('documents') |
||||||
|
.insert({ |
||||||
|
org_id: orgId, |
||||||
|
name, |
||||||
|
type, |
||||||
|
parent_id: parentId, |
||||||
|
created_by: userId, |
||||||
|
content: type === 'document' ? { type: 'doc', content: [] } : null |
||||||
|
}) |
||||||
|
.select() |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
export async function updateDocument( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string, |
||||||
|
updates: Partial<Pick<Document, 'name' | 'content' | 'parent_id'>> |
||||||
|
): Promise<Document> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('documents') |
||||||
|
.update({ ...updates, updated_at: new Date().toISOString() }) |
||||||
|
.eq('id', id) |
||||||
|
.select() |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
export async function deleteDocument( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('documents').delete().eq('id', id); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function moveDocument( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string, |
||||||
|
newParentId: string | null |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase |
||||||
|
.from('documents') |
||||||
|
.update({ parent_id: newParentId, updated_at: new Date().toISOString() }) |
||||||
|
.eq('id', id); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export function buildDocumentTree(documents: Document[]): DocumentWithChildren[] { |
||||||
|
const map = new Map<string, DocumentWithChildren>(); |
||||||
|
const roots: DocumentWithChildren[] = []; |
||||||
|
|
||||||
|
// First pass: create map
|
||||||
|
documents.forEach((doc) => { |
||||||
|
map.set(doc.id, { ...doc, children: [] }); |
||||||
|
}); |
||||||
|
|
||||||
|
// Second pass: build tree
|
||||||
|
documents.forEach((doc) => { |
||||||
|
const node = map.get(doc.id)!; |
||||||
|
if (doc.parent_id && map.has(doc.parent_id)) { |
||||||
|
map.get(doc.parent_id)!.children!.push(node); |
||||||
|
} else { |
||||||
|
roots.push(node); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
return roots; |
||||||
|
} |
||||||
|
|
||||||
|
export function subscribeToDocuments( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
orgId: string, |
||||||
|
onInsert: (doc: Document) => void, |
||||||
|
onUpdate: (doc: Document) => void, |
||||||
|
onDelete: (id: string) => void |
||||||
|
) { |
||||||
|
return supabase |
||||||
|
.channel(`documents:${orgId}`) |
||||||
|
.on( |
||||||
|
'postgres_changes', |
||||||
|
{ event: 'INSERT', schema: 'public', table: 'documents', filter: `org_id=eq.${orgId}` }, |
||||||
|
(payload) => onInsert(payload.new as Document) |
||||||
|
) |
||||||
|
.on( |
||||||
|
'postgres_changes', |
||||||
|
{ event: 'UPDATE', schema: 'public', table: 'documents', filter: `org_id=eq.${orgId}` }, |
||||||
|
(payload) => onUpdate(payload.new as Document) |
||||||
|
) |
||||||
|
.on( |
||||||
|
'postgres_changes', |
||||||
|
{ event: 'DELETE', schema: 'public', table: 'documents', filter: `org_id=eq.${orgId}` }, |
||||||
|
(payload) => onDelete((payload.old as { id: string }).id) |
||||||
|
) |
||||||
|
.subscribe(); |
||||||
|
} |
||||||
@ -0,0 +1,264 @@ |
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'; |
||||||
|
import type { Database } from '$lib/supabase/types'; |
||||||
|
|
||||||
|
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; |
||||||
|
const GOOGLE_CLIENT_SECRET = import.meta.env.VITE_GOOGLE_CLIENT_SECRET; |
||||||
|
|
||||||
|
interface GoogleTokens { |
||||||
|
access_token: string; |
||||||
|
refresh_token: string; |
||||||
|
expires_in: number; |
||||||
|
} |
||||||
|
|
||||||
|
interface GoogleCalendarEvent { |
||||||
|
id: string; |
||||||
|
summary: string; |
||||||
|
description?: string; |
||||||
|
start: { dateTime?: string; date?: string }; |
||||||
|
end: { dateTime?: string; date?: string }; |
||||||
|
colorId?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export function getGoogleAuthUrl(redirectUri: string, state: string): string { |
||||||
|
const params = new URLSearchParams({ |
||||||
|
client_id: GOOGLE_CLIENT_ID, |
||||||
|
redirect_uri: redirectUri, |
||||||
|
response_type: 'code', |
||||||
|
scope: 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events', |
||||||
|
access_type: 'offline', |
||||||
|
prompt: 'consent', |
||||||
|
state |
||||||
|
}); |
||||||
|
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`; |
||||||
|
} |
||||||
|
|
||||||
|
export async function exchangeCodeForTokens(code: string, redirectUri: string): Promise<GoogleTokens> { |
||||||
|
const response = await fetch('https://oauth2.googleapis.com/token', { |
||||||
|
method: 'POST', |
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
||||||
|
body: new URLSearchParams({ |
||||||
|
code, |
||||||
|
client_id: GOOGLE_CLIENT_ID, |
||||||
|
client_secret: GOOGLE_CLIENT_SECRET, |
||||||
|
redirect_uri: redirectUri, |
||||||
|
grant_type: 'authorization_code' |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error('Failed to exchange code for tokens'); |
||||||
|
} |
||||||
|
|
||||||
|
return response.json(); |
||||||
|
} |
||||||
|
|
||||||
|
export async function refreshAccessToken(refreshToken: string): Promise<GoogleTokens> { |
||||||
|
const response = await fetch('https://oauth2.googleapis.com/token', { |
||||||
|
method: 'POST', |
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
||||||
|
body: new URLSearchParams({ |
||||||
|
refresh_token: refreshToken, |
||||||
|
client_id: GOOGLE_CLIENT_ID, |
||||||
|
client_secret: GOOGLE_CLIENT_SECRET, |
||||||
|
grant_type: 'refresh_token' |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error('Failed to refresh access token'); |
||||||
|
} |
||||||
|
|
||||||
|
return response.json(); |
||||||
|
} |
||||||
|
|
||||||
|
export async function getValidAccessToken( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
userId: string |
||||||
|
): Promise<string | null> { |
||||||
|
const { data: connection } = await supabase |
||||||
|
.from('google_calendar_connections') |
||||||
|
.select('*') |
||||||
|
.eq('user_id', userId) |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (!connection) return null; |
||||||
|
|
||||||
|
const expiresAt = new Date(connection.token_expires_at); |
||||||
|
const now = new Date(); |
||||||
|
|
||||||
|
// Refresh if expires within 5 minutes
|
||||||
|
if (expiresAt.getTime() - now.getTime() < 5 * 60 * 1000) { |
||||||
|
try { |
||||||
|
const tokens = await refreshAccessToken(connection.refresh_token); |
||||||
|
const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1000); |
||||||
|
|
||||||
|
await supabase |
||||||
|
.from('google_calendar_connections') |
||||||
|
.update({ |
||||||
|
access_token: tokens.access_token, |
||||||
|
token_expires_at: newExpiresAt.toISOString(), |
||||||
|
updated_at: new Date().toISOString() |
||||||
|
}) |
||||||
|
.eq('user_id', userId); |
||||||
|
|
||||||
|
return tokens.access_token; |
||||||
|
} catch { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return connection.access_token; |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchGoogleCalendarEvents( |
||||||
|
accessToken: string, |
||||||
|
calendarId: string = 'primary', |
||||||
|
timeMin: Date, |
||||||
|
timeMax: Date |
||||||
|
): Promise<GoogleCalendarEvent[]> { |
||||||
|
const params = new URLSearchParams({ |
||||||
|
timeMin: timeMin.toISOString(), |
||||||
|
timeMax: timeMax.toISOString(), |
||||||
|
singleEvents: 'true', |
||||||
|
orderBy: 'startTime' |
||||||
|
}); |
||||||
|
|
||||||
|
const response = await fetch( |
||||||
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`, |
||||||
|
{ |
||||||
|
headers: { Authorization: `Bearer ${accessToken}` } |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error('Failed to fetch Google Calendar events'); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
return data.items ?? []; |
||||||
|
} |
||||||
|
|
||||||
|
export async function createGoogleCalendarEvent( |
||||||
|
accessToken: string, |
||||||
|
calendarId: string = 'primary', |
||||||
|
event: { |
||||||
|
title: string; |
||||||
|
description?: string; |
||||||
|
startTime: string; |
||||||
|
endTime: string; |
||||||
|
allDay?: boolean; |
||||||
|
} |
||||||
|
): Promise<GoogleCalendarEvent> { |
||||||
|
const body: Record<string, unknown> = { |
||||||
|
summary: event.title, |
||||||
|
description: event.description |
||||||
|
}; |
||||||
|
|
||||||
|
if (event.allDay) { |
||||||
|
const startDate = event.startTime.split('T')[0]; |
||||||
|
const endDate = event.endTime.split('T')[0]; |
||||||
|
body.start = { date: startDate }; |
||||||
|
body.end = { date: endDate }; |
||||||
|
} else { |
||||||
|
body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; |
||||||
|
body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; |
||||||
|
} |
||||||
|
|
||||||
|
const response = await fetch( |
||||||
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`, |
||||||
|
{ |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
Authorization: `Bearer ${accessToken}`, |
||||||
|
'Content-Type': 'application/json' |
||||||
|
}, |
||||||
|
body: JSON.stringify(body) |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error('Failed to create Google Calendar event'); |
||||||
|
} |
||||||
|
|
||||||
|
return response.json(); |
||||||
|
} |
||||||
|
|
||||||
|
export async function updateGoogleCalendarEvent( |
||||||
|
accessToken: string, |
||||||
|
calendarId: string = 'primary', |
||||||
|
eventId: string, |
||||||
|
event: { |
||||||
|
title: string; |
||||||
|
description?: string; |
||||||
|
startTime: string; |
||||||
|
endTime: string; |
||||||
|
allDay?: boolean; |
||||||
|
} |
||||||
|
): Promise<GoogleCalendarEvent> { |
||||||
|
const body: Record<string, unknown> = { |
||||||
|
summary: event.title, |
||||||
|
description: event.description |
||||||
|
}; |
||||||
|
|
||||||
|
if (event.allDay) { |
||||||
|
const startDate = event.startTime.split('T')[0]; |
||||||
|
const endDate = event.endTime.split('T')[0]; |
||||||
|
body.start = { date: startDate }; |
||||||
|
body.end = { date: endDate }; |
||||||
|
} else { |
||||||
|
body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; |
||||||
|
body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; |
||||||
|
} |
||||||
|
|
||||||
|
const response = await fetch( |
||||||
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, |
||||||
|
{ |
||||||
|
method: 'PUT', |
||||||
|
headers: { |
||||||
|
Authorization: `Bearer ${accessToken}`, |
||||||
|
'Content-Type': 'application/json' |
||||||
|
}, |
||||||
|
body: JSON.stringify(body) |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error('Failed to update Google Calendar event'); |
||||||
|
} |
||||||
|
|
||||||
|
return response.json(); |
||||||
|
} |
||||||
|
|
||||||
|
export async function deleteGoogleCalendarEvent( |
||||||
|
accessToken: string, |
||||||
|
calendarId: string = 'primary', |
||||||
|
eventId: string |
||||||
|
): Promise<void> { |
||||||
|
const response = await fetch( |
||||||
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, |
||||||
|
{ |
||||||
|
method: 'DELETE', |
||||||
|
headers: { Authorization: `Bearer ${accessToken}` } |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (!response.ok && response.status !== 404) { |
||||||
|
throw new Error('Failed to delete Google Calendar event'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export async function listGoogleCalendars(accessToken: string): Promise<{ id: string; summary: string }[]> { |
||||||
|
const response = await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', { |
||||||
|
headers: { Authorization: `Bearer ${accessToken}` } |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error('Failed to list calendars'); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
return (data.items ?? []).map((cal: { id: string; summary: string }) => ({ |
||||||
|
id: cal.id, |
||||||
|
summary: cal.summary |
||||||
|
})); |
||||||
|
} |
||||||
@ -0,0 +1,215 @@ |
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'; |
||||||
|
import type { Database, KanbanBoard, KanbanColumn, KanbanCard } from '$lib/supabase/types'; |
||||||
|
|
||||||
|
export interface ColumnWithCards extends KanbanColumn { |
||||||
|
cards: KanbanCard[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface BoardWithColumns extends KanbanBoard { |
||||||
|
columns: ColumnWithCards[]; |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchBoards( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
orgId: string |
||||||
|
): Promise<KanbanBoard[]> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('kanban_boards') |
||||||
|
.select('*') |
||||||
|
.eq('org_id', orgId) |
||||||
|
.order('created_at'); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data ?? []; |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchBoardWithColumns( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
boardId: string |
||||||
|
): Promise<BoardWithColumns | null> { |
||||||
|
const { data: board, error: boardError } = await supabase |
||||||
|
.from('kanban_boards') |
||||||
|
.select('*') |
||||||
|
.eq('id', boardId) |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (boardError) throw boardError; |
||||||
|
if (!board) return null; |
||||||
|
|
||||||
|
const { data: columns, error: colError } = await supabase |
||||||
|
.from('kanban_columns') |
||||||
|
.select('*') |
||||||
|
.eq('board_id', boardId) |
||||||
|
.order('position'); |
||||||
|
|
||||||
|
if (colError) throw colError; |
||||||
|
|
||||||
|
const { data: cards, error: cardError } = await supabase |
||||||
|
.from('kanban_cards') |
||||||
|
.select('*') |
||||||
|
.in('column_id', (columns ?? []).map((c) => c.id)) |
||||||
|
.order('position'); |
||||||
|
|
||||||
|
if (cardError) throw cardError; |
||||||
|
|
||||||
|
const cardsByColumn = new Map<string, KanbanCard[]>(); |
||||||
|
(cards ?? []).forEach((card) => { |
||||||
|
if (!cardsByColumn.has(card.column_id)) { |
||||||
|
cardsByColumn.set(card.column_id, []); |
||||||
|
} |
||||||
|
cardsByColumn.get(card.column_id)!.push(card); |
||||||
|
}); |
||||||
|
|
||||||
|
return { |
||||||
|
...board, |
||||||
|
columns: (columns ?? []).map((col) => ({ |
||||||
|
...col, |
||||||
|
cards: cardsByColumn.get(col.id) ?? [] |
||||||
|
})) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export async function createBoard( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
orgId: string, |
||||||
|
name: string |
||||||
|
): Promise<KanbanBoard> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('kanban_boards') |
||||||
|
.insert({ org_id: orgId, name }) |
||||||
|
.select() |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
|
||||||
|
// Create default columns
|
||||||
|
const defaultColumns = ['To Do', 'In Progress', 'Done']; |
||||||
|
await supabase.from('kanban_columns').insert( |
||||||
|
defaultColumns.map((name, index) => ({ |
||||||
|
board_id: data.id, |
||||||
|
name, |
||||||
|
position: index |
||||||
|
})) |
||||||
|
); |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
export async function updateBoard( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string, |
||||||
|
name: string |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('kanban_boards').update({ name }).eq('id', id); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function deleteBoard( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('kanban_boards').delete().eq('id', id); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function createColumn( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
boardId: string, |
||||||
|
name: string, |
||||||
|
position: number |
||||||
|
): Promise<KanbanColumn> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('kanban_columns') |
||||||
|
.insert({ board_id: boardId, name, position }) |
||||||
|
.select() |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
export async function updateColumn( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string, |
||||||
|
updates: Partial<Pick<KanbanColumn, 'name' | 'position' | 'color'>> |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('kanban_columns').update(updates).eq('id', id); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function deleteColumn( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('kanban_columns').delete().eq('id', id); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function createCard( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
columnId: string, |
||||||
|
title: string, |
||||||
|
position: number, |
||||||
|
userId: string |
||||||
|
): Promise<KanbanCard> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('kanban_cards') |
||||||
|
.insert({ |
||||||
|
column_id: columnId, |
||||||
|
title, |
||||||
|
position, |
||||||
|
created_by: userId |
||||||
|
}) |
||||||
|
.select() |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
export async function updateCard( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string, |
||||||
|
updates: Partial<Pick<KanbanCard, 'title' | 'description' | 'column_id' | 'position' | 'due_date' | 'color'>> |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('kanban_cards').update(updates).eq('id', id); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function deleteCard( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('kanban_cards').delete().eq('id', id); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function moveCard( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
cardId: string, |
||||||
|
newColumnId: string, |
||||||
|
newPosition: number |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase |
||||||
|
.from('kanban_cards') |
||||||
|
.update({ column_id: newColumnId, position: newPosition }) |
||||||
|
.eq('id', cardId); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export function subscribeToBoard( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
boardId: string, |
||||||
|
onColumnChange: () => void, |
||||||
|
onCardChange: () => void |
||||||
|
) { |
||||||
|
const channel = supabase.channel(`kanban:${boardId}`); |
||||||
|
|
||||||
|
channel |
||||||
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_columns', filter: `board_id=eq.${boardId}` }, onColumnChange) |
||||||
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' }, onCardChange) |
||||||
|
.subscribe(); |
||||||
|
|
||||||
|
return channel; |
||||||
|
} |
||||||
@ -0,0 +1,164 @@ |
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'; |
||||||
|
import type { Database, Organization, MemberRole } from '$lib/supabase/types'; |
||||||
|
import type { OrgWithRole } from '$lib/stores/organizations.svelte'; |
||||||
|
|
||||||
|
export async function fetchUserOrganizations( |
||||||
|
supabase: SupabaseClient<Database> |
||||||
|
): Promise<OrgWithRole[]> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('org_members') |
||||||
|
.select(` |
||||||
|
role, |
||||||
|
organizations ( |
||||||
|
id, |
||||||
|
name, |
||||||
|
slug, |
||||||
|
avatar_url, |
||||||
|
created_at, |
||||||
|
updated_at |
||||||
|
) |
||||||
|
`)
|
||||||
|
.not('joined_at', 'is', null); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
|
||||||
|
return (data ?? []) |
||||||
|
.filter((item) => item.organizations) |
||||||
|
.map((item) => ({ |
||||||
|
...(item.organizations as Organization), |
||||||
|
role: item.role as MemberRole |
||||||
|
})); |
||||||
|
} |
||||||
|
|
||||||
|
export async function createOrganization( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
name: string, |
||||||
|
slug: string |
||||||
|
): Promise<Organization> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('organizations') |
||||||
|
.insert({ name, slug }) |
||||||
|
.select() |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
export async function updateOrganization( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string, |
||||||
|
updates: Partial<Pick<Organization, 'name' | 'slug' | 'avatar_url'>> |
||||||
|
): Promise<Organization> { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('organizations') |
||||||
|
.update({ ...updates, updated_at: new Date().toISOString() }) |
||||||
|
.eq('id', id) |
||||||
|
.select() |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data; |
||||||
|
} |
||||||
|
|
||||||
|
export async function deleteOrganization( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
id: string |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('organizations').delete().eq('id', id); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchOrgMembers( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
orgId: string |
||||||
|
) { |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('org_members') |
||||||
|
.select(` |
||||||
|
id, |
||||||
|
org_id, |
||||||
|
user_id, |
||||||
|
role, |
||||||
|
invited_at, |
||||||
|
joined_at, |
||||||
|
profiles ( |
||||||
|
email, |
||||||
|
full_name, |
||||||
|
avatar_url |
||||||
|
) |
||||||
|
`)
|
||||||
|
.eq('org_id', orgId); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
return data ?? []; |
||||||
|
} |
||||||
|
|
||||||
|
export async function inviteMember( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
orgId: string, |
||||||
|
email: string, |
||||||
|
role: MemberRole = 'viewer' |
||||||
|
): Promise<void> { |
||||||
|
// First, find user by email
|
||||||
|
const { data: profile, error: profileError } = await supabase |
||||||
|
.from('profiles') |
||||||
|
.select('id') |
||||||
|
.eq('email', email) |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (profileError || !profile) { |
||||||
|
throw new Error('User not found. They need to sign up first.'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if already a member
|
||||||
|
const { data: existing } = await supabase |
||||||
|
.from('org_members') |
||||||
|
.select('id') |
||||||
|
.eq('org_id', orgId) |
||||||
|
.eq('user_id', profile.id) |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (existing) { |
||||||
|
throw new Error('User is already a member of this organization.'); |
||||||
|
} |
||||||
|
|
||||||
|
// Create invitation
|
||||||
|
const { error } = await supabase.from('org_members').insert({ |
||||||
|
org_id: orgId, |
||||||
|
user_id: profile.id, |
||||||
|
role, |
||||||
|
joined_at: new Date().toISOString() // Auto-join for now
|
||||||
|
}); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function updateMemberRole( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
memberId: string, |
||||||
|
role: MemberRole |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase |
||||||
|
.from('org_members') |
||||||
|
.update({ role }) |
||||||
|
.eq('id', memberId); |
||||||
|
|
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export async function removeMember( |
||||||
|
supabase: SupabaseClient<Database>, |
||||||
|
memberId: string |
||||||
|
): Promise<void> { |
||||||
|
const { error } = await supabase.from('org_members').delete().eq('id', memberId); |
||||||
|
if (error) throw error; |
||||||
|
} |
||||||
|
|
||||||
|
export function generateSlug(name: string): string { |
||||||
|
return name |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^a-z0-9]+/g, '-') |
||||||
|
.replace(/^-|-$/g, '') |
||||||
|
.slice(0, 50); |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,124 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { CalendarEvent } from '$lib/supabase/types'; |
||||||
|
import { getMonthDays, isSameDay } from '$lib/api/calendar'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
events: CalendarEvent[]; |
||||||
|
onDateClick?: (date: Date) => void; |
||||||
|
onEventClick?: (event: CalendarEvent) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { events, onDateClick, onEventClick }: Props = $props(); |
||||||
|
|
||||||
|
let currentDate = $state(new Date()); |
||||||
|
const today = new Date(); |
||||||
|
|
||||||
|
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; |
||||||
|
|
||||||
|
const days = $derived(getMonthDays(currentDate.getFullYear(), currentDate.getMonth())); |
||||||
|
|
||||||
|
function prevMonth() { |
||||||
|
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1); |
||||||
|
} |
||||||
|
|
||||||
|
function nextMonth() { |
||||||
|
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1); |
||||||
|
} |
||||||
|
|
||||||
|
function goToToday() { |
||||||
|
currentDate = new Date(); |
||||||
|
} |
||||||
|
|
||||||
|
function getEventsForDay(date: Date): CalendarEvent[] { |
||||||
|
return events.filter((event) => { |
||||||
|
const eventStart = new Date(event.start_time); |
||||||
|
return isSameDay(eventStart, date); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function isCurrentMonth(date: Date): boolean { |
||||||
|
return date.getMonth() === currentDate.getMonth(); |
||||||
|
} |
||||||
|
|
||||||
|
const monthYear = $derived( |
||||||
|
currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) |
||||||
|
); |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="bg-surface rounded-xl p-4"> |
||||||
|
<div class="flex items-center justify-between mb-4"> |
||||||
|
<h2 class="text-xl font-semibold text-light">{monthYear}</h2> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<button |
||||||
|
class="px-3 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors" |
||||||
|
onclick={goToToday} |
||||||
|
> |
||||||
|
Today |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors" |
||||||
|
onclick={prevMonth} |
||||||
|
aria-label="Previous month" |
||||||
|
> |
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<path d="m15 18-6-6 6-6" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors" |
||||||
|
onclick={nextMonth} |
||||||
|
aria-label="Next month" |
||||||
|
> |
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<path d="m9 18 6-6-6-6" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden"> |
||||||
|
{#each weekDays as day} |
||||||
|
<div class="bg-dark px-2 py-2 text-center text-sm font-medium text-light/50"> |
||||||
|
{day} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
|
||||||
|
{#each days as day} |
||||||
|
{@const dayEvents = getEventsForDay(day)} |
||||||
|
{@const isToday = isSameDay(day, today)} |
||||||
|
{@const inMonth = isCurrentMonth(day)} |
||||||
|
<button |
||||||
|
class="bg-dark min-h-[80px] p-1 text-left transition-colors hover:bg-light/5" |
||||||
|
class:opacity-40={!inMonth} |
||||||
|
onclick={() => onDateClick?.(day)} |
||||||
|
> |
||||||
|
<div class="flex items-center justify-center w-7 h-7 mb-1"> |
||||||
|
<span |
||||||
|
class="text-sm {isToday |
||||||
|
? 'bg-primary text-white rounded-full w-7 h-7 flex items-center justify-center' |
||||||
|
: 'text-light/80'}" |
||||||
|
> |
||||||
|
{day.getDate()} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<div class="space-y-0.5"> |
||||||
|
{#each dayEvents.slice(0, 3) as event} |
||||||
|
<button |
||||||
|
class="w-full text-xs px-1 py-0.5 rounded truncate text-left" |
||||||
|
style="background-color: {event.color ?? '#6366f1'}20; color: {event.color ?? '#6366f1'}" |
||||||
|
onclick={(e) => { |
||||||
|
e.stopPropagation(); |
||||||
|
onEventClick?.(event); |
||||||
|
}} |
||||||
|
> |
||||||
|
{event.title} |
||||||
|
</button> |
||||||
|
{/each} |
||||||
|
{#if dayEvents.length > 3} |
||||||
|
<p class="text-xs text-light/40 px-1">+{dayEvents.length - 3} more</p> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1 @@ |
|||||||
|
export { default as Calendar } from './Calendar.svelte'; |
||||||
@ -0,0 +1,201 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount, onDestroy } from 'svelte'; |
||||||
|
import { Editor } from '@tiptap/core'; |
||||||
|
import StarterKit from '@tiptap/starter-kit'; |
||||||
|
import Placeholder from '@tiptap/extension-placeholder'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
content?: object | null; |
||||||
|
editable?: boolean; |
||||||
|
placeholder?: string; |
||||||
|
onUpdate?: (content: object) => void; |
||||||
|
onSave?: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
content = null, |
||||||
|
editable = true, |
||||||
|
placeholder = 'Start writing...', |
||||||
|
onUpdate, |
||||||
|
onSave |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
let element: HTMLDivElement; |
||||||
|
let editor: Editor | null = $state(null); |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
editor = new Editor({ |
||||||
|
element, |
||||||
|
extensions: [ |
||||||
|
StarterKit, |
||||||
|
Placeholder.configure({ placeholder }) |
||||||
|
], |
||||||
|
content: content ?? undefined, |
||||||
|
editable, |
||||||
|
onUpdate: ({ editor }) => { |
||||||
|
onUpdate?.(editor.getJSON()); |
||||||
|
}, |
||||||
|
editorProps: { |
||||||
|
attributes: { |
||||||
|
class: 'prose prose-invert max-w-none focus:outline-none min-h-[200px] p-4' |
||||||
|
}, |
||||||
|
handleKeyDown: (view, event) => { |
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 's') { |
||||||
|
event.preventDefault(); |
||||||
|
onSave?.(); |
||||||
|
return true; |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
onDestroy(() => { |
||||||
|
editor?.destroy(); |
||||||
|
}); |
||||||
|
|
||||||
|
export function setContent(newContent: object | null) { |
||||||
|
if (editor && newContent) { |
||||||
|
editor.commands.setContent(newContent); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function getContent() { |
||||||
|
return editor?.getJSON() ?? null; |
||||||
|
} |
||||||
|
|
||||||
|
export function focus() { |
||||||
|
editor?.commands.focus(); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="bg-surface rounded-xl border border-light/10 overflow-hidden"> |
||||||
|
{#if editable} |
||||||
|
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-light/10 bg-dark/50"> |
||||||
|
<button |
||||||
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors" |
||||||
|
onclick={() => editor?.chain().focus().toggleBold().run()} |
||||||
|
class:text-primary={editor?.isActive('bold')} |
||||||
|
title="Bold (Ctrl+B)" |
||||||
|
> |
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> |
||||||
|
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" /> |
||||||
|
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors" |
||||||
|
onclick={() => editor?.chain().focus().toggleItalic().run()} |
||||||
|
class:text-primary={editor?.isActive('italic')} |
||||||
|
title="Italic (Ctrl+I)" |
||||||
|
> |
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<line x1="19" y1="4" x2="10" y2="4" /> |
||||||
|
<line x1="14" y1="20" x2="5" y2="20" /> |
||||||
|
<line x1="15" y1="4" x2="9" y2="20" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors" |
||||||
|
onclick={() => editor?.chain().focus().toggleStrike().run()} |
||||||
|
class:text-primary={editor?.isActive('strike')} |
||||||
|
title="Strikethrough" |
||||||
|
> |
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<path d="M16 4H9a3 3 0 0 0-2.83 4" /> |
||||||
|
<path d="M14 12a4 4 0 0 1 0 8H6" /> |
||||||
|
<line x1="4" y1="12" x2="20" y2="12" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
<div class="w-px h-5 bg-light/20 mx-1"></div> |
||||||
|
<button |
||||||
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors" |
||||||
|
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} |
||||||
|
class:text-primary={editor?.isActive('heading', { level: 1 })} |
||||||
|
title="Heading 1" |
||||||
|
> |
||||||
|
<span class="text-xs font-bold">H1</span> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors" |
||||||
|
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} |
||||||
|
class:text-primary={editor?.isActive('heading', { level: 2 })} |
||||||
|
title="Heading 2" |
||||||
|
> |
||||||
|
<span class="text-xs font-bold">H2</span> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors" |
||||||
|
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} |
||||||
|
class:text-primary={editor?.isActive('heading', { level: 3 })} |
||||||
|
title="Heading 3" |
||||||
|
> |
||||||
|
<span class="text-xs font-bold">H3</span> |
||||||
|
</button> |
||||||
|
<div class="w-px h-5 bg-light/20 mx-1"></div> |
||||||
|
<button |
||||||
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors" |
||||||
|
onclick={() => editor?.chain().focus().toggleBulletList().run()} |
||||||
|
class:text-primary={editor?.isActive('bulletList')} |
||||||
|
title="Bullet List" |
||||||
|
> |
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<line x1="8" y1="6" x2="21" y2="6" /> |
||||||
|
<line x1="8" y1="12" x2="21" y2="12" /> |
||||||
|
<line x1="8" y1="18" x2="21" y2="18" /> |
||||||
|
<circle cx="4" cy="6" r="1" fill="currentColor" /> |
||||||
|
<circle cx="4" cy="12" r="1" fill="currentColor" /> |
||||||
|
<circle cx="4" cy="18" r="1" fill="currentColor" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors" |
||||||
|
onclick={() => editor?.chain().focus().toggleOrderedList().run()} |
||||||
|
class:text-primary={editor?.isActive('orderedList')} |
||||||
|
title="Numbered List" |
||||||
|
> |
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<line x1="10" y1="6" x2="21" y2="6" /> |
||||||
|
<line x1="10" y1="12" x2="21" y2="12" /> |
||||||
|
<line x1="10" y1="18" x2="21" y2="18" /> |
||||||
|
<text x="3" y="8" font-size="8" fill="currentColor">1</text> |
||||||
|
<text x="3" y="14" font-size="8" fill="currentColor">2</text> |
||||||
|
<text x="3" y="20" font-size="8" fill="currentColor">3</text> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors" |
||||||
|
onclick={() => editor?.chain().focus().toggleBlockquote().run()} |
||||||
|
class:text-primary={editor?.isActive('blockquote')} |
||||||
|
title="Quote" |
||||||
|
> |
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"> |
||||||
|
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors" |
||||||
|
onclick={() => editor?.chain().focus().toggleCodeBlock().run()} |
||||||
|
class:text-primary={editor?.isActive('codeBlock')} |
||||||
|
title="Code Block" |
||||||
|
> |
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<polyline points="16,18 22,12 16,6" /> |
||||||
|
<polyline points="8,6 2,12 8,18" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<div bind:this={element}></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
:global(.ProseMirror p.is-editor-empty:first-child::before) { |
||||||
|
content: attr(data-placeholder); |
||||||
|
float: left; |
||||||
|
color: rgb(var(--color-light) / 0.3); |
||||||
|
pointer-events: none; |
||||||
|
height: 0; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,192 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { DocumentWithChildren } from "$lib/api/documents"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
items: DocumentWithChildren[]; |
||||||
|
selectedId?: string | null; |
||||||
|
onSelect: (doc: DocumentWithChildren) => void; |
||||||
|
onAdd?: (parentId: string | null) => void; |
||||||
|
onMove?: (docId: string, newParentId: string | null) => void; |
||||||
|
level?: number; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
items, |
||||||
|
selectedId = null, |
||||||
|
onSelect, |
||||||
|
onAdd, |
||||||
|
onMove, |
||||||
|
level = 0, |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
let expandedFolders = $state<Set<string>>(new Set()); |
||||||
|
let dragOverId = $state<string | null>(null); |
||||||
|
|
||||||
|
function toggleFolder(id: string, e?: MouseEvent) { |
||||||
|
e?.stopPropagation(); |
||||||
|
const newSet = new Set(expandedFolders); |
||||||
|
if (newSet.has(id)) { |
||||||
|
newSet.delete(id); |
||||||
|
} else { |
||||||
|
newSet.add(id); |
||||||
|
} |
||||||
|
expandedFolders = newSet; |
||||||
|
} |
||||||
|
|
||||||
|
function handleSelect(doc: DocumentWithChildren) { |
||||||
|
onSelect(doc); |
||||||
|
} |
||||||
|
|
||||||
|
function handleAdd(e: MouseEvent, parentId: string | null) { |
||||||
|
e.stopPropagation(); |
||||||
|
onAdd?.(parentId); |
||||||
|
} |
||||||
|
|
||||||
|
function handleDragStart(e: DragEvent, doc: DocumentWithChildren) { |
||||||
|
if (!e.dataTransfer) return; |
||||||
|
e.dataTransfer.effectAllowed = "move"; |
||||||
|
e.dataTransfer.setData("text/plain", doc.id); |
||||||
|
} |
||||||
|
|
||||||
|
function handleDragOver( |
||||||
|
e: DragEvent, |
||||||
|
targetId: string | null, |
||||||
|
isFolder: boolean, |
||||||
|
) { |
||||||
|
if (!isFolder && targetId !== null) return; |
||||||
|
e.preventDefault(); |
||||||
|
dragOverId = targetId; |
||||||
|
} |
||||||
|
|
||||||
|
function handleDragLeave() { |
||||||
|
dragOverId = null; |
||||||
|
} |
||||||
|
|
||||||
|
function handleDrop(e: DragEvent, targetFolderId: string | null) { |
||||||
|
e.preventDefault(); |
||||||
|
dragOverId = null; |
||||||
|
const docId = e.dataTransfer?.getData("text/plain"); |
||||||
|
if (docId && docId !== targetFolderId) { |
||||||
|
onMove?.(docId, targetFolderId); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div |
||||||
|
class="space-y-0.5" |
||||||
|
ondragover={(e) => level === 0 && handleDragOver(e, null, true)} |
||||||
|
ondragleave={handleDragLeave} |
||||||
|
ondrop={(e) => level === 0 && handleDrop(e, null)} |
||||||
|
role="tree" |
||||||
|
> |
||||||
|
{#each items as item} |
||||||
|
<div role="treeitem"> |
||||||
|
<div |
||||||
|
class="group w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors cursor-pointer |
||||||
|
{selectedId === item.id |
||||||
|
? 'bg-primary/20 text-primary' |
||||||
|
: 'text-light/80 hover:bg-light/5'} |
||||||
|
{dragOverId === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}" |
||||||
|
onclick={() => handleSelect(item)} |
||||||
|
draggable="true" |
||||||
|
ondragstart={(e) => handleDragStart(e, item)} |
||||||
|
ondragover={(e) => |
||||||
|
handleDragOver(e, item.id, item.type === "folder")} |
||||||
|
ondragleave={handleDragLeave} |
||||||
|
ondrop={(e) => item.type === "folder" && handleDrop(e, item.id)} |
||||||
|
role="button" |
||||||
|
tabindex="0" |
||||||
|
> |
||||||
|
{#if item.type === "folder"} |
||||||
|
<button |
||||||
|
class="p-0.5 hover:bg-light/10 rounded" |
||||||
|
onclick={(e) => toggleFolder(item.id, e)} |
||||||
|
aria-label="Toggle folder" |
||||||
|
> |
||||||
|
<svg |
||||||
|
class="w-4 h-4 transition-transform {expandedFolders.has( |
||||||
|
item.id, |
||||||
|
) |
||||||
|
? 'rotate-90' |
||||||
|
: ''}" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<path d="m9 18 6-6-6-6" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
<svg |
||||||
|
class="w-4 h-4 text-warning" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="currentColor" |
||||||
|
> |
||||||
|
<path |
||||||
|
d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H12L10 5H5C3.89543 5 3 5.89543 3 7Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
{:else} |
||||||
|
<div class="w-5"></div> |
||||||
|
<svg |
||||||
|
class="w-4 h-4 text-light/50" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<path |
||||||
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" |
||||||
|
/> |
||||||
|
<polyline points="14,2 14,8 20,8" /> |
||||||
|
<line x1="16" y1="13" x2="8" y2="13" /> |
||||||
|
<line x1="16" y1="17" x2="8" y2="17" /> |
||||||
|
</svg> |
||||||
|
{/if} |
||||||
|
<span class="flex-1 truncate text-sm">{item.name}</span> |
||||||
|
|
||||||
|
{#if item.type === "folder" && onAdd} |
||||||
|
<button |
||||||
|
class="opacity-0 group-hover:opacity-100 p-1 hover:bg-light/10 rounded transition-opacity" |
||||||
|
onclick={(e) => handleAdd(e, item.id)} |
||||||
|
aria-label="Add to folder" |
||||||
|
> |
||||||
|
<svg |
||||||
|
class="w-4 h-4" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /> |
||||||
|
<line x1="5" y1="12" x2="19" y2="12" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if item.type === "folder" && expandedFolders.has(item.id)} |
||||||
|
<div class="ml-4 border-l border-light/10 pl-2"> |
||||||
|
{#if item.children?.length} |
||||||
|
<svelte:self |
||||||
|
items={item.children} |
||||||
|
{selectedId} |
||||||
|
{onSelect} |
||||||
|
{onAdd} |
||||||
|
{onMove} |
||||||
|
level={level + 1} |
||||||
|
/> |
||||||
|
{:else} |
||||||
|
<p class="text-light/30 text-xs px-3 py-2 italic"> |
||||||
|
Empty folder |
||||||
|
</p> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
|
||||||
|
{#if items.length === 0 && level === 0} |
||||||
|
<p class="text-light/40 text-sm px-3 py-2">No documents yet</p> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
export { default as FileTree } from './FileTree.svelte'; |
||||||
|
export { default as Editor } from './Editor.svelte'; |
||||||
@ -0,0 +1,231 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { getContext } from 'svelte'; |
||||||
|
import { Modal, Button, Input, Textarea } from '$lib/components/ui'; |
||||||
|
import type { KanbanCard } from '$lib/supabase/types'; |
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'; |
||||||
|
import type { Database } from '$lib/supabase/types'; |
||||||
|
|
||||||
|
interface ChecklistItem { |
||||||
|
id: string; |
||||||
|
card_id: string; |
||||||
|
title: string; |
||||||
|
completed: boolean; |
||||||
|
position: number; |
||||||
|
} |
||||||
|
|
||||||
|
interface Props { |
||||||
|
card: KanbanCard | null; |
||||||
|
isOpen: boolean; |
||||||
|
onClose: () => void; |
||||||
|
onUpdate: (card: KanbanCard) => void; |
||||||
|
onDelete: (cardId: string) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { card, isOpen, onClose, onUpdate, onDelete }: Props = $props(); |
||||||
|
|
||||||
|
const supabase = getContext<SupabaseClient<Database>>('supabase'); |
||||||
|
|
||||||
|
let title = $state(''); |
||||||
|
let description = $state(''); |
||||||
|
let checklist = $state<ChecklistItem[]>([]); |
||||||
|
let newItemTitle = $state(''); |
||||||
|
let isLoading = $state(false); |
||||||
|
let isSaving = $state(false); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (card && isOpen) { |
||||||
|
title = card.title; |
||||||
|
description = card.description ?? ''; |
||||||
|
loadChecklist(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadChecklist() { |
||||||
|
if (!card) return; |
||||||
|
isLoading = true; |
||||||
|
|
||||||
|
const { data } = await supabase |
||||||
|
.from('checklist_items') |
||||||
|
.select('*') |
||||||
|
.eq('card_id', card.id) |
||||||
|
.order('position'); |
||||||
|
|
||||||
|
checklist = (data ?? []) as ChecklistItem[]; |
||||||
|
isLoading = false; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleSave() { |
||||||
|
if (!card) return; |
||||||
|
isSaving = true; |
||||||
|
|
||||||
|
const { error } = await supabase |
||||||
|
.from('kanban_cards') |
||||||
|
.update({ |
||||||
|
title, |
||||||
|
description: description || null |
||||||
|
}) |
||||||
|
.eq('id', card.id); |
||||||
|
|
||||||
|
if (!error) { |
||||||
|
onUpdate({ ...card, title, description: description || null }); |
||||||
|
} |
||||||
|
isSaving = false; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleAddItem() { |
||||||
|
if (!card || !newItemTitle.trim()) return; |
||||||
|
|
||||||
|
const position = checklist.length; |
||||||
|
const { data, error } = await supabase |
||||||
|
.from('checklist_items') |
||||||
|
.insert({ |
||||||
|
card_id: card.id, |
||||||
|
title: newItemTitle, |
||||||
|
position, |
||||||
|
completed: false |
||||||
|
}) |
||||||
|
.select() |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (!error && data) { |
||||||
|
checklist = [...checklist, data as ChecklistItem]; |
||||||
|
newItemTitle = ''; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function toggleItem(item: ChecklistItem) { |
||||||
|
const { error } = await supabase |
||||||
|
.from('checklist_items') |
||||||
|
.update({ completed: !item.completed }) |
||||||
|
.eq('id', item.id); |
||||||
|
|
||||||
|
if (!error) { |
||||||
|
checklist = checklist.map(i => |
||||||
|
i.id === item.id ? { ...i, completed: !i.completed } : i |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function deleteItem(itemId: string) { |
||||||
|
const { error } = await supabase |
||||||
|
.from('checklist_items') |
||||||
|
.delete() |
||||||
|
.eq('id', itemId); |
||||||
|
|
||||||
|
if (!error) { |
||||||
|
checklist = checklist.filter(i => i.id !== itemId); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function handleDelete() { |
||||||
|
if (!card || !confirm('Delete this card?')) return; |
||||||
|
|
||||||
|
await supabase |
||||||
|
.from('kanban_cards') |
||||||
|
.delete() |
||||||
|
.eq('id', card.id); |
||||||
|
|
||||||
|
onDelete(card.id); |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
|
||||||
|
const completedCount = $derived(checklist.filter(i => i.completed).length); |
||||||
|
const progress = $derived(checklist.length > 0 ? (completedCount / checklist.length) * 100 : 0); |
||||||
|
</script> |
||||||
|
|
||||||
|
<Modal {isOpen} {onClose} title="Card Details" size="lg"> |
||||||
|
{#if card} |
||||||
|
<div class="space-y-5"> |
||||||
|
<Input |
||||||
|
label="Title" |
||||||
|
bind:value={title} |
||||||
|
placeholder="Card title" |
||||||
|
/> |
||||||
|
|
||||||
|
<Textarea |
||||||
|
label="Description" |
||||||
|
bind:value={description} |
||||||
|
placeholder="Add a more detailed description..." |
||||||
|
rows={3} |
||||||
|
/> |
||||||
|
|
||||||
|
<div> |
||||||
|
<div class="flex items-center justify-between mb-3"> |
||||||
|
<label class="text-sm font-medium text-light">Checklist</label> |
||||||
|
{#if checklist.length > 0} |
||||||
|
<span class="text-xs text-light/50">{completedCount}/{checklist.length}</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if checklist.length > 0} |
||||||
|
<div class="mb-3 h-1.5 bg-light/10 rounded-full overflow-hidden"> |
||||||
|
<div |
||||||
|
class="h-full bg-success transition-all duration-300" |
||||||
|
style="width: {progress}%" |
||||||
|
></div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if isLoading} |
||||||
|
<div class="text-light/50 text-sm py-2">Loading...</div> |
||||||
|
{:else} |
||||||
|
<div class="space-y-2 mb-3"> |
||||||
|
{#each checklist as item} |
||||||
|
<div class="flex items-center gap-3 group"> |
||||||
|
<button |
||||||
|
class="w-5 h-5 rounded border flex items-center justify-center transition-colors |
||||||
|
{item.completed ? 'bg-success border-success' : 'border-light/30 hover:border-light/50'}" |
||||||
|
onclick={() => toggleItem(item)} |
||||||
|
> |
||||||
|
{#if item.completed} |
||||||
|
<svg class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"> |
||||||
|
<polyline points="20,6 9,17 4,12" /> |
||||||
|
</svg> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
<span class="flex-1 text-sm {item.completed ? 'line-through text-light/40' : 'text-light'}"> |
||||||
|
{item.title} |
||||||
|
</span> |
||||||
|
<button |
||||||
|
class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all" |
||||||
|
onclick={() => deleteItem(item.id)} |
||||||
|
aria-label="Delete item" |
||||||
|
> |
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /> |
||||||
|
<line x1="6" y1="6" x2="18" y2="18" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex gap-2"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary" |
||||||
|
placeholder="Add an item..." |
||||||
|
bind:value={newItemTitle} |
||||||
|
onkeydown={(e) => e.key === 'Enter' && handleAddItem()} |
||||||
|
/> |
||||||
|
<Button size="sm" onclick={handleAddItem} disabled={!newItemTitle.trim()}> |
||||||
|
Add |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-3 border-t border-light/10"> |
||||||
|
<Button variant="danger" onclick={handleDelete}> |
||||||
|
Delete Card |
||||||
|
</Button> |
||||||
|
<div class="flex gap-2"> |
||||||
|
<Button variant="ghost" onclick={onClose}>Cancel</Button> |
||||||
|
<Button onclick={handleSave} loading={isSaving}> |
||||||
|
Save Changes |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</Modal> |
||||||
@ -0,0 +1,162 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { ColumnWithCards } from '$lib/api/kanban'; |
||||||
|
import type { KanbanCard } from '$lib/supabase/types'; |
||||||
|
import { Button, Card, Badge } from '$lib/components/ui'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
columns: ColumnWithCards[]; |
||||||
|
onCardClick?: (card: KanbanCard) => void; |
||||||
|
onCardMove?: (cardId: string, toColumnId: string, toPosition: number) => void; |
||||||
|
onAddCard?: (columnId: string) => void; |
||||||
|
onAddColumn?: () => void; |
||||||
|
canEdit?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
columns, |
||||||
|
onCardClick, |
||||||
|
onCardMove, |
||||||
|
onAddCard, |
||||||
|
onAddColumn, |
||||||
|
canEdit = true |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
let draggedCard = $state<KanbanCard | null>(null); |
||||||
|
let dragOverColumn = $state<string | null>(null); |
||||||
|
|
||||||
|
function handleDragStart(e: DragEvent, card: KanbanCard) { |
||||||
|
draggedCard = card; |
||||||
|
if (e.dataTransfer) { |
||||||
|
e.dataTransfer.effectAllowed = 'move'; |
||||||
|
e.dataTransfer.setData('text/plain', card.id); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent, columnId: string) { |
||||||
|
e.preventDefault(); |
||||||
|
dragOverColumn = columnId; |
||||||
|
} |
||||||
|
|
||||||
|
function handleDragLeave() { |
||||||
|
dragOverColumn = null; |
||||||
|
} |
||||||
|
|
||||||
|
function handleDrop(e: DragEvent, columnId: string) { |
||||||
|
e.preventDefault(); |
||||||
|
dragOverColumn = null; |
||||||
|
|
||||||
|
if (draggedCard && draggedCard.column_id !== columnId) { |
||||||
|
const column = columns.find((c) => c.id === columnId); |
||||||
|
const newPosition = column?.cards.length ?? 0; |
||||||
|
onCardMove?.(draggedCard.id, columnId, newPosition); |
||||||
|
} |
||||||
|
draggedCard = null; |
||||||
|
} |
||||||
|
|
||||||
|
function formatDueDate(dateStr: string | null): string { |
||||||
|
if (!dateStr) return ''; |
||||||
|
const date = new Date(dateStr); |
||||||
|
const now = new Date(); |
||||||
|
const diff = date.getTime() - now.getTime(); |
||||||
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); |
||||||
|
|
||||||
|
if (days < 0) return 'Overdue'; |
||||||
|
if (days === 0) return 'Today'; |
||||||
|
if (days === 1) return 'Tomorrow'; |
||||||
|
return date.toLocaleDateString(); |
||||||
|
} |
||||||
|
|
||||||
|
function getDueDateColor(dateStr: string | null): 'error' | 'warning' | 'default' { |
||||||
|
if (!dateStr) return 'default'; |
||||||
|
const date = new Date(dateStr); |
||||||
|
const now = new Date(); |
||||||
|
const diff = date.getTime() - now.getTime(); |
||||||
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); |
||||||
|
|
||||||
|
if (days < 0) return 'error'; |
||||||
|
if (days <= 2) return 'warning'; |
||||||
|
return 'default'; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex gap-4 overflow-x-auto pb-4 min-h-[500px]"> |
||||||
|
{#each columns as column} |
||||||
|
<div |
||||||
|
class="flex-shrink-0 w-72 bg-surface rounded-xl p-3 flex flex-col max-h-[calc(100vh-200px)]" |
||||||
|
class:ring-2={dragOverColumn === column.id} |
||||||
|
class:ring-primary={dragOverColumn === column.id} |
||||||
|
ondragover={(e) => handleDragOver(e, column.id)} |
||||||
|
ondragleave={handleDragLeave} |
||||||
|
ondrop={(e) => handleDrop(e, column.id)} |
||||||
|
role="list" |
||||||
|
> |
||||||
|
<div class="flex items-center justify-between mb-3 px-1"> |
||||||
|
<h3 class="font-medium text-light flex items-center gap-2"> |
||||||
|
{column.name} |
||||||
|
<span class="text-xs text-light/50 bg-light/10 px-1.5 py-0.5 rounded"> |
||||||
|
{column.cards.length} |
||||||
|
</span> |
||||||
|
</h3> |
||||||
|
{#if column.color} |
||||||
|
<div class="w-3 h-3 rounded-full" style="background-color: {column.color}"></div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto space-y-2"> |
||||||
|
{#each column.cards as card} |
||||||
|
<div |
||||||
|
class="bg-dark rounded-lg p-3 cursor-pointer hover:ring-1 hover:ring-light/20 transition-all" |
||||||
|
class:opacity-50={draggedCard?.id === card.id} |
||||||
|
draggable={canEdit} |
||||||
|
ondragstart={(e) => handleDragStart(e, card)} |
||||||
|
onclick={() => onCardClick?.(card)} |
||||||
|
onkeydown={(e) => e.key === 'Enter' && onCardClick?.(card)} |
||||||
|
role="listitem" |
||||||
|
tabindex="0" |
||||||
|
> |
||||||
|
{#if card.color} |
||||||
|
<div class="w-full h-1 rounded-full mb-2" style="background-color: {card.color}"></div> |
||||||
|
{/if} |
||||||
|
<p class="text-sm text-light">{card.title}</p> |
||||||
|
{#if card.description} |
||||||
|
<p class="text-xs text-light/50 mt-1 line-clamp-2">{card.description}</p> |
||||||
|
{/if} |
||||||
|
{#if card.due_date} |
||||||
|
<div class="mt-2"> |
||||||
|
<Badge size="sm" variant={getDueDateColor(card.due_date)}> |
||||||
|
{formatDueDate(card.due_date)} |
||||||
|
</Badge> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if canEdit} |
||||||
|
<button |
||||||
|
class="mt-2 w-full py-2 text-sm text-light/50 hover:text-light hover:bg-light/5 rounded-lg transition-colors flex items-center justify-center gap-1" |
||||||
|
onclick={() => onAddCard?.(column.id)} |
||||||
|
> |
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /> |
||||||
|
<line x1="5" y1="12" x2="19" y2="12" /> |
||||||
|
</svg> |
||||||
|
Add card |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
|
||||||
|
{#if canEdit} |
||||||
|
<button |
||||||
|
class="flex-shrink-0 w-72 h-12 bg-light/5 hover:bg-light/10 rounded-xl flex items-center justify-center gap-2 text-light/50 hover:text-light transition-colors" |
||||||
|
onclick={() => onAddColumn?.()} |
||||||
|
> |
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /> |
||||||
|
<line x1="5" y1="12" x2="19" y2="12" /> |
||||||
|
</svg> |
||||||
|
Add column |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
export { default as KanbanBoard } from './KanbanBoard.svelte'; |
||||||
|
export { default as CardDetailModal } from './CardDetailModal.svelte'; |
||||||
@ -0,0 +1,92 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
interface Props { |
||||||
|
src?: string | null; |
||||||
|
name?: string; |
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; |
||||||
|
status?: 'online' | 'offline' | 'away' | 'busy' | null; |
||||||
|
} |
||||||
|
|
||||||
|
let { src = null, name = '?', size = 'md', status = null }: Props = $props(); |
||||||
|
|
||||||
|
const sizeClasses = { |
||||||
|
xs: 'w-6 h-6 text-xs', |
||||||
|
sm: 'w-8 h-8 text-sm', |
||||||
|
md: 'w-10 h-10 text-base', |
||||||
|
lg: 'w-12 h-12 text-lg', |
||||||
|
xl: 'w-16 h-16 text-xl', |
||||||
|
'2xl': 'w-20 h-20 text-2xl' |
||||||
|
}; |
||||||
|
|
||||||
|
const statusSizes = { |
||||||
|
xs: 'w-2 h-2', |
||||||
|
sm: 'w-2.5 h-2.5', |
||||||
|
md: 'w-3 h-3', |
||||||
|
lg: 'w-3.5 h-3.5', |
||||||
|
xl: 'w-4 h-4', |
||||||
|
'2xl': 'w-5 h-5' |
||||||
|
}; |
||||||
|
|
||||||
|
const statusColors = { |
||||||
|
online: 'bg-success', |
||||||
|
offline: 'bg-light/30', |
||||||
|
away: 'bg-warning', |
||||||
|
busy: 'bg-error' |
||||||
|
}; |
||||||
|
|
||||||
|
function getInitials(name: string): string { |
||||||
|
return name |
||||||
|
.split(' ') |
||||||
|
.map((part) => part[0]) |
||||||
|
.join('') |
||||||
|
.toUpperCase() |
||||||
|
.slice(0, 2); |
||||||
|
} |
||||||
|
|
||||||
|
function getColorFromName(name: string): string { |
||||||
|
const colors = [ |
||||||
|
'bg-red-500', |
||||||
|
'bg-orange-500', |
||||||
|
'bg-amber-500', |
||||||
|
'bg-yellow-500', |
||||||
|
'bg-lime-500', |
||||||
|
'bg-green-500', |
||||||
|
'bg-emerald-500', |
||||||
|
'bg-teal-500', |
||||||
|
'bg-cyan-500', |
||||||
|
'bg-sky-500', |
||||||
|
'bg-blue-500', |
||||||
|
'bg-indigo-500', |
||||||
|
'bg-violet-500', |
||||||
|
'bg-purple-500', |
||||||
|
'bg-fuchsia-500', |
||||||
|
'bg-pink-500' |
||||||
|
]; |
||||||
|
let hash = 0; |
||||||
|
for (let i = 0; i < name.length; i++) { |
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash); |
||||||
|
} |
||||||
|
return colors[Math.abs(hash) % colors.length]; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="relative inline-block"> |
||||||
|
<div |
||||||
|
class="rounded-full flex items-center justify-center font-medium text-white overflow-hidden {sizeClasses[ |
||||||
|
size |
||||||
|
]} {!src ? getColorFromName(name) : 'bg-surface'}" |
||||||
|
> |
||||||
|
{#if src} |
||||||
|
<img {src} alt={name} class="w-full h-full object-cover" /> |
||||||
|
{:else} |
||||||
|
{getInitials(name)} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if status} |
||||||
|
<div |
||||||
|
class="absolute bottom-0 right-0 rounded-full border-2 border-dark {statusSizes[size]} {statusColors[ |
||||||
|
status |
||||||
|
]}" |
||||||
|
></div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,30 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { Snippet } from 'svelte'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info'; |
||||||
|
size?: 'sm' | 'md' | 'lg'; |
||||||
|
children: Snippet; |
||||||
|
} |
||||||
|
|
||||||
|
let { variant = 'default', size = 'md', children }: Props = $props(); |
||||||
|
|
||||||
|
const variantClasses = { |
||||||
|
default: 'bg-light/10 text-light', |
||||||
|
primary: 'bg-primary/20 text-primary', |
||||||
|
success: 'bg-success/20 text-success', |
||||||
|
warning: 'bg-warning/20 text-warning', |
||||||
|
error: 'bg-error/20 text-error', |
||||||
|
info: 'bg-info/20 text-info' |
||||||
|
}; |
||||||
|
|
||||||
|
const sizeClasses = { |
||||||
|
sm: 'px-1.5 py-0.5 text-xs', |
||||||
|
md: 'px-2 py-0.5 text-sm', |
||||||
|
lg: 'px-2.5 py-1 text-sm' |
||||||
|
}; |
||||||
|
</script> |
||||||
|
|
||||||
|
<span class="inline-flex items-center font-medium rounded-full {variantClasses[variant]} {sizeClasses[size]}"> |
||||||
|
{@render children()} |
||||||
|
</span> |
||||||
@ -0,0 +1,71 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { Snippet } from 'svelte'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success'; |
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg'; |
||||||
|
disabled?: boolean; |
||||||
|
loading?: boolean; |
||||||
|
type?: 'button' | 'submit' | 'reset'; |
||||||
|
fullWidth?: boolean; |
||||||
|
onclick?: (e: MouseEvent) => void; |
||||||
|
children: Snippet; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
variant = 'primary', |
||||||
|
size = 'md', |
||||||
|
disabled = false, |
||||||
|
loading = false, |
||||||
|
type = 'button', |
||||||
|
fullWidth = false, |
||||||
|
onclick, |
||||||
|
children |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
const baseClasses = |
||||||
|
'inline-flex items-center justify-center font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark disabled:opacity-50 disabled:cursor-not-allowed'; |
||||||
|
|
||||||
|
const variantClasses = { |
||||||
|
primary: 'bg-primary text-white hover:bg-primary/90 focus:ring-primary rounded-xl', |
||||||
|
secondary: |
||||||
|
'bg-surface text-light border border-light/20 hover:bg-light/5 focus:ring-light/50 rounded-xl', |
||||||
|
ghost: 'bg-transparent text-light hover:bg-light/10 focus:ring-light/50 rounded-xl', |
||||||
|
danger: 'bg-error text-white hover:bg-error/90 focus:ring-error rounded-xl', |
||||||
|
success: 'bg-success text-white hover:bg-success/90 focus:ring-success rounded-xl' |
||||||
|
}; |
||||||
|
|
||||||
|
const sizeClasses = { |
||||||
|
xs: 'px-2 py-1 text-xs gap-1', |
||||||
|
sm: 'px-3 py-1.5 text-sm gap-1.5', |
||||||
|
md: 'px-4 py-2 text-sm gap-2', |
||||||
|
lg: 'px-6 py-3 text-base gap-2.5' |
||||||
|
}; |
||||||
|
</script> |
||||||
|
|
||||||
|
<button |
||||||
|
{type} |
||||||
|
class="{baseClasses} {variantClasses[variant]} {sizeClasses[size]}" |
||||||
|
class:w-full={fullWidth} |
||||||
|
disabled={disabled || loading} |
||||||
|
{onclick} |
||||||
|
> |
||||||
|
{#if loading} |
||||||
|
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none"> |
||||||
|
<circle |
||||||
|
class="opacity-25" |
||||||
|
cx="12" |
||||||
|
cy="12" |
||||||
|
r="10" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="4" |
||||||
|
></circle> |
||||||
|
<path |
||||||
|
class="opacity-75" |
||||||
|
fill="currentColor" |
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
||||||
|
></path> |
||||||
|
</svg> |
||||||
|
{/if} |
||||||
|
{@render children()} |
||||||
|
</button> |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { Snippet } from 'svelte'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
variant?: 'default' | 'elevated' | 'outlined'; |
||||||
|
padding?: 'none' | 'sm' | 'md' | 'lg'; |
||||||
|
children: Snippet; |
||||||
|
} |
||||||
|
|
||||||
|
let { variant = 'default', padding = 'md', children }: Props = $props(); |
||||||
|
|
||||||
|
const variantClasses = { |
||||||
|
default: 'bg-surface', |
||||||
|
elevated: 'bg-surface shadow-lg shadow-black/20', |
||||||
|
outlined: 'bg-surface border border-light/10' |
||||||
|
}; |
||||||
|
|
||||||
|
const paddingClasses = { |
||||||
|
none: '', |
||||||
|
sm: 'p-3', |
||||||
|
md: 'p-4', |
||||||
|
lg: 'p-6' |
||||||
|
}; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="rounded-2xl {variantClasses[variant]} {paddingClasses[padding]}"> |
||||||
|
{@render children()} |
||||||
|
</div> |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
interface Props { |
||||||
|
type?: "text" | "password" | "email" | "url" | "search" | "number"; |
||||||
|
value?: string; |
||||||
|
placeholder?: string; |
||||||
|
label?: string; |
||||||
|
error?: string; |
||||||
|
hint?: string; |
||||||
|
disabled?: boolean; |
||||||
|
required?: boolean; |
||||||
|
autocomplete?: AutoFill; |
||||||
|
oninput?: (e: Event) => void; |
||||||
|
onkeydown?: (e: KeyboardEvent) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
type = "text", |
||||||
|
value = $bindable(""), |
||||||
|
placeholder = "", |
||||||
|
label, |
||||||
|
error, |
||||||
|
hint, |
||||||
|
disabled = false, |
||||||
|
required = false, |
||||||
|
autocomplete, |
||||||
|
oninput, |
||||||
|
onkeydown, |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
const inputId = `input-${crypto.randomUUID().slice(0, 8)}`; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5"> |
||||||
|
{#if label} |
||||||
|
<label for={inputId} class="text-sm font-medium text-light/80"> |
||||||
|
{label} |
||||||
|
{#if required}<span class="text-primary">*</span>{/if} |
||||||
|
</label> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<input |
||||||
|
id={inputId} |
||||||
|
{type} |
||||||
|
bind:value |
||||||
|
{placeholder} |
||||||
|
{disabled} |
||||||
|
{required} |
||||||
|
{autocomplete} |
||||||
|
{oninput} |
||||||
|
{onkeydown} |
||||||
|
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20 |
||||||
|
placeholder:text-light/40 |
||||||
|
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary |
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed |
||||||
|
transition-colors" |
||||||
|
class:border-error={error} |
||||||
|
class:focus:border-error={error} |
||||||
|
class:focus:ring-error={error} |
||||||
|
/> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<p class="text-sm text-error">{error}</p> |
||||||
|
{:else if hint} |
||||||
|
<p class="text-sm text-light/50">{hint}</p> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { Snippet } from 'svelte'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
isOpen: boolean; |
||||||
|
onClose: () => void; |
||||||
|
title?: string; |
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl'; |
||||||
|
children: Snippet; |
||||||
|
} |
||||||
|
|
||||||
|
let { isOpen, onClose, title, size = 'md', children }: Props = $props(); |
||||||
|
|
||||||
|
const sizeClasses = { |
||||||
|
sm: 'max-w-sm', |
||||||
|
md: 'max-w-md', |
||||||
|
lg: 'max-w-lg', |
||||||
|
xl: 'max-w-xl' |
||||||
|
}; |
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) { |
||||||
|
if (e.key === 'Escape') { |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) { |
||||||
|
if (e.target === e.currentTarget) { |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if isOpen} |
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
||||||
|
<div |
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" |
||||||
|
onclick={handleBackdropClick} |
||||||
|
onkeydown={handleKeydown} |
||||||
|
role="dialog" |
||||||
|
aria-modal="true" |
||||||
|
aria-labelledby={title ? 'modal-title' : undefined} |
||||||
|
tabindex="-1" |
||||||
|
> |
||||||
|
<div |
||||||
|
class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[size]} shadow-xl" |
||||||
|
onclick={(e) => e.stopPropagation()} |
||||||
|
role="document" |
||||||
|
> |
||||||
|
{#if title} |
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-light/10"> |
||||||
|
<h2 id="modal-title" class="text-lg font-semibold text-light">{title}</h2> |
||||||
|
<button |
||||||
|
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors" |
||||||
|
onclick={onClose} |
||||||
|
aria-label="Close" |
||||||
|
> |
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /> |
||||||
|
<line x1="6" y1="6" x2="18" y2="18" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<div class="p-6"> |
||||||
|
{@render children()} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
@ -0,0 +1,68 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
interface Option { |
||||||
|
value: string; |
||||||
|
label: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface Props { |
||||||
|
value?: string; |
||||||
|
options: Option[]; |
||||||
|
label?: string; |
||||||
|
placeholder?: string; |
||||||
|
error?: string; |
||||||
|
disabled?: boolean; |
||||||
|
required?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
value = $bindable(""), |
||||||
|
options, |
||||||
|
label, |
||||||
|
placeholder = "Select...", |
||||||
|
error, |
||||||
|
disabled = false, |
||||||
|
required = false, |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
const inputId = `select-${crypto.randomUUID().slice(0, 8)}`; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5"> |
||||||
|
{#if label} |
||||||
|
<label for={inputId} class="text-sm font-medium text-light/80"> |
||||||
|
{label} |
||||||
|
{#if required}<span class="text-primary">*</span>{/if} |
||||||
|
</label> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<select |
||||||
|
id={inputId} |
||||||
|
bind:value |
||||||
|
{disabled} |
||||||
|
{required} |
||||||
|
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20 |
||||||
|
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary |
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed |
||||||
|
transition-colors appearance-none cursor-pointer" |
||||||
|
class:border-error={error} |
||||||
|
class:placeholder-shown={!value} |
||||||
|
> |
||||||
|
<option value="" disabled>{placeholder}</option> |
||||||
|
{#each options as option} |
||||||
|
<option value={option.value}>{option.label}</option> |
||||||
|
{/each} |
||||||
|
</select> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<p class="text-sm text-error">{error}</p> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
select { |
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); |
||||||
|
background-repeat: no-repeat; |
||||||
|
background-position: right 12px center; |
||||||
|
padding-right: 40px; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
interface Props { |
||||||
|
size?: 'sm' | 'md' | 'lg'; |
||||||
|
color?: 'primary' | 'light' | 'current'; |
||||||
|
} |
||||||
|
|
||||||
|
let { size = 'md', color = 'primary' }: Props = $props(); |
||||||
|
|
||||||
|
const sizeClasses = { |
||||||
|
sm: 'w-4 h-4', |
||||||
|
md: 'w-6 h-6', |
||||||
|
lg: 'w-8 h-8' |
||||||
|
}; |
||||||
|
|
||||||
|
const colorClasses = { |
||||||
|
primary: 'text-primary', |
||||||
|
light: 'text-light', |
||||||
|
current: 'text-current' |
||||||
|
}; |
||||||
|
</script> |
||||||
|
|
||||||
|
<svg |
||||||
|
class="animate-spin {sizeClasses[size]} {colorClasses[color]}" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
> |
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" |
||||||
|
></circle> |
||||||
|
<path |
||||||
|
class="opacity-75" |
||||||
|
fill="currentColor" |
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
||||||
|
></path> |
||||||
|
</svg> |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
interface Props { |
||||||
|
value?: string; |
||||||
|
placeholder?: string; |
||||||
|
label?: string; |
||||||
|
error?: string; |
||||||
|
hint?: string; |
||||||
|
disabled?: boolean; |
||||||
|
required?: boolean; |
||||||
|
rows?: number; |
||||||
|
resize?: 'none' | 'vertical' | 'horizontal' | 'both'; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
value = $bindable(''), |
||||||
|
placeholder = '', |
||||||
|
label, |
||||||
|
error, |
||||||
|
hint, |
||||||
|
disabled = false, |
||||||
|
required = false, |
||||||
|
rows = 3, |
||||||
|
resize = 'vertical' |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
const inputId = `textarea-${crypto.randomUUID().slice(0, 8)}`; |
||||||
|
|
||||||
|
const resizeClasses = { |
||||||
|
none: 'resize-none', |
||||||
|
vertical: 'resize-y', |
||||||
|
horizontal: 'resize-x', |
||||||
|
both: 'resize' |
||||||
|
}; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5"> |
||||||
|
{#if label} |
||||||
|
<label for={inputId} class="text-sm font-medium text-light/80"> |
||||||
|
{label} |
||||||
|
{#if required}<span class="text-primary">*</span>{/if} |
||||||
|
</label> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<textarea |
||||||
|
id={inputId} |
||||||
|
bind:value |
||||||
|
{placeholder} |
||||||
|
{disabled} |
||||||
|
{required} |
||||||
|
{rows} |
||||||
|
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20 |
||||||
|
placeholder:text-light/40 |
||||||
|
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary |
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed |
||||||
|
transition-colors {resizeClasses[resize]}" |
||||||
|
class:border-error={error} |
||||||
|
class:focus:border-error={error} |
||||||
|
class:focus:ring-error={error} |
||||||
|
></textarea> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<p class="text-sm text-error">{error}</p> |
||||||
|
{:else if hint} |
||||||
|
<p class="text-sm text-light/50">{hint}</p> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,40 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
interface Props { |
||||||
|
checked?: boolean; |
||||||
|
disabled?: boolean; |
||||||
|
size?: 'sm' | 'md' | 'lg'; |
||||||
|
onchange?: (checked: boolean) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { checked = $bindable(false), disabled = false, size = 'md', onchange }: Props = $props(); |
||||||
|
|
||||||
|
const sizeClasses = { |
||||||
|
sm: { track: 'w-8 h-4', thumb: 'w-3 h-3', translate: 'translate-x-4' }, |
||||||
|
md: { track: 'w-10 h-5', thumb: 'w-4 h-4', translate: 'translate-x-5' }, |
||||||
|
lg: { track: 'w-12 h-6', thumb: 'w-5 h-5', translate: 'translate-x-6' } |
||||||
|
}; |
||||||
|
|
||||||
|
function handleClick() { |
||||||
|
if (!disabled) { |
||||||
|
checked = !checked; |
||||||
|
onchange?.(checked); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<button |
||||||
|
type="button" |
||||||
|
role="switch" |
||||||
|
aria-checked={checked} |
||||||
|
{disabled} |
||||||
|
onclick={handleClick} |
||||||
|
class="relative inline-flex items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-dark disabled:opacity-50 disabled:cursor-not-allowed {sizeClasses[ |
||||||
|
size |
||||||
|
].track} {checked ? 'bg-primary' : 'bg-light/20'}" |
||||||
|
> |
||||||
|
<span |
||||||
|
class="inline-block rounded-full bg-white shadow-sm transition-transform duration-200 {sizeClasses[ |
||||||
|
size |
||||||
|
].thumb} {checked ? sizeClasses[size].translate : 'translate-x-0.5'}" |
||||||
|
></span> |
||||||
|
</button> |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
export { default as Button } from './Button.svelte'; |
||||||
|
export { default as Input } from './Input.svelte'; |
||||||
|
export { default as Textarea } from './Textarea.svelte'; |
||||||
|
export { default as Select } from './Select.svelte'; |
||||||
|
export { default as Avatar } from './Avatar.svelte'; |
||||||
|
export { default as Badge } from './Badge.svelte'; |
||||||
|
export { default as Card } from './Card.svelte'; |
||||||
|
export { default as Modal } from './Modal.svelte'; |
||||||
|
export { default as Spinner } from './Spinner.svelte'; |
||||||
|
export { default as Toggle } from './Toggle.svelte'; |
||||||
@ -0,0 +1 @@ |
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
@ -0,0 +1,27 @@ |
|||||||
|
import type { Session, User } from '@supabase/supabase-js'; |
||||||
|
|
||||||
|
class AuthStore { |
||||||
|
session = $state<Session | null>(null); |
||||||
|
user = $state<User | null>(null); |
||||||
|
isLoading = $state(true); |
||||||
|
|
||||||
|
setSession(session: Session | null, user: User | null) { |
||||||
|
this.session = session; |
||||||
|
this.user = user; |
||||||
|
this.isLoading = false; |
||||||
|
} |
||||||
|
|
||||||
|
get isAuthenticated() { |
||||||
|
return !!this.session && !!this.user; |
||||||
|
} |
||||||
|
|
||||||
|
get userId() { |
||||||
|
return this.user?.id ?? null; |
||||||
|
} |
||||||
|
|
||||||
|
get email() { |
||||||
|
return this.user?.email ?? null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const auth = new AuthStore(); |
||||||
@ -0,0 +1,52 @@ |
|||||||
|
import type { Document } from '$lib/supabase/types'; |
||||||
|
import type { DocumentWithChildren } from '$lib/api/documents'; |
||||||
|
import { buildDocumentTree } from '$lib/api/documents'; |
||||||
|
|
||||||
|
class DocumentsStore { |
||||||
|
documents = $state<Document[]>([]); |
||||||
|
currentDocument = $state<Document | null>(null); |
||||||
|
isLoading = $state(false); |
||||||
|
isSaving = $state(false); |
||||||
|
|
||||||
|
setDocuments(docs: Document[]) { |
||||||
|
this.documents = docs; |
||||||
|
} |
||||||
|
|
||||||
|
setCurrentDocument(doc: Document | null) { |
||||||
|
this.currentDocument = doc; |
||||||
|
} |
||||||
|
|
||||||
|
addDocument(doc: Document) { |
||||||
|
this.documents = [...this.documents, doc]; |
||||||
|
} |
||||||
|
|
||||||
|
updateDocument(id: string, updates: Partial<Document>) { |
||||||
|
this.documents = this.documents.map((doc) => |
||||||
|
doc.id === id ? { ...doc, ...updates } : doc |
||||||
|
); |
||||||
|
if (this.currentDocument?.id === id) { |
||||||
|
this.currentDocument = { ...this.currentDocument, ...updates }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
removeDocument(id: string) { |
||||||
|
this.documents = this.documents.filter((doc) => doc.id !== id); |
||||||
|
if (this.currentDocument?.id === id) { |
||||||
|
this.currentDocument = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
get tree(): DocumentWithChildren[] { |
||||||
|
return buildDocumentTree(this.documents); |
||||||
|
} |
||||||
|
|
||||||
|
get folders() { |
||||||
|
return this.documents.filter((doc) => doc.type === 'folder'); |
||||||
|
} |
||||||
|
|
||||||
|
get files() { |
||||||
|
return this.documents.filter((doc) => doc.type === 'document'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const docs = new DocumentsStore(); |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
export { auth } from './auth.svelte'; |
||||||
|
export { orgs, type OrgWithRole } from './organizations.svelte'; |
||||||
@ -0,0 +1,59 @@ |
|||||||
|
import type { Organization, OrgMember, MemberRole } from '$lib/supabase/types'; |
||||||
|
|
||||||
|
export interface OrgWithRole extends Organization { |
||||||
|
role: MemberRole; |
||||||
|
memberCount?: number; |
||||||
|
} |
||||||
|
|
||||||
|
class OrganizationsStore { |
||||||
|
organizations = $state<OrgWithRole[]>([]); |
||||||
|
currentOrg = $state<OrgWithRole | null>(null); |
||||||
|
members = $state<(OrgMember & { profile?: { email: string; full_name: string | null; avatar_url: string | null } })[]>([]); |
||||||
|
isLoading = $state(false); |
||||||
|
|
||||||
|
setOrganizations(orgs: OrgWithRole[]) { |
||||||
|
this.organizations = orgs; |
||||||
|
} |
||||||
|
|
||||||
|
setCurrentOrg(org: OrgWithRole | null) { |
||||||
|
this.currentOrg = org; |
||||||
|
} |
||||||
|
|
||||||
|
setMembers(members: typeof this.members) { |
||||||
|
this.members = members; |
||||||
|
} |
||||||
|
|
||||||
|
addOrganization(org: OrgWithRole) { |
||||||
|
this.organizations = [...this.organizations, org]; |
||||||
|
} |
||||||
|
|
||||||
|
updateOrganization(id: string, updates: Partial<Organization>) { |
||||||
|
this.organizations = this.organizations.map((org) => |
||||||
|
org.id === id ? { ...org, ...updates } : org |
||||||
|
); |
||||||
|
if (this.currentOrg?.id === id) { |
||||||
|
this.currentOrg = { ...this.currentOrg, ...updates }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
removeOrganization(id: string) { |
||||||
|
this.organizations = this.organizations.filter((org) => org.id !== id); |
||||||
|
if (this.currentOrg?.id === id) { |
||||||
|
this.currentOrg = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
get hasOrganizations() { |
||||||
|
return this.organizations.length > 0; |
||||||
|
} |
||||||
|
|
||||||
|
get isOwnerOrAdmin() { |
||||||
|
return this.currentOrg?.role === 'owner' || this.currentOrg?.role === 'admin'; |
||||||
|
} |
||||||
|
|
||||||
|
get canEdit() { |
||||||
|
return ['owner', 'admin', 'editor'].includes(this.currentOrg?.role ?? ''); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const orgs = new OrganizationsStore(); |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
import { createBrowserClient } from '@supabase/ssr'; |
||||||
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; |
||||||
|
import type { Database } from './types'; |
||||||
|
|
||||||
|
export function createClient() { |
||||||
|
return createBrowserClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY); |
||||||
|
} |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
export { createClient } from './client'; |
||||||
|
export { createClient as createServerClient } from './server'; |
||||||
|
export type * from './types'; |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
import { createServerClient } from '@supabase/ssr'; |
||||||
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; |
||||||
|
import type { Database } from './types'; |
||||||
|
import type { Cookies } from '@sveltejs/kit'; |
||||||
|
|
||||||
|
export function createClient(cookies: Cookies) { |
||||||
|
return createServerClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { |
||||||
|
cookies: { |
||||||
|
getAll() { |
||||||
|
return cookies.getAll(); |
||||||
|
}, |
||||||
|
setAll(cookiesToSet) { |
||||||
|
cookiesToSet.forEach(({ name, value, options }) => { |
||||||
|
cookies.set(name, value, { ...options, path: '/' }); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
@ -0,0 +1,288 @@ |
|||||||
|
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; |
||||||
|
|
||||||
|
export interface Database { |
||||||
|
public: { |
||||||
|
Tables: { |
||||||
|
organizations: { |
||||||
|
Row: { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
slug: string; |
||||||
|
avatar_url: string | null; |
||||||
|
created_at: string; |
||||||
|
updated_at: string; |
||||||
|
}; |
||||||
|
Insert: { |
||||||
|
id?: string; |
||||||
|
name: string; |
||||||
|
slug: string; |
||||||
|
avatar_url?: string | null; |
||||||
|
created_at?: string; |
||||||
|
updated_at?: string; |
||||||
|
}; |
||||||
|
Update: { |
||||||
|
id?: string; |
||||||
|
name?: string; |
||||||
|
slug?: string; |
||||||
|
avatar_url?: string | null; |
||||||
|
created_at?: string; |
||||||
|
updated_at?: string; |
||||||
|
}; |
||||||
|
}; |
||||||
|
org_members: { |
||||||
|
Row: { |
||||||
|
id: string; |
||||||
|
org_id: string; |
||||||
|
user_id: string; |
||||||
|
role: 'owner' | 'admin' | 'editor' | 'viewer'; |
||||||
|
invited_at: string; |
||||||
|
joined_at: string | null; |
||||||
|
}; |
||||||
|
Insert: { |
||||||
|
id?: string; |
||||||
|
org_id: string; |
||||||
|
user_id: string; |
||||||
|
role: 'owner' | 'admin' | 'editor' | 'viewer'; |
||||||
|
invited_at?: string; |
||||||
|
joined_at?: string | null; |
||||||
|
}; |
||||||
|
Update: { |
||||||
|
id?: string; |
||||||
|
org_id?: string; |
||||||
|
user_id?: string; |
||||||
|
role?: 'owner' | 'admin' | 'editor' | 'viewer'; |
||||||
|
invited_at?: string; |
||||||
|
joined_at?: string | null; |
||||||
|
}; |
||||||
|
}; |
||||||
|
documents: { |
||||||
|
Row: { |
||||||
|
id: string; |
||||||
|
org_id: string; |
||||||
|
parent_id: string | null; |
||||||
|
type: 'folder' | 'document'; |
||||||
|
name: string; |
||||||
|
content: Json | null; |
||||||
|
created_by: string; |
||||||
|
created_at: string; |
||||||
|
updated_at: string; |
||||||
|
}; |
||||||
|
Insert: { |
||||||
|
id?: string; |
||||||
|
org_id: string; |
||||||
|
parent_id?: string | null; |
||||||
|
type: 'folder' | 'document'; |
||||||
|
name: string; |
||||||
|
content?: Json | null; |
||||||
|
created_by: string; |
||||||
|
created_at?: string; |
||||||
|
updated_at?: string; |
||||||
|
}; |
||||||
|
Update: { |
||||||
|
id?: string; |
||||||
|
org_id?: string; |
||||||
|
parent_id?: string | null; |
||||||
|
type?: 'folder' | 'document'; |
||||||
|
name?: string; |
||||||
|
content?: Json | null; |
||||||
|
created_by?: string; |
||||||
|
created_at?: string; |
||||||
|
updated_at?: string; |
||||||
|
}; |
||||||
|
}; |
||||||
|
kanban_boards: { |
||||||
|
Row: { |
||||||
|
id: string; |
||||||
|
org_id: string; |
||||||
|
name: string; |
||||||
|
created_at: string; |
||||||
|
}; |
||||||
|
Insert: { |
||||||
|
id?: string; |
||||||
|
org_id: string; |
||||||
|
name: string; |
||||||
|
created_at?: string; |
||||||
|
}; |
||||||
|
Update: { |
||||||
|
id?: string; |
||||||
|
org_id?: string; |
||||||
|
name?: string; |
||||||
|
created_at?: string; |
||||||
|
}; |
||||||
|
}; |
||||||
|
kanban_columns: { |
||||||
|
Row: { |
||||||
|
id: string; |
||||||
|
board_id: string; |
||||||
|
name: string; |
||||||
|
position: number; |
||||||
|
color: string | null; |
||||||
|
}; |
||||||
|
Insert: { |
||||||
|
id?: string; |
||||||
|
board_id: string; |
||||||
|
name: string; |
||||||
|
position: number; |
||||||
|
color?: string | null; |
||||||
|
}; |
||||||
|
Update: { |
||||||
|
id?: string; |
||||||
|
board_id?: string; |
||||||
|
name?: string; |
||||||
|
position?: number; |
||||||
|
color?: string | null; |
||||||
|
}; |
||||||
|
}; |
||||||
|
kanban_cards: { |
||||||
|
Row: { |
||||||
|
id: string; |
||||||
|
column_id: string; |
||||||
|
title: string; |
||||||
|
description: string | null; |
||||||
|
position: number; |
||||||
|
due_date: string | null; |
||||||
|
color: string | null; |
||||||
|
created_by: string; |
||||||
|
created_at: string; |
||||||
|
}; |
||||||
|
Insert: { |
||||||
|
id?: string; |
||||||
|
column_id: string; |
||||||
|
title: string; |
||||||
|
description?: string | null; |
||||||
|
position: number; |
||||||
|
due_date?: string | null; |
||||||
|
color?: string | null; |
||||||
|
created_by: string; |
||||||
|
created_at?: string; |
||||||
|
}; |
||||||
|
Update: { |
||||||
|
id?: string; |
||||||
|
column_id?: string; |
||||||
|
title?: string; |
||||||
|
description?: string | null; |
||||||
|
position?: number; |
||||||
|
due_date?: string | null; |
||||||
|
color?: string | null; |
||||||
|
created_by?: string; |
||||||
|
created_at?: string; |
||||||
|
}; |
||||||
|
}; |
||||||
|
card_assignees: { |
||||||
|
Row: { |
||||||
|
card_id: string; |
||||||
|
user_id: string; |
||||||
|
}; |
||||||
|
Insert: { |
||||||
|
card_id: string; |
||||||
|
user_id: string; |
||||||
|
}; |
||||||
|
Update: { |
||||||
|
card_id?: string; |
||||||
|
user_id?: string; |
||||||
|
}; |
||||||
|
}; |
||||||
|
calendar_events: { |
||||||
|
Row: { |
||||||
|
id: string; |
||||||
|
org_id: string; |
||||||
|
title: string; |
||||||
|
description: string | null; |
||||||
|
start_time: string; |
||||||
|
end_time: string; |
||||||
|
all_day: boolean; |
||||||
|
color: string | null; |
||||||
|
recurrence: string | null; |
||||||
|
created_by: string; |
||||||
|
created_at: string; |
||||||
|
}; |
||||||
|
Insert: { |
||||||
|
id?: string; |
||||||
|
org_id: string; |
||||||
|
title: string; |
||||||
|
description?: string | null; |
||||||
|
start_time: string; |
||||||
|
end_time: string; |
||||||
|
all_day?: boolean; |
||||||
|
color?: string | null; |
||||||
|
recurrence?: string | null; |
||||||
|
created_by: string; |
||||||
|
created_at?: string; |
||||||
|
}; |
||||||
|
Update: { |
||||||
|
id?: string; |
||||||
|
org_id?: string; |
||||||
|
title?: string; |
||||||
|
description?: string | null; |
||||||
|
start_time?: string; |
||||||
|
end_time?: string; |
||||||
|
all_day?: boolean; |
||||||
|
color?: string | null; |
||||||
|
recurrence?: string | null; |
||||||
|
created_by?: string; |
||||||
|
created_at?: string; |
||||||
|
}; |
||||||
|
}; |
||||||
|
event_attendees: { |
||||||
|
Row: { |
||||||
|
event_id: string; |
||||||
|
user_id: string; |
||||||
|
status: 'pending' | 'accepted' | 'declined'; |
||||||
|
}; |
||||||
|
Insert: { |
||||||
|
event_id: string; |
||||||
|
user_id: string; |
||||||
|
status?: 'pending' | 'accepted' | 'declined'; |
||||||
|
}; |
||||||
|
Update: { |
||||||
|
event_id?: string; |
||||||
|
user_id?: string; |
||||||
|
status?: 'pending' | 'accepted' | 'declined'; |
||||||
|
}; |
||||||
|
}; |
||||||
|
profiles: { |
||||||
|
Row: { |
||||||
|
id: string; |
||||||
|
email: string; |
||||||
|
full_name: string | null; |
||||||
|
avatar_url: string | null; |
||||||
|
created_at: string; |
||||||
|
updated_at: string; |
||||||
|
}; |
||||||
|
Insert: { |
||||||
|
id: string; |
||||||
|
email: string; |
||||||
|
full_name?: string | null; |
||||||
|
avatar_url?: string | null; |
||||||
|
created_at?: string; |
||||||
|
updated_at?: string; |
||||||
|
}; |
||||||
|
Update: { |
||||||
|
id?: string; |
||||||
|
email?: string; |
||||||
|
full_name?: string | null; |
||||||
|
avatar_url?: string | null; |
||||||
|
created_at?: string; |
||||||
|
updated_at?: string; |
||||||
|
}; |
||||||
|
}; |
||||||
|
}; |
||||||
|
Views: Record<string, never>; |
||||||
|
Functions: Record<string, never>; |
||||||
|
Enums: { |
||||||
|
member_role: 'owner' | 'admin' | 'editor' | 'viewer'; |
||||||
|
attendee_status: 'pending' | 'accepted' | 'declined'; |
||||||
|
}; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Convenience types
|
||||||
|
export type Organization = Database['public']['Tables']['organizations']['Row']; |
||||||
|
export type OrgMember = Database['public']['Tables']['org_members']['Row']; |
||||||
|
export type Document = Database['public']['Tables']['documents']['Row']; |
||||||
|
export type KanbanBoard = Database['public']['Tables']['kanban_boards']['Row']; |
||||||
|
export type KanbanColumn = Database['public']['Tables']['kanban_columns']['Row']; |
||||||
|
export type KanbanCard = Database['public']['Tables']['kanban_cards']['Row']; |
||||||
|
export type CalendarEvent = Database['public']['Tables']['calendar_events']['Row']; |
||||||
|
export type Profile = Database['public']['Tables']['profiles']['Row']; |
||||||
|
export type MemberRole = Database['public']['Enums']['member_role']; |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
import type { LayoutServerLoad } from './$types'; |
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => { |
||||||
|
const { session, user } = await locals.safeGetSession(); |
||||||
|
return { session, user }; |
||||||
|
}; |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import "./layout.css"; |
||||||
|
import favicon from "$lib/assets/favicon.svg"; |
||||||
|
import { createClient } from "$lib/supabase"; |
||||||
|
import { setContext } from "svelte"; |
||||||
|
|
||||||
|
let { children, data } = $props(); |
||||||
|
|
||||||
|
const supabase = createClient(); |
||||||
|
setContext("supabase", supabase); |
||||||
|
</script> |
||||||
|
|
||||||
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head> |
||||||
|
{@render children()} |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
import { redirect } from '@sveltejs/kit'; |
||||||
|
import type { PageServerLoad } from './$types'; |
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => { |
||||||
|
const { session, user } = await locals.safeGetSession(); |
||||||
|
|
||||||
|
if (!session || !user) { |
||||||
|
redirect(303, '/login'); |
||||||
|
} |
||||||
|
|
||||||
|
const { data: memberships } = await locals.supabase |
||||||
|
.from('org_members') |
||||||
|
.select(` |
||||||
|
role, |
||||||
|
organization:organizations(*) |
||||||
|
`)
|
||||||
|
.eq('user_id', user.id); |
||||||
|
|
||||||
|
const organizations = (memberships ?? []).map((m) => ({ |
||||||
|
...m.organization, |
||||||
|
role: m.role |
||||||
|
})); |
||||||
|
|
||||||
|
return { |
||||||
|
organizations |
||||||
|
}; |
||||||
|
}; |
||||||
@ -0,0 +1,190 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { getContext } from "svelte"; |
||||||
|
import { Button, Card, Modal, Input } from "$lib/components/ui"; |
||||||
|
import { createOrganization, generateSlug } from "$lib/api/organizations"; |
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js"; |
||||||
|
import type { Database } from "$lib/supabase/types"; |
||||||
|
|
||||||
|
interface OrgWithRole { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
slug: string; |
||||||
|
role: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface Props { |
||||||
|
data: { |
||||||
|
organizations: OrgWithRole[]; |
||||||
|
user: any; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
let { data }: Props = $props(); |
||||||
|
|
||||||
|
const supabase = getContext<SupabaseClient<Database>>("supabase"); |
||||||
|
|
||||||
|
let organizations = $state(data.organizations); |
||||||
|
let showCreateModal = $state(false); |
||||||
|
let newOrgName = $state(""); |
||||||
|
let creating = $state(false); |
||||||
|
|
||||||
|
async function handleCreateOrg() { |
||||||
|
if (!newOrgName.trim() || creating) return; |
||||||
|
|
||||||
|
creating = true; |
||||||
|
try { |
||||||
|
const slug = generateSlug(newOrgName); |
||||||
|
|
||||||
|
const newOrg = await createOrganization(supabase, newOrgName, slug); |
||||||
|
organizations = [...organizations, { ...newOrg, role: "owner" }]; |
||||||
|
|
||||||
|
showCreateModal = false; |
||||||
|
newOrgName = ""; |
||||||
|
} catch (error) { |
||||||
|
console.error("Failed to create organization:", error); |
||||||
|
} finally { |
||||||
|
creating = false; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="min-h-screen bg-dark"> |
||||||
|
<header class="border-b border-light/10 bg-surface"> |
||||||
|
<div |
||||||
|
class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between" |
||||||
|
> |
||||||
|
<h1 class="text-xl font-bold text-light">Root Org</h1> |
||||||
|
<div class="flex items-center gap-4"> |
||||||
|
<a href="/style" class="text-sm text-light/60 hover:text-light" |
||||||
|
>Style Guide</a |
||||||
|
> |
||||||
|
<form method="POST" action="/auth/logout"> |
||||||
|
<Button variant="ghost" size="sm" type="submit" |
||||||
|
>Sign Out</Button |
||||||
|
> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</header> |
||||||
|
|
||||||
|
<main class="max-w-6xl mx-auto px-6 py-8"> |
||||||
|
<div class="flex items-center justify-between mb-8"> |
||||||
|
<div> |
||||||
|
<h2 class="text-2xl font-bold text-light"> |
||||||
|
Your Organizations |
||||||
|
</h2> |
||||||
|
<p class="text-light/50 mt-1"> |
||||||
|
Select an organization to get started |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
<Button onclick={() => (showCreateModal = true)}> |
||||||
|
<svg |
||||||
|
class="w-4 h-4 mr-2" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /> |
||||||
|
<line x1="5" y1="12" x2="19" y2="12" /> |
||||||
|
</svg> |
||||||
|
New Organization |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if organizations.length === 0} |
||||||
|
<Card> |
||||||
|
<div class="p-12 text-center"> |
||||||
|
<svg |
||||||
|
class="w-16 h-16 mx-auto mb-4 text-light/30" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="1.5" |
||||||
|
> |
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /> |
||||||
|
<circle cx="9" cy="7" r="4" /> |
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /> |
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" /> |
||||||
|
</svg> |
||||||
|
<h3 class="text-lg font-medium text-light mb-2"> |
||||||
|
No organizations yet |
||||||
|
</h3> |
||||||
|
<p class="text-light/50 mb-6"> |
||||||
|
Create your first organization to start collaborating |
||||||
|
</p> |
||||||
|
<Button onclick={() => (showCreateModal = true)} |
||||||
|
>Create Organization</Button |
||||||
|
> |
||||||
|
</div> |
||||||
|
</Card> |
||||||
|
{:else} |
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> |
||||||
|
{#each organizations as org} |
||||||
|
<a href="/{org.slug}" class="block group"> |
||||||
|
<Card |
||||||
|
class="h-full hover:ring-1 hover:ring-primary/50 transition-all" |
||||||
|
> |
||||||
|
<div class="p-6"> |
||||||
|
<div |
||||||
|
class="flex items-start justify-between mb-4" |
||||||
|
> |
||||||
|
<div |
||||||
|
class="w-12 h-12 bg-primary/20 rounded-xl flex items-center justify-center text-primary font-bold text-lg" |
||||||
|
> |
||||||
|
{org.name.charAt(0).toUpperCase()} |
||||||
|
</div> |
||||||
|
<span |
||||||
|
class="text-xs px-2 py-1 bg-light/10 rounded text-light/60 capitalize" |
||||||
|
> |
||||||
|
{org.role} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<h3 |
||||||
|
class="text-lg font-semibold text-light group-hover:text-primary transition-colors" |
||||||
|
> |
||||||
|
{org.name} |
||||||
|
</h3> |
||||||
|
<p class="text-sm text-light/40 mt-1"> |
||||||
|
/{org.slug} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</Card> |
||||||
|
</a> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Modal |
||||||
|
isOpen={showCreateModal} |
||||||
|
onClose={() => (showCreateModal = false)} |
||||||
|
title="Create Organization" |
||||||
|
> |
||||||
|
<div class="space-y-4"> |
||||||
|
<Input |
||||||
|
label="Organization Name" |
||||||
|
bind:value={newOrgName} |
||||||
|
placeholder="e.g. Acme Inc" |
||||||
|
/> |
||||||
|
{#if newOrgName} |
||||||
|
<p class="text-sm text-light/50"> |
||||||
|
URL: <span class="text-light/70" |
||||||
|
>/{generateSlug(newOrgName)}</span |
||||||
|
> |
||||||
|
</p> |
||||||
|
{/if} |
||||||
|
<div class="flex justify-end gap-2 pt-2"> |
||||||
|
<Button variant="ghost" onclick={() => (showCreateModal = false)} |
||||||
|
>Cancel</Button |
||||||
|
> |
||||||
|
<Button |
||||||
|
onclick={handleCreateOrg} |
||||||
|
disabled={!newOrgName.trim() || creating} |
||||||
|
> |
||||||
|
{creating ? "Creating..." : "Create"} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
import { error } from '@sveltejs/kit'; |
||||||
|
import type { LayoutServerLoad } from './$types'; |
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ params, locals }) => { |
||||||
|
const { session, user } = await locals.safeGetSession(); |
||||||
|
|
||||||
|
if (!session || !user) { |
||||||
|
error(401, 'Unauthorized'); |
||||||
|
} |
||||||
|
|
||||||
|
const { data: org, error: orgError } = await locals.supabase |
||||||
|
.from('organizations') |
||||||
|
.select('*') |
||||||
|
.eq('slug', params.orgSlug) |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (orgError || !org) { |
||||||
|
error(404, 'Organization not found'); |
||||||
|
} |
||||||
|
|
||||||
|
const { data: membership } = await locals.supabase |
||||||
|
.from('org_members') |
||||||
|
.select('role') |
||||||
|
.eq('org_id', org.id) |
||||||
|
.eq('user_id', user.id) |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (!membership) { |
||||||
|
error(403, 'You are not a member of this organization'); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
org, |
||||||
|
role: membership.role |
||||||
|
}; |
||||||
|
}; |
||||||
@ -0,0 +1,151 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { page } from "$app/stores"; |
||||||
|
import type { Snippet } from "svelte"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
data: { |
||||||
|
org: { id: string; name: string; slug: string }; |
||||||
|
role: string; |
||||||
|
}; |
||||||
|
children: Snippet; |
||||||
|
} |
||||||
|
|
||||||
|
let { data, children }: Props = $props(); |
||||||
|
|
||||||
|
const navItems = [ |
||||||
|
{ href: `/${data.org.slug}`, label: "Overview", icon: "home" }, |
||||||
|
{ |
||||||
|
href: `/${data.org.slug}/documents`, |
||||||
|
label: "Documents", |
||||||
|
icon: "file", |
||||||
|
}, |
||||||
|
{ href: `/${data.org.slug}/kanban`, label: "Kanban", icon: "kanban" }, |
||||||
|
{ |
||||||
|
href: `/${data.org.slug}/calendar`, |
||||||
|
label: "Calendar", |
||||||
|
icon: "calendar", |
||||||
|
}, |
||||||
|
{ |
||||||
|
href: `/${data.org.slug}/settings`, |
||||||
|
label: "Settings", |
||||||
|
icon: "settings", |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
function isActive(href: string): boolean { |
||||||
|
return $page.url.pathname === href; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex h-screen bg-dark"> |
||||||
|
<aside class="w-64 bg-surface border-r border-light/10 flex flex-col"> |
||||||
|
<div class="p-4 border-b border-light/10"> |
||||||
|
<h1 class="text-lg font-semibold text-light truncate"> |
||||||
|
{data.org.name} |
||||||
|
</h1> |
||||||
|
<p class="text-xs text-light/50 capitalize">{data.role}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<nav class="flex-1 p-2 space-y-1"> |
||||||
|
{#each navItems as item} |
||||||
|
<a |
||||||
|
href={item.href} |
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors {isActive( |
||||||
|
item.href, |
||||||
|
) |
||||||
|
? 'bg-primary text-white' |
||||||
|
: 'text-light/70 hover:bg-light/5 hover:text-light'}" |
||||||
|
> |
||||||
|
{#if item.icon === "home"} |
||||||
|
<svg |
||||||
|
class="w-5 h-5" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<path |
||||||
|
d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" |
||||||
|
/> |
||||||
|
<polyline points="9,22 9,12 15,12 15,22" /> |
||||||
|
</svg> |
||||||
|
{:else if item.icon === "file"} |
||||||
|
<svg |
||||||
|
class="w-5 h-5" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<path |
||||||
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" |
||||||
|
/> |
||||||
|
<polyline points="14,2 14,8 20,8" /> |
||||||
|
</svg> |
||||||
|
{:else if item.icon === "kanban"} |
||||||
|
<svg |
||||||
|
class="w-5 h-5" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" /> |
||||||
|
<line x1="9" y1="3" x2="9" y2="21" /> |
||||||
|
<line x1="15" y1="3" x2="15" y2="21" /> |
||||||
|
</svg> |
||||||
|
{:else if item.icon === "calendar"} |
||||||
|
<svg |
||||||
|
class="w-5 h-5" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" /> |
||||||
|
<line x1="16" y1="2" x2="16" y2="6" /> |
||||||
|
<line x1="8" y1="2" x2="8" y2="6" /> |
||||||
|
<line x1="3" y1="10" x2="21" y2="10" /> |
||||||
|
</svg> |
||||||
|
{:else if item.icon === "settings"} |
||||||
|
<svg |
||||||
|
class="w-5 h-5" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<circle cx="12" cy="12" r="3" /> |
||||||
|
<path |
||||||
|
d="M12 1v2m0 18v2M4.2 4.2l1.4 1.4m12.8 12.8l1.4 1.4M1 12h2m18 0h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
{/if} |
||||||
|
{item.label} |
||||||
|
</a> |
||||||
|
{/each} |
||||||
|
</nav> |
||||||
|
|
||||||
|
<div class="p-4 border-t border-light/10"> |
||||||
|
<a |
||||||
|
href="/" |
||||||
|
class="flex items-center gap-2 text-sm text-light/50 hover:text-light transition-colors" |
||||||
|
> |
||||||
|
<svg |
||||||
|
class="w-4 h-4" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<path d="m15 18-6-6 6-6" /> |
||||||
|
</svg> |
||||||
|
All Organizations |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</aside> |
||||||
|
|
||||||
|
<main class="flex-1 overflow-auto"> |
||||||
|
{@render children()} |
||||||
|
</main> |
||||||
|
</div> |
||||||
@ -0,0 +1,68 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Card } from '$lib/components/ui'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
data: { |
||||||
|
org: { id: string; name: string; slug: string }; |
||||||
|
role: string; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
let { data }: Props = $props(); |
||||||
|
|
||||||
|
const quickLinks = [ |
||||||
|
{ href: `/${data.org.slug}/documents`, label: 'Documents', description: 'Collaborative docs and files', icon: 'file' }, |
||||||
|
{ href: `/${data.org.slug}/kanban`, label: 'Kanban', description: 'Track tasks and projects', icon: 'kanban' }, |
||||||
|
{ href: `/${data.org.slug}/calendar`, label: 'Calendar', description: 'Schedule events and meetings', icon: 'calendar' } |
||||||
|
]; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="p-8"> |
||||||
|
<header class="mb-8"> |
||||||
|
<h1 class="text-3xl font-bold text-light">{data.org.name}</h1> |
||||||
|
<p class="text-light/50 mt-1">Organization Overview</p> |
||||||
|
</header> |
||||||
|
|
||||||
|
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> |
||||||
|
{#each quickLinks as link} |
||||||
|
<a href={link.href} class="block group"> |
||||||
|
<Card class="h-full hover:ring-1 hover:ring-primary/50 transition-all"> |
||||||
|
<div class="p-6"> |
||||||
|
<div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors"> |
||||||
|
{#if link.icon === 'file'} |
||||||
|
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> |
||||||
|
<polyline points="14,2 14,8 20,8" /> |
||||||
|
</svg> |
||||||
|
{:else if link.icon === 'kanban'} |
||||||
|
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" /> |
||||||
|
<line x1="9" y1="3" x2="9" y2="21" /> |
||||||
|
<line x1="15" y1="3" x2="15" y2="21" /> |
||||||
|
</svg> |
||||||
|
{:else if link.icon === 'calendar'} |
||||||
|
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" /> |
||||||
|
<line x1="16" y1="2" x2="16" y2="6" /> |
||||||
|
<line x1="8" y1="2" x2="8" y2="6" /> |
||||||
|
<line x1="3" y1="10" x2="21" y2="10" /> |
||||||
|
</svg> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
<h3 class="text-lg font-semibold text-light mb-1">{link.label}</h3> |
||||||
|
<p class="text-sm text-light/50">{link.description}</p> |
||||||
|
</div> |
||||||
|
</Card> |
||||||
|
</a> |
||||||
|
{/each} |
||||||
|
</section> |
||||||
|
|
||||||
|
<section> |
||||||
|
<h2 class="text-xl font-semibold text-light mb-4">Recent Activity</h2> |
||||||
|
<Card> |
||||||
|
<div class="p-6 text-center text-light/50"> |
||||||
|
<p>No recent activity to show</p> |
||||||
|
</div> |
||||||
|
</Card> |
||||||
|
</section> |
||||||
|
</div> |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
import type { PageServerLoad } from './$types'; |
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ parent, locals }) => { |
||||||
|
const { org } = await parent(); |
||||||
|
const { supabase } = locals; |
||||||
|
|
||||||
|
// Fetch events for current month ± 1 month
|
||||||
|
const now = new Date(); |
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); |
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth() + 2, 0); |
||||||
|
|
||||||
|
const { data: events } = await supabase |
||||||
|
.from('calendar_events') |
||||||
|
.select('*') |
||||||
|
.eq('org_id', org.id) |
||||||
|
.gte('start_time', startDate.toISOString()) |
||||||
|
.lte('end_time', endDate.toISOString()) |
||||||
|
.order('start_time'); |
||||||
|
|
||||||
|
return { |
||||||
|
events: events ?? [] |
||||||
|
}; |
||||||
|
}; |
||||||
@ -0,0 +1,239 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { getContext } from "svelte"; |
||||||
|
import { Button, Modal, Input, Textarea } from "$lib/components/ui"; |
||||||
|
import { Calendar } from "$lib/components/calendar"; |
||||||
|
import { createEvent } from "$lib/api/calendar"; |
||||||
|
import type { CalendarEvent } from "$lib/supabase/types"; |
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js"; |
||||||
|
import type { Database } from "$lib/supabase/types"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
data: { |
||||||
|
org: { id: string; name: string; slug: string }; |
||||||
|
events: CalendarEvent[]; |
||||||
|
user: { id: string } | null; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
let { data }: Props = $props(); |
||||||
|
|
||||||
|
const supabase = getContext<SupabaseClient<Database>>("supabase"); |
||||||
|
|
||||||
|
let events = $state(data.events); |
||||||
|
let showCreateModal = $state(false); |
||||||
|
let showEventModal = $state(false); |
||||||
|
let selectedEvent = $state<CalendarEvent | null>(null); |
||||||
|
let selectedDate = $state<Date | null>(null); |
||||||
|
|
||||||
|
let newEvent = $state({ |
||||||
|
title: "", |
||||||
|
description: "", |
||||||
|
date: "", |
||||||
|
startTime: "09:00", |
||||||
|
endTime: "10:00", |
||||||
|
allDay: false, |
||||||
|
color: "#6366f1", |
||||||
|
}); |
||||||
|
|
||||||
|
const colorOptions = [ |
||||||
|
{ value: "#6366f1", label: "Indigo" }, |
||||||
|
{ value: "#ec4899", label: "Pink" }, |
||||||
|
{ value: "#10b981", label: "Green" }, |
||||||
|
{ value: "#f59e0b", label: "Amber" }, |
||||||
|
{ value: "#ef4444", label: "Red" }, |
||||||
|
{ value: "#8b5cf6", label: "Purple" }, |
||||||
|
]; |
||||||
|
|
||||||
|
function handleDateClick(date: Date) { |
||||||
|
selectedDate = date; |
||||||
|
newEvent.date = date.toISOString().split("T")[0]; |
||||||
|
showCreateModal = true; |
||||||
|
} |
||||||
|
|
||||||
|
function handleEventClick(event: CalendarEvent) { |
||||||
|
selectedEvent = event; |
||||||
|
showEventModal = true; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleCreateEvent() { |
||||||
|
if (!newEvent.title.trim() || !newEvent.date || !data.user) return; |
||||||
|
|
||||||
|
const startTime = newEvent.allDay |
||||||
|
? `${newEvent.date}T00:00:00` |
||||||
|
: `${newEvent.date}T${newEvent.startTime}:00`; |
||||||
|
const endTime = newEvent.allDay |
||||||
|
? `${newEvent.date}T23:59:59` |
||||||
|
: `${newEvent.date}T${newEvent.endTime}:00`; |
||||||
|
|
||||||
|
const created = await createEvent( |
||||||
|
supabase, |
||||||
|
data.org.id, |
||||||
|
{ |
||||||
|
title: newEvent.title, |
||||||
|
description: newEvent.description || undefined, |
||||||
|
start_time: startTime, |
||||||
|
end_time: endTime, |
||||||
|
all_day: newEvent.allDay, |
||||||
|
color: newEvent.color, |
||||||
|
}, |
||||||
|
data.user.id, |
||||||
|
); |
||||||
|
|
||||||
|
events = [...events, created]; |
||||||
|
resetForm(); |
||||||
|
} |
||||||
|
|
||||||
|
function resetForm() { |
||||||
|
showCreateModal = false; |
||||||
|
newEvent = { |
||||||
|
title: "", |
||||||
|
description: "", |
||||||
|
date: "", |
||||||
|
startTime: "09:00", |
||||||
|
endTime: "10:00", |
||||||
|
allDay: false, |
||||||
|
color: "#6366f1", |
||||||
|
}; |
||||||
|
selectedDate = null; |
||||||
|
} |
||||||
|
|
||||||
|
function formatEventTime(event: CalendarEvent): string { |
||||||
|
if (event.all_day) return "All day"; |
||||||
|
const start = new Date(event.start_time); |
||||||
|
const end = new Date(event.end_time); |
||||||
|
return `${start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="p-6 h-full overflow-auto"> |
||||||
|
<header class="flex items-center justify-between mb-6"> |
||||||
|
<h1 class="text-2xl font-bold text-light">Calendar</h1> |
||||||
|
<Button onclick={() => (showCreateModal = true)}> |
||||||
|
<svg |
||||||
|
class="w-4 h-4 mr-2" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /> |
||||||
|
<line x1="5" y1="12" x2="19" y2="12" /> |
||||||
|
</svg> |
||||||
|
New Event |
||||||
|
</Button> |
||||||
|
</header> |
||||||
|
|
||||||
|
<Calendar |
||||||
|
{events} |
||||||
|
onDateClick={handleDateClick} |
||||||
|
onEventClick={handleEventClick} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Modal isOpen={showCreateModal} onClose={resetForm} title="Create Event"> |
||||||
|
<div class="space-y-4"> |
||||||
|
<Input |
||||||
|
label="Title" |
||||||
|
bind:value={newEvent.title} |
||||||
|
placeholder="Event title" |
||||||
|
/> |
||||||
|
|
||||||
|
<Textarea |
||||||
|
label="Description" |
||||||
|
bind:value={newEvent.description} |
||||||
|
placeholder="Optional description" |
||||||
|
rows={2} |
||||||
|
/> |
||||||
|
|
||||||
|
<Input label="Date" type="date" bind:value={newEvent.date} /> |
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-light"> |
||||||
|
<input |
||||||
|
type="checkbox" |
||||||
|
bind:checked={newEvent.allDay} |
||||||
|
class="rounded" |
||||||
|
/> |
||||||
|
All day event |
||||||
|
</label> |
||||||
|
|
||||||
|
{#if !newEvent.allDay} |
||||||
|
<div class="grid grid-cols-2 gap-4"> |
||||||
|
<Input |
||||||
|
label="Start Time" |
||||||
|
type="time" |
||||||
|
bind:value={newEvent.startTime} |
||||||
|
/> |
||||||
|
<Input |
||||||
|
label="End Time" |
||||||
|
type="time" |
||||||
|
bind:value={newEvent.endTime} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div> |
||||||
|
<label class="block text-sm font-medium text-light mb-2" |
||||||
|
>Color</label |
||||||
|
> |
||||||
|
<div class="flex gap-2"> |
||||||
|
{#each colorOptions as color} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="w-8 h-8 rounded-full transition-transform" |
||||||
|
class:ring-2={newEvent.color === color.value} |
||||||
|
class:ring-white={newEvent.color === color.value} |
||||||
|
class:scale-110={newEvent.color === color.value} |
||||||
|
style="background-color: {color.value}" |
||||||
|
onclick={() => (newEvent.color = color.value)} |
||||||
|
aria-label={color.label} |
||||||
|
></button> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2"> |
||||||
|
<Button variant="ghost" onclick={resetForm}>Cancel</Button> |
||||||
|
<Button |
||||||
|
onclick={handleCreateEvent} |
||||||
|
disabled={!newEvent.title.trim() || !newEvent.date} |
||||||
|
>Create</Button |
||||||
|
> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
|
||||||
|
<Modal |
||||||
|
isOpen={showEventModal} |
||||||
|
onClose={() => (showEventModal = false)} |
||||||
|
title={selectedEvent?.title ?? "Event"} |
||||||
|
> |
||||||
|
{#if selectedEvent} |
||||||
|
<div class="space-y-3"> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<div |
||||||
|
class="w-3 h-3 rounded-full" |
||||||
|
style="background-color: {selectedEvent.color ?? '#6366f1'}" |
||||||
|
></div> |
||||||
|
<span class="text-light/70" |
||||||
|
>{formatEventTime(selectedEvent)}</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if selectedEvent.description} |
||||||
|
<p class="text-light/80">{selectedEvent.description}</p> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<p class="text-xs text-light/40"> |
||||||
|
{new Date(selectedEvent.start_time).toLocaleDateString( |
||||||
|
undefined, |
||||||
|
{ |
||||||
|
weekday: "long", |
||||||
|
year: "numeric", |
||||||
|
month: "long", |
||||||
|
day: "numeric", |
||||||
|
}, |
||||||
|
)} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</Modal> |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
import type { PageServerLoad } from './$types'; |
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ parent, locals }) => { |
||||||
|
const { org } = await parent(); |
||||||
|
const { supabase } = locals; |
||||||
|
|
||||||
|
const { data: documents } = await supabase |
||||||
|
.from('documents') |
||||||
|
.select('*') |
||||||
|
.eq('org_id', org.id) |
||||||
|
.order('name'); |
||||||
|
|
||||||
|
return { |
||||||
|
documents: documents ?? [] |
||||||
|
}; |
||||||
|
}; |
||||||
@ -0,0 +1,212 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { getContext } from "svelte"; |
||||||
|
import { Button, Modal, Input } from "$lib/components/ui"; |
||||||
|
import { FileTree, Editor } from "$lib/components/documents"; |
||||||
|
import { buildDocumentTree } from "$lib/api/documents"; |
||||||
|
import type { Document } from "$lib/supabase/types"; |
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js"; |
||||||
|
import type { Database } from "$lib/supabase/types"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
data: { |
||||||
|
org: { id: string; name: string; slug: string }; |
||||||
|
documents: Document[]; |
||||||
|
user: { id: string } | null; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
let { data }: Props = $props(); |
||||||
|
|
||||||
|
const supabase = getContext<SupabaseClient<Database>>("supabase"); |
||||||
|
|
||||||
|
let documents = $state(data.documents); |
||||||
|
let selectedDoc = $state<Document | null>(null); |
||||||
|
let showCreateModal = $state(false); |
||||||
|
let newDocName = $state(""); |
||||||
|
let newDocType = $state<"folder" | "document">("document"); |
||||||
|
let parentFolderId = $state<string | null>(null); |
||||||
|
|
||||||
|
const documentTree = $derived(buildDocumentTree(documents)); |
||||||
|
|
||||||
|
function handleSelect(doc: Document) { |
||||||
|
if (doc.type === "document") { |
||||||
|
selectedDoc = doc; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleAdd(folderId: string | null) { |
||||||
|
parentFolderId = folderId; |
||||||
|
showCreateModal = true; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleMove(docId: string, newParentId: string | null) { |
||||||
|
const { error } = await supabase |
||||||
|
.from("documents") |
||||||
|
.update({ |
||||||
|
parent_id: newParentId, |
||||||
|
updated_at: new Date().toISOString(), |
||||||
|
}) |
||||||
|
.eq("id", docId); |
||||||
|
|
||||||
|
if (!error) { |
||||||
|
documents = documents.map((d) => |
||||||
|
d.id === docId ? { ...d, parent_id: newParentId } : d, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function handleCreate() { |
||||||
|
if (!newDocName.trim() || !data.user) return; |
||||||
|
|
||||||
|
const { data: newDoc, error } = await supabase |
||||||
|
.from("documents") |
||||||
|
.insert({ |
||||||
|
org_id: data.org.id, |
||||||
|
name: newDocName, |
||||||
|
type: newDocType, |
||||||
|
parent_id: parentFolderId, |
||||||
|
created_by: data.user.id, |
||||||
|
content: |
||||||
|
newDocType === "document" |
||||||
|
? { type: "doc", content: [] } |
||||||
|
: null, |
||||||
|
}) |
||||||
|
.select() |
||||||
|
.single(); |
||||||
|
|
||||||
|
if (!error && newDoc) { |
||||||
|
documents = [...documents, newDoc]; |
||||||
|
if (newDocType === "document") { |
||||||
|
selectedDoc = newDoc; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
showCreateModal = false; |
||||||
|
newDocName = ""; |
||||||
|
newDocType = "document"; |
||||||
|
parentFolderId = null; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleSave(content: unknown) { |
||||||
|
if (!selectedDoc) return; |
||||||
|
|
||||||
|
await supabase |
||||||
|
.from("documents") |
||||||
|
.update({ content, updated_at: new Date().toISOString() }) |
||||||
|
.eq("id", selectedDoc.id); |
||||||
|
|
||||||
|
documents = documents.map((d) => |
||||||
|
d.id === selectedDoc!.id ? { ...d, content } : d, |
||||||
|
); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex h-full"> |
||||||
|
<aside class="w-72 border-r border-light/10 flex flex-col"> |
||||||
|
<div |
||||||
|
class="p-4 border-b border-light/10 flex items-center justify-between" |
||||||
|
> |
||||||
|
<h2 class="font-semibold text-light">Documents</h2> |
||||||
|
<Button size="sm" onclick={() => (showCreateModal = true)}> |
||||||
|
<svg |
||||||
|
class="w-4 h-4 mr-1" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /> |
||||||
|
<line x1="5" y1="12" x2="19" y2="12" /> |
||||||
|
</svg> |
||||||
|
New |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-2"> |
||||||
|
{#if documentTree.length === 0} |
||||||
|
<div class="text-center text-light/40 py-8 text-sm"> |
||||||
|
<p>No documents yet</p> |
||||||
|
<p class="mt-1">Create your first document</p> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<FileTree |
||||||
|
items={documentTree} |
||||||
|
selectedId={selectedDoc?.id ?? null} |
||||||
|
onSelect={handleSelect} |
||||||
|
onAdd={handleAdd} |
||||||
|
onMove={handleMove} |
||||||
|
/> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</aside> |
||||||
|
|
||||||
|
<main class="flex-1 overflow-hidden"> |
||||||
|
{#if selectedDoc} |
||||||
|
<Editor document={selectedDoc} onSave={handleSave} /> |
||||||
|
{:else} |
||||||
|
<div class="h-full flex items-center justify-center text-light/40"> |
||||||
|
<div class="text-center"> |
||||||
|
<svg |
||||||
|
class="w-16 h-16 mx-auto mb-4 opacity-50" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="1.5" |
||||||
|
> |
||||||
|
<path |
||||||
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" |
||||||
|
/> |
||||||
|
<polyline points="14,2 14,8 20,8" /> |
||||||
|
</svg> |
||||||
|
<p>Select a document to edit</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Modal |
||||||
|
isOpen={showCreateModal} |
||||||
|
onClose={() => (showCreateModal = false)} |
||||||
|
title="Create New" |
||||||
|
> |
||||||
|
<div class="space-y-4"> |
||||||
|
<div class="flex gap-2"> |
||||||
|
<button |
||||||
|
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType === |
||||||
|
'document' |
||||||
|
? 'border-primary bg-primary/10' |
||||||
|
: 'border-light/20'}" |
||||||
|
onclick={() => (newDocType = "document")} |
||||||
|
> |
||||||
|
Document |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType === |
||||||
|
'folder' |
||||||
|
? 'border-primary bg-primary/10' |
||||||
|
: 'border-light/20'}" |
||||||
|
onclick={() => (newDocType = "folder")} |
||||||
|
> |
||||||
|
Folder |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Input |
||||||
|
label="Name" |
||||||
|
bind:value={newDocName} |
||||||
|
placeholder={newDocType === "folder" |
||||||
|
? "Folder name" |
||||||
|
: "Document name"} |
||||||
|
/> |
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2"> |
||||||
|
<Button variant="ghost" onclick={() => (showCreateModal = false)} |
||||||
|
>Cancel</Button |
||||||
|
> |
||||||
|
<Button onclick={handleCreate} disabled={!newDocName.trim()} |
||||||
|
>Create</Button |
||||||
|
> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
import type { PageServerLoad } from './$types'; |
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ parent, locals }) => { |
||||||
|
const { org } = await parent(); |
||||||
|
const { supabase } = locals; |
||||||
|
|
||||||
|
const { data: boards } = await supabase |
||||||
|
.from('kanban_boards') |
||||||
|
.select('*') |
||||||
|
.eq('org_id', org.id) |
||||||
|
.order('created_at'); |
||||||
|
|
||||||
|
return { |
||||||
|
boards: boards ?? [] |
||||||
|
}; |
||||||
|
}; |
||||||
@ -0,0 +1,256 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { getContext } from "svelte"; |
||||||
|
import { Button, Card, Modal, Input } from "$lib/components/ui"; |
||||||
|
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban"; |
||||||
|
import { |
||||||
|
fetchBoardWithColumns, |
||||||
|
createBoard, |
||||||
|
createCard, |
||||||
|
moveCard, |
||||||
|
} from "$lib/api/kanban"; |
||||||
|
import type { |
||||||
|
KanbanBoard as KanbanBoardType, |
||||||
|
KanbanCard, |
||||||
|
} from "$lib/supabase/types"; |
||||||
|
import type { BoardWithColumns } from "$lib/api/kanban"; |
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js"; |
||||||
|
import type { Database } from "$lib/supabase/types"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
data: { |
||||||
|
org: { id: string; name: string; slug: string }; |
||||||
|
boards: KanbanBoardType[]; |
||||||
|
user: { id: string } | null; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
let { data }: Props = $props(); |
||||||
|
|
||||||
|
const supabase = getContext<SupabaseClient<Database>>("supabase"); |
||||||
|
|
||||||
|
let boards = $state(data.boards); |
||||||
|
let selectedBoard = $state<BoardWithColumns | null>(null); |
||||||
|
let showCreateBoardModal = $state(false); |
||||||
|
let showCreateCardModal = $state(false); |
||||||
|
let showCardDetailModal = $state(false); |
||||||
|
let selectedCard = $state<KanbanCard | null>(null); |
||||||
|
let newBoardName = $state(""); |
||||||
|
let newCardTitle = $state(""); |
||||||
|
let targetColumnId = $state<string | null>(null); |
||||||
|
|
||||||
|
async function loadBoard(boardId: string) { |
||||||
|
selectedBoard = await fetchBoardWithColumns(supabase, boardId); |
||||||
|
} |
||||||
|
|
||||||
|
async function handleCreateBoard() { |
||||||
|
if (!newBoardName.trim()) return; |
||||||
|
|
||||||
|
const newBoard = await createBoard(supabase, data.org.id, newBoardName); |
||||||
|
boards = [...boards, newBoard]; |
||||||
|
await loadBoard(newBoard.id); |
||||||
|
|
||||||
|
showCreateBoardModal = false; |
||||||
|
newBoardName = ""; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleAddCard(columnId: string) { |
||||||
|
targetColumnId = columnId; |
||||||
|
showCreateCardModal = true; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleCreateCard() { |
||||||
|
if ( |
||||||
|
!newCardTitle.trim() || |
||||||
|
!targetColumnId || |
||||||
|
!selectedBoard || |
||||||
|
!data.user |
||||||
|
) |
||||||
|
return; |
||||||
|
|
||||||
|
const column = selectedBoard.columns.find( |
||||||
|
(c) => c.id === targetColumnId, |
||||||
|
); |
||||||
|
const position = column?.cards.length ?? 0; |
||||||
|
|
||||||
|
await createCard( |
||||||
|
supabase, |
||||||
|
targetColumnId, |
||||||
|
newCardTitle, |
||||||
|
position, |
||||||
|
data.user.id, |
||||||
|
); |
||||||
|
await loadBoard(selectedBoard.id); |
||||||
|
|
||||||
|
showCreateCardModal = false; |
||||||
|
newCardTitle = ""; |
||||||
|
targetColumnId = null; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleCardMove( |
||||||
|
cardId: string, |
||||||
|
toColumnId: string, |
||||||
|
toPosition: number, |
||||||
|
) { |
||||||
|
if (!selectedBoard) return; |
||||||
|
|
||||||
|
await moveCard(supabase, cardId, toColumnId, toPosition); |
||||||
|
await loadBoard(selectedBoard.id); |
||||||
|
} |
||||||
|
|
||||||
|
function handleCardClick(card: KanbanCard) { |
||||||
|
selectedCard = card; |
||||||
|
showCardDetailModal = true; |
||||||
|
} |
||||||
|
|
||||||
|
function handleCardUpdate(updatedCard: KanbanCard) { |
||||||
|
if (!selectedBoard) return; |
||||||
|
selectedBoard = { |
||||||
|
...selectedBoard, |
||||||
|
columns: selectedBoard.columns.map((col) => ({ |
||||||
|
...col, |
||||||
|
cards: col.cards.map((c) => |
||||||
|
c.id === updatedCard.id ? updatedCard : c, |
||||||
|
), |
||||||
|
})), |
||||||
|
}; |
||||||
|
selectedCard = updatedCard; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleCardDelete(cardId: string) { |
||||||
|
if (!selectedBoard) return; |
||||||
|
await loadBoard(selectedBoard.id); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex h-full"> |
||||||
|
<aside class="w-64 border-r border-light/10 flex flex-col"> |
||||||
|
<div |
||||||
|
class="p-4 border-b border-light/10 flex items-center justify-between" |
||||||
|
> |
||||||
|
<h2 class="font-semibold text-light">Boards</h2> |
||||||
|
<Button size="sm" onclick={() => (showCreateBoardModal = true)}> |
||||||
|
<svg |
||||||
|
class="w-4 h-4" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
> |
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /> |
||||||
|
<line x1="5" y1="12" x2="19" y2="12" /> |
||||||
|
</svg> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-2 space-y-1"> |
||||||
|
{#if boards.length === 0} |
||||||
|
<div class="text-center text-light/40 py-8 text-sm"> |
||||||
|
<p>No boards yet</p> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
{#each boards as board} |
||||||
|
<button |
||||||
|
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {selectedBoard?.id === |
||||||
|
board.id |
||||||
|
? 'bg-primary text-white' |
||||||
|
: 'text-light/70 hover:bg-light/5'}" |
||||||
|
onclick={() => loadBoard(board.id)} |
||||||
|
> |
||||||
|
{board.name} |
||||||
|
</button> |
||||||
|
{/each} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</aside> |
||||||
|
|
||||||
|
<main class="flex-1 overflow-hidden p-6"> |
||||||
|
{#if selectedBoard} |
||||||
|
<header class="mb-6"> |
||||||
|
<h1 class="text-2xl font-bold text-light"> |
||||||
|
{selectedBoard.name} |
||||||
|
</h1> |
||||||
|
</header> |
||||||
|
|
||||||
|
<KanbanBoard |
||||||
|
columns={selectedBoard.columns} |
||||||
|
onCardClick={handleCardClick} |
||||||
|
onCardMove={handleCardMove} |
||||||
|
onAddCard={handleAddCard} |
||||||
|
/> |
||||||
|
{:else} |
||||||
|
<div class="h-full flex items-center justify-center text-light/40"> |
||||||
|
<div class="text-center"> |
||||||
|
<svg |
||||||
|
class="w-16 h-16 mx-auto mb-4 opacity-50" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="1.5" |
||||||
|
> |
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" /> |
||||||
|
<line x1="9" y1="3" x2="9" y2="21" /> |
||||||
|
<line x1="15" y1="3" x2="15" y2="21" /> |
||||||
|
</svg> |
||||||
|
<p>Select a board or create a new one</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Modal |
||||||
|
isOpen={showCreateBoardModal} |
||||||
|
onClose={() => (showCreateBoardModal = false)} |
||||||
|
title="Create Board" |
||||||
|
> |
||||||
|
<div class="space-y-4"> |
||||||
|
<Input |
||||||
|
label="Board Name" |
||||||
|
bind:value={newBoardName} |
||||||
|
placeholder="e.g. Sprint 1" |
||||||
|
/> |
||||||
|
<div class="flex justify-end gap-2"> |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
onclick={() => (showCreateBoardModal = false)}>Cancel</Button |
||||||
|
> |
||||||
|
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()} |
||||||
|
>Create</Button |
||||||
|
> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
|
||||||
|
<Modal |
||||||
|
isOpen={showCreateCardModal} |
||||||
|
onClose={() => (showCreateCardModal = false)} |
||||||
|
title="Add Card" |
||||||
|
> |
||||||
|
<div class="space-y-4"> |
||||||
|
<Input |
||||||
|
label="Title" |
||||||
|
bind:value={newCardTitle} |
||||||
|
placeholder="Card title" |
||||||
|
/> |
||||||
|
<div class="flex justify-end gap-2"> |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
onclick={() => (showCreateCardModal = false)}>Cancel</Button |
||||||
|
> |
||||||
|
<Button onclick={handleCreateCard} disabled={!newCardTitle.trim()} |
||||||
|
>Add</Button |
||||||
|
> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
|
||||||
|
<CardDetailModal |
||||||
|
card={selectedCard} |
||||||
|
isOpen={showCardDetailModal} |
||||||
|
onClose={() => { |
||||||
|
showCardDetailModal = false; |
||||||
|
selectedCard = null; |
||||||
|
}} |
||||||
|
onUpdate={handleCardUpdate} |
||||||
|
onDelete={handleCardDelete} |
||||||
|
/> |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
import { redirect } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { exchangeCodeForTokens } from '$lib/api/google-calendar'; |
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => { |
||||||
|
const code = url.searchParams.get('code'); |
||||||
|
const stateParam = url.searchParams.get('state'); |
||||||
|
const error = url.searchParams.get('error'); |
||||||
|
|
||||||
|
if (error || !code || !stateParam) { |
||||||
|
redirect(303, '/?error=google_auth_failed'); |
||||||
|
} |
||||||
|
|
||||||
|
let state: { orgSlug: string; userId: string }; |
||||||
|
try { |
||||||
|
state = JSON.parse(decodeURIComponent(stateParam)); |
||||||
|
} catch { |
||||||
|
redirect(303, '/?error=invalid_state'); |
||||||
|
} |
||||||
|
|
||||||
|
const redirectUri = `${url.origin}/api/google-calendar/callback`; |
||||||
|
|
||||||
|
try { |
||||||
|
const tokens = await exchangeCodeForTokens(code, redirectUri); |
||||||
|
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); |
||||||
|
|
||||||
|
// Store tokens in database
|
||||||
|
await locals.supabase |
||||||
|
.from('google_calendar_connections') |
||||||
|
.upsert({ |
||||||
|
user_id: state.userId, |
||||||
|
access_token: tokens.access_token, |
||||||
|
refresh_token: tokens.refresh_token, |
||||||
|
token_expires_at: expiresAt.toISOString(), |
||||||
|
updated_at: new Date().toISOString() |
||||||
|
}, { onConflict: 'user_id' }); |
||||||
|
|
||||||
|
redirect(303, `/${state.orgSlug}/calendar?connected=true`); |
||||||
|
} catch (err) { |
||||||
|
console.error('Google Calendar OAuth error:', err); |
||||||
|
redirect(303, `/${state.orgSlug}/calendar?error=token_exchange_failed`); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
import { redirect } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { getGoogleAuthUrl } from '$lib/api/google-calendar'; |
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => { |
||||||
|
const { session } = await locals.safeGetSession(); |
||||||
|
|
||||||
|
if (!session) { |
||||||
|
redirect(303, '/login'); |
||||||
|
} |
||||||
|
|
||||||
|
const orgSlug = url.searchParams.get('org'); |
||||||
|
if (!orgSlug) { |
||||||
|
redirect(303, '/'); |
||||||
|
} |
||||||
|
|
||||||
|
const redirectUri = `${url.origin}/api/google-calendar/callback`; |
||||||
|
const state = JSON.stringify({ orgSlug, userId: session.user.id }); |
||||||
|
const authUrl = getGoogleAuthUrl(redirectUri, encodeURIComponent(state)); |
||||||
|
|
||||||
|
redirect(303, authUrl); |
||||||
|
}; |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
import { redirect } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => { |
||||||
|
const code = url.searchParams.get('code'); |
||||||
|
const next = url.searchParams.get('next') ?? '/'; |
||||||
|
|
||||||
|
if (code) { |
||||||
|
const { error } = await locals.supabase.auth.exchangeCodeForSession(code); |
||||||
|
if (!error) { |
||||||
|
redirect(303, next); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
redirect(303, '/login?error=auth_callback_error'); |
||||||
|
}; |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
import { redirect } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ locals }) => { |
||||||
|
const { supabase } = locals; |
||||||
|
await supabase.auth.signOut(); |
||||||
|
redirect(303, '/login'); |
||||||
|
}; |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
import { json } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => { |
||||||
|
return json({ |
||||||
|
status: 'healthy', |
||||||
|
timestamp: new Date().toISOString() |
||||||
|
}); |
||||||
|
}; |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
@import 'tailwindcss'; |
||||||
|
@plugin '@tailwindcss/forms'; |
||||||
|
@plugin '@tailwindcss/typography'; |
||||||
|
|
||||||
|
@theme { |
||||||
|
/* Colors - Dark theme */ |
||||||
|
--color-dark: #0a0a0f; |
||||||
|
--color-surface: #14141f; |
||||||
|
--color-light: #f0f0f5; |
||||||
|
|
||||||
|
/* Brand */ |
||||||
|
--color-primary: #6366f1; |
||||||
|
--color-primary-hover: #4f46e5; |
||||||
|
|
||||||
|
/* Status */ |
||||||
|
--color-success: #22c55e; |
||||||
|
--color-warning: #f59e0b; |
||||||
|
--color-error: #ef4444; |
||||||
|
--color-info: #3b82f6; |
||||||
|
|
||||||
|
/* Font */ |
||||||
|
--font-sans: 'Inter', system-ui, -apple-system, sans-serif; |
||||||
|
} |
||||||
|
|
||||||
|
/* Base styles */ |
||||||
|
body { |
||||||
|
background-color: var(--color-dark); |
||||||
|
color: var(--color-light); |
||||||
|
font-family: var(--font-sans); |
||||||
|
} |
||||||
|
|
||||||
|
/* Scrollbar styling */ |
||||||
|
::-webkit-scrollbar { |
||||||
|
width: 8px; |
||||||
|
height: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
::-webkit-scrollbar-track { |
||||||
|
background: var(--color-dark); |
||||||
|
} |
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb { |
||||||
|
background: var(--color-light) / 0.2; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover { |
||||||
|
background: var(--color-light) / 0.3; |
||||||
|
} |
||||||
@ -0,0 +1,167 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Button, Input, Card } from "$lib/components/ui"; |
||||||
|
import { createClient } from "$lib/supabase"; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
|
||||||
|
let email = $state(""); |
||||||
|
let password = $state(""); |
||||||
|
let isLoading = $state(false); |
||||||
|
let error = $state(""); |
||||||
|
let mode = $state<"login" | "signup">("login"); |
||||||
|
|
||||||
|
const supabase = createClient(); |
||||||
|
|
||||||
|
async function handleSubmit() { |
||||||
|
if (!email || !password) { |
||||||
|
error = "Please fill in all fields"; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
isLoading = true; |
||||||
|
error = ""; |
||||||
|
|
||||||
|
try { |
||||||
|
if (mode === "login") { |
||||||
|
const { error: authError } = |
||||||
|
await supabase.auth.signInWithPassword({ |
||||||
|
email, |
||||||
|
password, |
||||||
|
}); |
||||||
|
if (authError) throw authError; |
||||||
|
} else { |
||||||
|
const { error: authError } = await supabase.auth.signUp({ |
||||||
|
email, |
||||||
|
password, |
||||||
|
options: { |
||||||
|
emailRedirectTo: `${window.location.origin}/auth/callback`, |
||||||
|
}, |
||||||
|
}); |
||||||
|
if (authError) throw authError; |
||||||
|
} |
||||||
|
goto("/"); |
||||||
|
} catch (e: unknown) { |
||||||
|
error = e instanceof Error ? e.message : "An error occurred"; |
||||||
|
} finally { |
||||||
|
isLoading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function handleOAuth(provider: "google" | "github") { |
||||||
|
const { error: authError } = await supabase.auth.signInWithOAuth({ |
||||||
|
provider, |
||||||
|
options: { |
||||||
|
redirectTo: `${window.location.origin}/auth/callback`, |
||||||
|
}, |
||||||
|
}); |
||||||
|
if (authError) { |
||||||
|
error = authError.message; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<svelte:head> |
||||||
|
<title>{mode === "login" ? "Log In" : "Sign Up"} | Root</title> |
||||||
|
</svelte:head> |
||||||
|
|
||||||
|
<div class="min-h-screen bg-dark flex items-center justify-center p-4"> |
||||||
|
<div class="w-full max-w-md"> |
||||||
|
<div class="text-center mb-8"> |
||||||
|
<h1 class="text-3xl font-bold text-primary mb-2">Root</h1> |
||||||
|
<p class="text-light/60">Team collaboration, reimagined</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Card variant="elevated" padding="lg"> |
||||||
|
<h2 class="text-xl font-semibold text-light mb-6"> |
||||||
|
{mode === "login" ? "Welcome back" : "Create your account"} |
||||||
|
</h2> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<div |
||||||
|
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm" |
||||||
|
> |
||||||
|
{error} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<form |
||||||
|
onsubmit={(e) => { |
||||||
|
e.preventDefault(); |
||||||
|
handleSubmit(); |
||||||
|
}} |
||||||
|
class="space-y-4" |
||||||
|
> |
||||||
|
<Input |
||||||
|
type="email" |
||||||
|
label="Email" |
||||||
|
placeholder="you@example.com" |
||||||
|
bind:value={email} |
||||||
|
required |
||||||
|
/> |
||||||
|
|
||||||
|
<Input |
||||||
|
type="password" |
||||||
|
label="Password" |
||||||
|
placeholder="••••••••" |
||||||
|
bind:value={password} |
||||||
|
required |
||||||
|
/> |
||||||
|
|
||||||
|
<Button type="submit" fullWidth loading={isLoading}> |
||||||
|
{mode === "login" ? "Log In" : "Sign Up"} |
||||||
|
</Button> |
||||||
|
</form> |
||||||
|
|
||||||
|
<div class="my-6 flex items-center gap-3"> |
||||||
|
<div class="flex-1 h-px bg-light/10"></div> |
||||||
|
<span class="text-light/40 text-sm">or continue with</span> |
||||||
|
<div class="flex-1 h-px bg-light/10"></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Button |
||||||
|
variant="secondary" |
||||||
|
fullWidth |
||||||
|
onclick={() => handleOAuth("google")} |
||||||
|
> |
||||||
|
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24"> |
||||||
|
<path |
||||||
|
fill="currentColor" |
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" |
||||||
|
/> |
||||||
|
<path |
||||||
|
fill="currentColor" |
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" |
||||||
|
/> |
||||||
|
<path |
||||||
|
fill="currentColor" |
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" |
||||||
|
/> |
||||||
|
<path |
||||||
|
fill="currentColor" |
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
Continue with Google |
||||||
|
</Button> |
||||||
|
|
||||||
|
<p class="mt-6 text-center text-light/60 text-sm"> |
||||||
|
{#if mode === "login"} |
||||||
|
Don't have an account? |
||||||
|
<button |
||||||
|
class="text-primary hover:underline" |
||||||
|
onclick={() => (mode = "signup")} |
||||||
|
> |
||||||
|
Sign up |
||||||
|
</button> |
||||||
|
{:else} |
||||||
|
Already have an account? |
||||||
|
<button |
||||||
|
class="text-primary hover:underline" |
||||||
|
onclick={() => (mode = "login")} |
||||||
|
> |
||||||
|
Log in |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</p> |
||||||
|
</Card> |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
import { page } from 'vitest/browser'; |
||||||
|
import { describe, expect, it } from 'vitest'; |
||||||
|
import { render } from 'vitest-browser-svelte'; |
||||||
|
import Page from './+page.svelte'; |
||||||
|
|
||||||
|
describe('/+page.svelte', () => { |
||||||
|
it('should render h1', async () => { |
||||||
|
render(Page); |
||||||
|
|
||||||
|
const heading = page.getByRole('heading', { level: 1 }); |
||||||
|
await expect.element(heading).toBeInTheDocument(); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,371 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { |
||||||
|
Button, |
||||||
|
Input, |
||||||
|
Textarea, |
||||||
|
Select, |
||||||
|
Avatar, |
||||||
|
Badge, |
||||||
|
Card, |
||||||
|
Modal, |
||||||
|
Spinner, |
||||||
|
Toggle |
||||||
|
} from '$lib/components/ui'; |
||||||
|
|
||||||
|
let inputValue = $state(''); |
||||||
|
let textareaValue = $state(''); |
||||||
|
let selectValue = $state(''); |
||||||
|
let toggleChecked = $state(false); |
||||||
|
let modalOpen = $state(false); |
||||||
|
|
||||||
|
const selectOptions = [ |
||||||
|
{ value: 'option1', label: 'Option 1' }, |
||||||
|
{ value: 'option2', label: 'Option 2' }, |
||||||
|
{ value: 'option3', label: 'Option 3' } |
||||||
|
]; |
||||||
|
</script> |
||||||
|
|
||||||
|
<svelte:head> |
||||||
|
<title>Style Guide | Root</title> |
||||||
|
</svelte:head> |
||||||
|
|
||||||
|
<div class="min-h-screen bg-dark p-8"> |
||||||
|
<div class="max-w-6xl mx-auto space-y-12"> |
||||||
|
<!-- Header --> |
||||||
|
<header class="text-center space-y-4"> |
||||||
|
<h1 class="text-4xl font-bold text-light">Root Style Guide</h1> |
||||||
|
<p class="text-light/60">All UI components and their variants</p> |
||||||
|
</header> |
||||||
|
|
||||||
|
<!-- Colors --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Colors</h2> |
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4"> |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="w-full h-20 rounded-xl bg-dark border border-light/20"></div> |
||||||
|
<p class="text-sm text-light/60">Dark</p> |
||||||
|
<code class="text-xs text-light/40">#0a0a0f</code> |
||||||
|
</div> |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="w-full h-20 rounded-xl bg-surface"></div> |
||||||
|
<p class="text-sm text-light/60">Surface</p> |
||||||
|
<code class="text-xs text-light/40">#14141f</code> |
||||||
|
</div> |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="w-full h-20 rounded-xl bg-light"></div> |
||||||
|
<p class="text-sm text-light/60">Light</p> |
||||||
|
<code class="text-xs text-light/40">#f0f0f5</code> |
||||||
|
</div> |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="w-full h-20 rounded-xl bg-primary"></div> |
||||||
|
<p class="text-sm text-light/60">Primary</p> |
||||||
|
<code class="text-xs text-light/40">#6366f1</code> |
||||||
|
</div> |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="w-full h-20 rounded-xl bg-success"></div> |
||||||
|
<p class="text-sm text-light/60">Success</p> |
||||||
|
<code class="text-xs text-light/40">#22c55e</code> |
||||||
|
</div> |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="w-full h-20 rounded-xl bg-warning"></div> |
||||||
|
<p class="text-sm text-light/60">Warning</p> |
||||||
|
<code class="text-xs text-light/40">#f59e0b</code> |
||||||
|
</div> |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="w-full h-20 rounded-xl bg-error"></div> |
||||||
|
<p class="text-sm text-light/60">Error</p> |
||||||
|
<code class="text-xs text-light/40">#ef4444</code> |
||||||
|
</div> |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="w-full h-20 rounded-xl bg-info"></div> |
||||||
|
<p class="text-sm text-light/60">Info</p> |
||||||
|
<code class="text-xs text-light/40">#3b82f6</code> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Buttons --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Buttons</h2> |
||||||
|
|
||||||
|
<div class="space-y-6"> |
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">Variants</h3> |
||||||
|
<div class="flex flex-wrap gap-3"> |
||||||
|
<Button variant="primary">Primary</Button> |
||||||
|
<Button variant="secondary">Secondary</Button> |
||||||
|
<Button variant="ghost">Ghost</Button> |
||||||
|
<Button variant="danger">Danger</Button> |
||||||
|
<Button variant="success">Success</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3> |
||||||
|
<div class="flex flex-wrap items-center gap-3"> |
||||||
|
<Button size="xs">Extra Small</Button> |
||||||
|
<Button size="sm">Small</Button> |
||||||
|
<Button size="md">Medium</Button> |
||||||
|
<Button size="lg">Large</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">States</h3> |
||||||
|
<div class="flex flex-wrap gap-3"> |
||||||
|
<Button>Normal</Button> |
||||||
|
<Button disabled>Disabled</Button> |
||||||
|
<Button loading>Loading</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">Full Width</h3> |
||||||
|
<div class="max-w-sm"> |
||||||
|
<Button fullWidth>Full Width Button</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Inputs --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Inputs</h2> |
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6"> |
||||||
|
<Input label="Default Input" placeholder="Enter text..." bind:value={inputValue} /> |
||||||
|
<Input label="Required Field" placeholder="Required..." required /> |
||||||
|
<Input label="With Hint" placeholder="Email..." hint="We'll never share your email" /> |
||||||
|
<Input label="With Error" placeholder="Password..." error="Password is too short" /> |
||||||
|
<Input label="Disabled" placeholder="Can't edit this" disabled /> |
||||||
|
<Input type="password" label="Password" placeholder="••••••••" /> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Textarea --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Textarea</h2> |
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6"> |
||||||
|
<Textarea label="Default Textarea" placeholder="Enter description..." bind:value={textareaValue} /> |
||||||
|
<Textarea label="With Error" placeholder="Description..." error="Description is required" /> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Select --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Select</h2> |
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-6"> |
||||||
|
<Select label="Default Select" options={selectOptions} bind:value={selectValue} /> |
||||||
|
<Select label="With Error" options={selectOptions} error="Please select an option" /> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Avatars --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Avatars</h2> |
||||||
|
|
||||||
|
<div class="space-y-6"> |
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3> |
||||||
|
<div class="flex items-end gap-4"> |
||||||
|
<Avatar name="John Doe" size="xs" /> |
||||||
|
<Avatar name="John Doe" size="sm" /> |
||||||
|
<Avatar name="John Doe" size="md" /> |
||||||
|
<Avatar name="John Doe" size="lg" /> |
||||||
|
<Avatar name="John Doe" size="xl" /> |
||||||
|
<Avatar name="John Doe" size="2xl" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">With Status</h3> |
||||||
|
<div class="flex items-center gap-4"> |
||||||
|
<Avatar name="Online User" size="lg" status="online" /> |
||||||
|
<Avatar name="Away User" size="lg" status="away" /> |
||||||
|
<Avatar name="Busy User" size="lg" status="busy" /> |
||||||
|
<Avatar name="Offline User" size="lg" status="offline" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">Different Names (Color Generation)</h3> |
||||||
|
<div class="flex items-center gap-4"> |
||||||
|
<Avatar name="Alice" size="lg" /> |
||||||
|
<Avatar name="Bob" size="lg" /> |
||||||
|
<Avatar name="Charlie" size="lg" /> |
||||||
|
<Avatar name="Diana" size="lg" /> |
||||||
|
<Avatar name="Eve" size="lg" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Badges --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Badges</h2> |
||||||
|
|
||||||
|
<div class="space-y-6"> |
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">Variants</h3> |
||||||
|
<div class="flex flex-wrap gap-3"> |
||||||
|
<Badge variant="default">Default</Badge> |
||||||
|
<Badge variant="primary">Primary</Badge> |
||||||
|
<Badge variant="success">Success</Badge> |
||||||
|
<Badge variant="warning">Warning</Badge> |
||||||
|
<Badge variant="error">Error</Badge> |
||||||
|
<Badge variant="info">Info</Badge> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3> |
||||||
|
<div class="flex flex-wrap items-center gap-3"> |
||||||
|
<Badge size="sm">Small</Badge> |
||||||
|
<Badge size="md">Medium</Badge> |
||||||
|
<Badge size="lg">Large</Badge> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Cards --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Cards</h2> |
||||||
|
|
||||||
|
<div class="grid md:grid-cols-3 gap-6"> |
||||||
|
<Card variant="default"> |
||||||
|
<h3 class="font-semibold text-light mb-2">Default Card</h3> |
||||||
|
<p class="text-light/60 text-sm">This is a default card with medium padding.</p> |
||||||
|
</Card> |
||||||
|
<Card variant="elevated"> |
||||||
|
<h3 class="font-semibold text-light mb-2">Elevated Card</h3> |
||||||
|
<p class="text-light/60 text-sm">This card has a shadow for elevation.</p> |
||||||
|
</Card> |
||||||
|
<Card variant="outlined"> |
||||||
|
<h3 class="font-semibold text-light mb-2">Outlined Card</h3> |
||||||
|
<p class="text-light/60 text-sm">This card has a subtle border.</p> |
||||||
|
</Card> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Toggle --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Toggle</h2> |
||||||
|
|
||||||
|
<div class="space-y-6"> |
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3> |
||||||
|
<div class="flex items-center gap-6"> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<Toggle size="sm" /> |
||||||
|
<span class="text-light/60 text-sm">Small</span> |
||||||
|
</div> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<Toggle size="md" bind:checked={toggleChecked} /> |
||||||
|
<span class="text-light/60 text-sm">Medium</span> |
||||||
|
</div> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<Toggle size="lg" checked /> |
||||||
|
<span class="text-light/60 text-sm">Large</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">States</h3> |
||||||
|
<div class="flex items-center gap-6"> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<Toggle /> |
||||||
|
<span class="text-light/60 text-sm">Off</span> |
||||||
|
</div> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<Toggle checked /> |
||||||
|
<span class="text-light/60 text-sm">On</span> |
||||||
|
</div> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<Toggle disabled /> |
||||||
|
<span class="text-light/60 text-sm">Disabled</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Spinners --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Spinners</h2> |
||||||
|
|
||||||
|
<div class="space-y-6"> |
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3> |
||||||
|
<div class="flex items-center gap-6"> |
||||||
|
<Spinner size="sm" /> |
||||||
|
<Spinner size="md" /> |
||||||
|
<Spinner size="lg" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">Colors</h3> |
||||||
|
<div class="flex items-center gap-6"> |
||||||
|
<Spinner color="primary" /> |
||||||
|
<Spinner color="light" /> |
||||||
|
<div class="text-success"> |
||||||
|
<Spinner color="current" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Modal --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Modal</h2> |
||||||
|
|
||||||
|
<div> |
||||||
|
<Button onclick={() => (modalOpen = true)}>Open Modal</Button> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<Modal isOpen={modalOpen} onClose={() => (modalOpen = false)} title="Example Modal"> |
||||||
|
<p class="text-light/70 mb-4"> |
||||||
|
This is an example modal dialog. You can put any content here. |
||||||
|
</p> |
||||||
|
<div class="flex gap-3 justify-end"> |
||||||
|
<Button variant="secondary" onclick={() => (modalOpen = false)}>Cancel</Button> |
||||||
|
<Button onclick={() => (modalOpen = false)}>Confirm</Button> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
|
||||||
|
<!-- Typography --> |
||||||
|
<section class="space-y-4"> |
||||||
|
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Typography</h2> |
||||||
|
|
||||||
|
<div class="space-y-4"> |
||||||
|
<h1 class="text-4xl font-bold text-light">Heading 1 (4xl bold)</h1> |
||||||
|
<h2 class="text-3xl font-bold text-light">Heading 2 (3xl bold)</h2> |
||||||
|
<h3 class="text-2xl font-semibold text-light">Heading 3 (2xl semibold)</h3> |
||||||
|
<h4 class="text-xl font-semibold text-light">Heading 4 (xl semibold)</h4> |
||||||
|
<h5 class="text-lg font-medium text-light">Heading 5 (lg medium)</h5> |
||||||
|
<h6 class="text-base font-medium text-light">Heading 6 (base medium)</h6> |
||||||
|
<p class="text-base text-light/80"> |
||||||
|
Body text (base, 80% opacity) - Lorem ipsum dolor sit amet, consectetur adipiscing elit. |
||||||
|
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. |
||||||
|
</p> |
||||||
|
<p class="text-sm text-light/60"> |
||||||
|
Small text (sm, 60% opacity) - Used for secondary information and hints. |
||||||
|
</p> |
||||||
|
<p class="text-xs text-light/40"> |
||||||
|
Extra small text (xs, 40% opacity) - Used for metadata and timestamps. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Footer --> |
||||||
|
<footer class="text-center py-8 border-t border-light/10"> |
||||||
|
<p class="text-light/40 text-sm">Root Organization Platform - Style Guide</p> |
||||||
|
</footer> |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
# allow crawling everything by default |
||||||
|
User-agent: * |
||||||
|
Disallow: |
||||||
@ -0,0 +1,224 @@ |
|||||||
|
-- Root Organization Platform - Initial Schema |
||||||
|
-- Run this in your Supabase SQL editor |
||||||
|
|
||||||
|
-- Enable UUID extension |
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; |
||||||
|
|
||||||
|
-- Profiles (synced from auth.users) |
||||||
|
CREATE TABLE profiles ( |
||||||
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, |
||||||
|
email TEXT NOT NULL, |
||||||
|
full_name TEXT, |
||||||
|
avatar_url TEXT, |
||||||
|
created_at TIMESTAMPTZ DEFAULT now(), |
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() |
||||||
|
); |
||||||
|
|
||||||
|
-- Organizations (workspaces) |
||||||
|
CREATE TABLE organizations ( |
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||||
|
name TEXT NOT NULL, |
||||||
|
slug TEXT UNIQUE NOT NULL, |
||||||
|
avatar_url TEXT, |
||||||
|
created_at TIMESTAMPTZ DEFAULT now(), |
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() |
||||||
|
); |
||||||
|
|
||||||
|
-- Organization Members |
||||||
|
CREATE TABLE org_members ( |
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||||
|
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE, |
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, |
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'editor', 'viewer')), |
||||||
|
invited_at TIMESTAMPTZ DEFAULT now(), |
||||||
|
joined_at TIMESTAMPTZ, |
||||||
|
UNIQUE(org_id, user_id) |
||||||
|
); |
||||||
|
|
||||||
|
-- Documents/Folders |
||||||
|
CREATE TABLE documents ( |
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||||
|
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE, |
||||||
|
parent_id UUID REFERENCES documents(id) ON DELETE CASCADE, |
||||||
|
type TEXT NOT NULL CHECK (type IN ('folder', 'document')), |
||||||
|
name TEXT NOT NULL, |
||||||
|
content JSONB, |
||||||
|
created_by UUID REFERENCES auth.users(id), |
||||||
|
created_at TIMESTAMPTZ DEFAULT now(), |
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() |
||||||
|
); |
||||||
|
|
||||||
|
-- Kanban Boards |
||||||
|
CREATE TABLE kanban_boards ( |
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||||
|
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE, |
||||||
|
name TEXT NOT NULL, |
||||||
|
created_at TIMESTAMPTZ DEFAULT now() |
||||||
|
); |
||||||
|
|
||||||
|
-- Kanban Columns |
||||||
|
CREATE TABLE kanban_columns ( |
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||||
|
board_id UUID REFERENCES kanban_boards(id) ON DELETE CASCADE, |
||||||
|
name TEXT NOT NULL, |
||||||
|
position INTEGER NOT NULL, |
||||||
|
color TEXT |
||||||
|
); |
||||||
|
|
||||||
|
-- Kanban Cards |
||||||
|
CREATE TABLE kanban_cards ( |
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||||
|
column_id UUID REFERENCES kanban_columns(id) ON DELETE CASCADE, |
||||||
|
title TEXT NOT NULL, |
||||||
|
description TEXT, |
||||||
|
position INTEGER NOT NULL, |
||||||
|
due_date TIMESTAMPTZ, |
||||||
|
color TEXT, |
||||||
|
created_by UUID REFERENCES auth.users(id), |
||||||
|
created_at TIMESTAMPTZ DEFAULT now() |
||||||
|
); |
||||||
|
|
||||||
|
-- Card Assignees |
||||||
|
CREATE TABLE card_assignees ( |
||||||
|
card_id UUID REFERENCES kanban_cards(id) ON DELETE CASCADE, |
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, |
||||||
|
PRIMARY KEY (card_id, user_id) |
||||||
|
); |
||||||
|
|
||||||
|
-- Calendar Events |
||||||
|
CREATE TABLE calendar_events ( |
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||||
|
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE, |
||||||
|
title TEXT NOT NULL, |
||||||
|
description TEXT, |
||||||
|
start_time TIMESTAMPTZ NOT NULL, |
||||||
|
end_time TIMESTAMPTZ NOT NULL, |
||||||
|
all_day BOOLEAN DEFAULT false, |
||||||
|
color TEXT, |
||||||
|
recurrence TEXT, |
||||||
|
created_by UUID REFERENCES auth.users(id), |
||||||
|
created_at TIMESTAMPTZ DEFAULT now() |
||||||
|
); |
||||||
|
|
||||||
|
-- Event Attendees |
||||||
|
CREATE TABLE event_attendees ( |
||||||
|
event_id UUID REFERENCES calendar_events(id) ON DELETE CASCADE, |
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, |
||||||
|
status TEXT CHECK (status IN ('pending', 'accepted', 'declined')) DEFAULT 'pending', |
||||||
|
PRIMARY KEY (event_id, user_id) |
||||||
|
); |
||||||
|
|
||||||
|
-- Indexes for performance |
||||||
|
CREATE INDEX idx_org_members_org ON org_members(org_id); |
||||||
|
CREATE INDEX idx_org_members_user ON org_members(user_id); |
||||||
|
CREATE INDEX idx_documents_org ON documents(org_id); |
||||||
|
CREATE INDEX idx_documents_parent ON documents(parent_id); |
||||||
|
CREATE INDEX idx_kanban_boards_org ON kanban_boards(org_id); |
||||||
|
CREATE INDEX idx_kanban_columns_board ON kanban_columns(board_id); |
||||||
|
CREATE INDEX idx_kanban_cards_column ON kanban_cards(column_id); |
||||||
|
CREATE INDEX idx_calendar_events_org ON calendar_events(org_id); |
||||||
|
CREATE INDEX idx_calendar_events_time ON calendar_events(start_time, end_time); |
||||||
|
|
||||||
|
-- Row Level Security (RLS) |
||||||
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; |
||||||
|
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; |
||||||
|
ALTER TABLE org_members ENABLE ROW LEVEL SECURITY; |
||||||
|
ALTER TABLE documents ENABLE ROW LEVEL SECURITY; |
||||||
|
ALTER TABLE kanban_boards ENABLE ROW LEVEL SECURITY; |
||||||
|
ALTER TABLE kanban_columns ENABLE ROW LEVEL SECURITY; |
||||||
|
ALTER TABLE kanban_cards ENABLE ROW LEVEL SECURITY; |
||||||
|
ALTER TABLE card_assignees ENABLE ROW LEVEL SECURITY; |
||||||
|
ALTER TABLE calendar_events ENABLE ROW LEVEL SECURITY; |
||||||
|
ALTER TABLE event_attendees ENABLE ROW LEVEL SECURITY; |
||||||
|
|
||||||
|
-- Profiles: Users can read all profiles, update their own |
||||||
|
CREATE POLICY "Profiles are viewable by everyone" ON profiles FOR SELECT USING (true); |
||||||
|
CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = id); |
||||||
|
|
||||||
|
-- Organizations: Members can view, owners/admins can modify |
||||||
|
CREATE POLICY "Org members can view organizations" ON organizations FOR SELECT |
||||||
|
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = organizations.id AND user_id = auth.uid())); |
||||||
|
|
||||||
|
CREATE POLICY "Org owners/admins can update organizations" ON organizations FOR UPDATE |
||||||
|
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = organizations.id AND user_id = auth.uid() AND role IN ('owner', 'admin'))); |
||||||
|
|
||||||
|
CREATE POLICY "Anyone can create organizations" ON organizations FOR INSERT WITH CHECK (true); |
||||||
|
|
||||||
|
-- Org Members: Members can view, owners/admins can modify |
||||||
|
CREATE POLICY "Org members can view members" ON org_members FOR SELECT |
||||||
|
USING (EXISTS (SELECT 1 FROM org_members om WHERE om.org_id = org_members.org_id AND om.user_id = auth.uid())); |
||||||
|
|
||||||
|
CREATE POLICY "Org owners/admins can manage members" ON org_members FOR ALL |
||||||
|
USING (EXISTS (SELECT 1 FROM org_members om WHERE om.org_id = org_members.org_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin'))); |
||||||
|
|
||||||
|
-- Documents: Based on org membership and role |
||||||
|
CREATE POLICY "Org members can view documents" ON documents FOR SELECT |
||||||
|
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = documents.org_id AND user_id = auth.uid())); |
||||||
|
|
||||||
|
CREATE POLICY "Editors can manage documents" ON documents FOR ALL |
||||||
|
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = documents.org_id AND user_id = auth.uid() AND role IN ('owner', 'admin', 'editor'))); |
||||||
|
|
||||||
|
-- Kanban: Based on org membership |
||||||
|
CREATE POLICY "Org members can view boards" ON kanban_boards FOR SELECT |
||||||
|
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = kanban_boards.org_id AND user_id = auth.uid())); |
||||||
|
|
||||||
|
CREATE POLICY "Editors can manage boards" ON kanban_boards FOR ALL |
||||||
|
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = kanban_boards.org_id AND user_id = auth.uid() AND role IN ('owner', 'admin', 'editor'))); |
||||||
|
|
||||||
|
CREATE POLICY "Org members can view columns" ON kanban_columns FOR SELECT |
||||||
|
USING (EXISTS (SELECT 1 FROM kanban_boards b JOIN org_members om ON b.org_id = om.org_id WHERE b.id = kanban_columns.board_id AND om.user_id = auth.uid())); |
||||||
|
|
||||||
|
CREATE POLICY "Editors can manage columns" ON kanban_columns FOR ALL |
||||||
|
USING (EXISTS (SELECT 1 FROM kanban_boards b JOIN org_members om ON b.org_id = om.org_id WHERE b.id = kanban_columns.board_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor'))); |
||||||
|
|
||||||
|
CREATE POLICY "Org members can view cards" ON kanban_cards FOR SELECT |
||||||
|
USING (EXISTS (SELECT 1 FROM kanban_columns c JOIN kanban_boards b ON c.board_id = b.id JOIN org_members om ON b.org_id = om.org_id WHERE c.id = kanban_cards.column_id AND om.user_id = auth.uid())); |
||||||
|
|
||||||
|
CREATE POLICY "Editors can manage cards" ON kanban_cards FOR ALL |
||||||
|
USING (EXISTS (SELECT 1 FROM kanban_columns c JOIN kanban_boards b ON c.board_id = b.id JOIN org_members om ON b.org_id = om.org_id WHERE c.id = kanban_cards.column_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor'))); |
||||||
|
|
||||||
|
-- Calendar: Based on org membership |
||||||
|
CREATE POLICY "Org members can view events" ON calendar_events FOR SELECT |
||||||
|
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = calendar_events.org_id AND user_id = auth.uid())); |
||||||
|
|
||||||
|
CREATE POLICY "Editors can manage events" ON calendar_events FOR ALL |
||||||
|
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = calendar_events.org_id AND user_id = auth.uid() AND role IN ('owner', 'admin', 'editor'))); |
||||||
|
|
||||||
|
-- Trigger to create profile on user signup |
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_user() |
||||||
|
RETURNS TRIGGER AS $$ |
||||||
|
BEGIN |
||||||
|
INSERT INTO public.profiles (id, email, full_name, avatar_url) |
||||||
|
VALUES ( |
||||||
|
NEW.id, |
||||||
|
NEW.email, |
||||||
|
NEW.raw_user_meta_data->>'full_name', |
||||||
|
NEW.raw_user_meta_data->>'avatar_url' |
||||||
|
); |
||||||
|
RETURN NEW; |
||||||
|
END; |
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER; |
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER on_auth_user_created |
||||||
|
AFTER INSERT ON auth.users |
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); |
||||||
|
|
||||||
|
-- Function to add creator as owner when org is created |
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_org() |
||||||
|
RETURNS TRIGGER AS $$ |
||||||
|
BEGIN |
||||||
|
INSERT INTO public.org_members (org_id, user_id, role, joined_at) |
||||||
|
VALUES (NEW.id, auth.uid(), 'owner', now()); |
||||||
|
RETURN NEW; |
||||||
|
END; |
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER; |
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER on_org_created |
||||||
|
AFTER INSERT ON organizations |
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_org(); |
||||||
|
|
||||||
|
-- Enable realtime for tables that need live updates |
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE documents; |
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE kanban_columns; |
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE kanban_cards; |
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE calendar_events; |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
-- Add description to kanban_cards and create checklist tables |
||||||
|
|
||||||
|
-- Add description column to kanban_cards if not exists |
||||||
|
ALTER TABLE kanban_cards ADD COLUMN IF NOT EXISTS description TEXT; |
||||||
|
|
||||||
|
-- Checklist items for kanban cards |
||||||
|
CREATE TABLE IF NOT EXISTS checklist_items ( |
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||||
|
card_id UUID REFERENCES kanban_cards(id) ON DELETE CASCADE, |
||||||
|
title TEXT NOT NULL, |
||||||
|
completed BOOLEAN DEFAULT false, |
||||||
|
position INTEGER NOT NULL, |
||||||
|
created_at TIMESTAMPTZ DEFAULT now() |
||||||
|
); |
||||||
|
|
||||||
|
-- Index for performance |
||||||
|
CREATE INDEX IF NOT EXISTS idx_checklist_items_card ON checklist_items(card_id); |
||||||
|
|
||||||
|
-- RLS |
||||||
|
ALTER TABLE checklist_items ENABLE ROW LEVEL SECURITY; |
||||||
|
|
||||||
|
-- Checklist items inherit card permissions (via board -> org membership) |
||||||
|
CREATE POLICY "Org members can view checklist items" ON checklist_items FOR SELECT |
||||||
|
USING (EXISTS ( |
||||||
|
SELECT 1 FROM kanban_cards c |
||||||
|
JOIN kanban_columns col ON c.column_id = col.id |
||||||
|
JOIN kanban_boards b ON col.board_id = b.id |
||||||
|
JOIN org_members om ON b.org_id = om.org_id |
||||||
|
WHERE c.id = checklist_items.card_id AND om.user_id = auth.uid() |
||||||
|
)); |
||||||
|
|
||||||
|
CREATE POLICY "Editors can manage checklist items" ON checklist_items FOR ALL |
||||||
|
USING (EXISTS ( |
||||||
|
SELECT 1 FROM kanban_cards c |
||||||
|
JOIN kanban_columns col ON c.column_id = col.id |
||||||
|
JOIN kanban_boards b ON col.board_id = b.id |
||||||
|
JOIN org_members om ON b.org_id = om.org_id |
||||||
|
WHERE c.id = checklist_items.card_id |
||||||
|
AND om.user_id = auth.uid() |
||||||
|
AND om.role IN ('owner', 'admin', 'editor') |
||||||
|
)); |
||||||
|
|
||||||
|
-- Enable realtime for checklist items |
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE checklist_items; |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
-- Google Calendar integration |
||||||
|
|
||||||
|
-- Store Google Calendar connection per user |
||||||
|
CREATE TABLE IF NOT EXISTS google_calendar_connections ( |
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE, |
||||||
|
access_token TEXT NOT NULL, |
||||||
|
refresh_token TEXT NOT NULL, |
||||||
|
token_expires_at TIMESTAMPTZ NOT NULL, |
||||||
|
calendar_id TEXT, -- specific calendar to sync with |
||||||
|
created_at TIMESTAMPTZ DEFAULT now(), |
||||||
|
updated_at TIMESTAMPTZ DEFAULT now() |
||||||
|
); |
||||||
|
|
||||||
|
-- Link local events to Google Calendar events |
||||||
|
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS google_event_id TEXT; |
||||||
|
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS synced_at TIMESTAMPTZ; |
||||||
|
|
||||||
|
-- Index |
||||||
|
CREATE INDEX IF NOT EXISTS idx_google_calendar_user ON google_calendar_connections(user_id); |
||||||
|
CREATE INDEX IF NOT EXISTS idx_calendar_events_google ON calendar_events(google_event_id) WHERE google_event_id IS NOT NULL; |
||||||
|
|
||||||
|
-- RLS |
||||||
|
ALTER TABLE google_calendar_connections ENABLE ROW LEVEL SECURITY; |
||||||
|
|
||||||
|
-- Users can only see/manage their own connections |
||||||
|
CREATE POLICY "Users can view own Google connection" ON google_calendar_connections |
||||||
|
FOR SELECT USING (auth.uid() = user_id); |
||||||
|
|
||||||
|
CREATE POLICY "Users can manage own Google connection" ON google_calendar_connections |
||||||
|
FOR ALL USING (auth.uid() = user_id); |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
import adapter from '@sveltejs/adapter-node'; |
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */ |
||||||
|
const config = { kit: { adapter: adapter() } }; |
||||||
|
|
||||||
|
export default config; |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
{ |
||||||
|
"extends": "./.svelte-kit/tsconfig.json", |
||||||
|
"compilerOptions": { |
||||||
|
"rewriteRelativeImportExtensions": true, |
||||||
|
"allowJs": true, |
||||||
|
"checkJs": true, |
||||||
|
"esModuleInterop": true, |
||||||
|
"forceConsistentCasingInFileNames": true, |
||||||
|
"resolveJsonModule": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"sourceMap": true, |
||||||
|
"strict": true, |
||||||
|
"moduleResolution": "bundler" |
||||||
|
} |
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias |
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files |
||||||
|
// |
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending |
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript |
||||||
|
} |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
import { defineConfig } from 'vitest/config'; |
||||||
|
import { playwright } from '@vitest/browser-playwright'; |
||||||
|
import tailwindcss from '@tailwindcss/vite'; |
||||||
|
import { sveltekit } from '@sveltejs/kit/vite'; |
||||||
|
|
||||||
|
export default defineConfig({ |
||||||
|
plugins: [tailwindcss(), sveltekit()], |
||||||
|
test: { |
||||||
|
expect: { requireAssertions: true }, |
||||||
|
projects: [ |
||||||
|
{ |
||||||
|
extends: './vite.config.ts', |
||||||
|
test: { |
||||||
|
name: 'client', |
||||||
|
browser: { |
||||||
|
enabled: true, |
||||||
|
provider: playwright(), |
||||||
|
instances: [{ browser: 'chromium', headless: true }] |
||||||
|
}, |
||||||
|
include: ['src/**/*.svelte.{test,spec}.{js,ts}'], |
||||||
|
exclude: ['src/lib/server/**'] |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
{ |
||||||
|
extends: './vite.config.ts', |
||||||
|
test: { |
||||||
|
name: 'server', |
||||||
|
environment: 'node', |
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}'], |
||||||
|
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] |
||||||
|
} |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
|
}); |
||||||
Loading…
Reference in new issue