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