feat: full project sync - CI fixes, frontend, workspace API, and all changes

This commit is contained in:
Tomas Dvorak
2026-04-27 09:08:07 +02:00
parent a07fca997e
commit 89b9390c14
109 changed files with 21120 additions and 545 deletions
+377
View File
@@ -0,0 +1,377 @@
# Excalidraw FULL - Frontend Design System
## Design Context
### Target Audience
- Product teams who need visual collaboration tools
- Developers creating architecture diagrams, flowcharts
- Designers wireframing and prototyping
- Educators and facilitators running workshops
- Anyone who prefers hand-drawn aesthetics over sterile diagrams
### Use Cases
- Brainstorming and ideation sessions
- System architecture and technical diagrams
- UI/UX wireframing and user flows
- Kanban boards and project planning
- Meeting notes and retrospectives
- Mind mapping and knowledge organization
### Brand Personality
**Hand-crafted technical workspace**
- Human and approachable (hand-drawn aesthetic)
- Professional but not sterile
- Creative and collaborative
- Calm, uncluttered interface
- Self-hosted, privacy-conscious
### Tone
Warm minimalism meets technical precision. The hand-drawn style softens technical content, making complex diagrams feel accessible and collaborative.
---
## Color System
### Primary Palette
```css
--color-primary: #6965db; /* Main purple */
--color-primary-darker: #5b57d1; /* Hover states */
--color-primary-darkest: #4a47b1; /* Active states */
--color-primary-light: #e3e2fe; /* Subtle backgrounds */
--color-primary-hover: #5753d0; /* Interactive hover */
```
### Neutral Palette
```css
--color-gray-10: #f5f5f5; /* Lightest background */
--color-gray-20: #ebebeb; /* Card backgrounds */
--color-gray-30: #d6d6d6; /* Borders subtle */
--color-gray-40: #b8b8b8; /* Disabled states */
--color-gray-50: #999999; /* Muted text */
--color-gray-60: #7a7a7a; /* Secondary text */
--color-gray-70: #5c5c5c; /* Body text light */
--color-gray-80: #3d3d3d; /* Body text */
--color-gray-85: #242424; /* Headings */
--color-gray-90: #1e1e1e; /* Strong text */
--color-gray-100: #121212; /* Near black */
```
### Semantic Colors
```css
--color-success: #cafccc;
--color-success-text: #268029;
--color-warning: #fceeca;
--color-warning-dark: #f5c354;
--color-danger: #db6965;
--color-danger-dark: #d65550;
--color-danger-text: #700000;
```
### Surface Colors (Light Mode)
```css
--island-bg-color: #ffffff;
--color-surface-low: #f8f9fa;
--color-surface-high: #e9ecef;
--color-surface-primary-container: #e3e2fe;
--color-on-surface: #1e1e1e;
--color-on-primary-container: #4a47b1;
```
---
## Typography
### Font Stack
```css
--ui-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--editor-font: "Virgil", "Cascadia", "Segoe UI", sans-serif; /* Hand-drawn feel */
```
### Type Scale
```css
--text-xs: 0.75rem; /* 12px - Captions, labels */
--text-sm: 0.875rem; /* 14px - Secondary text */
--text-base: 1rem; /* 16px - Body text */
--text-lg: 1.125rem; /* 18px - Large body */
--text-xl: 1.25rem; /* 20px - H4 */
--text-2xl: 1.5rem; /* 24px - H3 */
--text-3xl: 1.875rem; /* 30px - H2 */
--text-4xl: 2.25rem; /* 36px - H1 */
```
### Font Weights
- **400** Regular - Body text
- **500** Medium - Emphasis, labels
- **600** Semibold - Subheadings
- **700** Bold - Headings, important text
---
## Spacing System
### Base Unit
```css
--space-factor: 0.25rem; /* 4px base */
```
### Scale
```css
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
```
---
## Component Patterns
### Island Pattern (Excalidraw Signature)
Floating container with subtle shadow:
```css
.island {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
padding: var(--space-4);
}
```
### Buttons
**Primary Button**
```css
.btn-primary {
background: var(--color-primary);
color: white;
border-radius: var(--border-radius-lg);
padding: 0.625rem 1rem;
font-weight: 500;
&:hover { background: var(--color-primary-hover); }
&:active { background: var(--color-primary-darkest); }
}
```
**Secondary Button**
```css
.btn-secondary {
background: var(--color-surface-low);
color: var(--color-on-surface);
border: 1px solid var(--color-surface-high);
border-radius: var(--border-radius-lg);
&:hover { background: var(--color-surface-high); }
}
```
**Ghost Button**
```css
.btn-ghost {
background: transparent;
color: var(--color-on-surface);
&:hover { background: var(--color-surface-low); }
}
```
### Cards
**Drawing Card**
```css
.drawing-card {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
overflow: hidden;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-island-stronger);
}
}
```
### Form Inputs
**Text Input**
```css
.input {
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
padding: 0.5rem 0.75rem;
font-size: var(--text-base);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
&:hover:not(:focus) {
background: var(--input-hover-bg-color);
}
}
```
---
## Layout Patterns
### App Shell Structure
```
┌─────────────────────────────────────────────────────────┐
│ Sidebar │ Header │
│ ├─────────────────────────────────────────────┤
│ │ │
│ Navigation │ Main Content │
│ │ │
│ │ │
│ │ │
└─────────────┴─────────────────────────────────────────────┘
```
### Dashboard Grid
```css
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
```
### Sidebar Navigation
```css
.sidebar {
width: 260px;
background: var(--island-bg-color);
border-right: 1px solid var(--color-gray-20);
padding: var(--space-4);
}
```
---
## Shadows & Effects
```css
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgb(0 0 0 / 18%);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.05),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.04);
```
---
## Border Radius
```css
--border-radius-sm: 0.25rem; /* 4px */
--border-radius-md: 0.375rem; /* 6px */
--border-radius-lg: 0.5rem; /* 8px */
--border-radius-xl: 0.75rem; /* 12px */
--border-radius-full: 9999px; /* Pills, avatars */
```
---
## Animation & Transitions
### Timing
```css
--duration-fast: 0.15s;
--duration-normal: 0.2s;
--duration-slow: 0.3s;
```
### Easing
```css
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
```
### Common Transitions
```css
/* Hover states */
transition: background-color var(--duration-fast) var(--ease-out);
/* Card interactions */
transition: transform var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
/* Modal/dialog */
transition: opacity var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out);
```
---
## Icon System
- 16px (default-icon-size) for inline UI
- 20px (lg-icon-size) for buttons
- 24px for navigation
- Lucide icons preferred for consistency
- Stroke width: 1.5px - 2px
---
## Responsive Breakpoints
```css
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
```
---
## Page Specifications
### Dashboard
- Grid of drawing cards with thumbnails
- Recent activity sidebar
- Quick actions header
- Team selector
### File Browser
- Folder tree sidebar
- Breadcrumb navigation
- Sortable/filterable drawing list
- Bulk actions toolbar
### Auth Pages
- Centered card layout
- Clean, minimal design
- Clear call-to-action
- Social auth buttons (GitHub)
### Editor (Canvas)
- Full-screen canvas
- Minimal surrounding UI
- Floating toolbars (island pattern)
- Collapsible side panels
### Settings
- Tabbed interface
- Sidebar navigation for sections
- Clear section headers
- Form-based layout
+74
View File
@@ -0,0 +1,74 @@
# Excalidraw FULL Frontend
Production-grade frontend for the Excalidraw FULL overhaul.
## Stack
- React 19 + TypeScript
- Vite (fast HMR, optimized builds)
- SCSS Modules (scoped styles)
- Zustand (state management)
- React Router (routing)
- Lucide React (icons)
## Design System
Excalidraw's hand-drawn aesthetic preserved:
- **Island UI**: Floating panels with subtle shadows
- **Primary**: Purple (`#6965db`)
- **Typography**: Inter font, clear hierarchy
- **Shadows**: Multi-layer island shadows
- **Radii**: Consistent 6-12px rounding
## Pages
| Route | Page | Features |
|-------|------|----------|
| `/` | Dashboard | Stats, recent drawings, activity, templates |
| `/login` | Login | Email/password + GitHub OAuth |
| `/signup` | Signup | Account creation |
| `/files` | File Browser | Folder tree, grid/list view, drawing cards |
| `/team` | Team Settings | Members, roles, invites |
| `/settings` | User Settings | Profile, account, notifications, appearance |
| `/templates` | Templates | Gallery with categories |
| `/drawing/:id` | Editor | Canvas placeholder (integrate Excalidraw) |
## Quick Start
```bash
npm install
npm run dev # Development server at http://localhost:3000
npm run typecheck # Type checking
npm run build # Production build
```
## API Integration
Update `src/services/api.ts` endpoints to match your Go backend:
- `GET /api/auth/me` - Current user
- `POST /api/auth/login` - Login
- `GET /api/drawings` - List drawings
- `GET /api/teams` - List teams
The Vite proxy forwards `/api` to `http://localhost:3002`.
## Canvas Integration
To integrate the existing Excalidraw canvas:
1. Install package: `npm install @excalidraw/excalidraw`
2. Import in `Editor.tsx`: `import { Excalidraw } from '@excalidraw/excalidraw'`
3. Replace the placeholder div with the `<Excalidraw />` component
4. Wire up `onChange` to save via API
## Project Structure
```
src/
├── components/ # Reusable UI (Button, Input, Card, Layout)
├── pages/ # Route components
├── stores/ # Zustand state
├── services/ # API clients
├── types/ # TypeScript interfaces
└── styles/ # SCSS variables, global styles
```
+169
View File
@@ -0,0 +1,169 @@
import { test, expect } from '@playwright/test';
const BASE = 'http://localhost:3456';
// Auth: first-run signup, blocked signup, login
test.describe.serial('auth flow', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('redirects to signup when no users exist', async ({ page }) => {
await page.goto(BASE + '/');
await expect(page).toHaveURL(/\/signup$/);
await expect(page.getByRole('heading', { name: 'Create account' })).toBeVisible();
});
test('first user can signup', async ({ page }) => {
await page.goto(BASE + '/signup');
await page.getByLabel('Full Name').fill('E2E User');
await page.getByLabel('Email').fill('e2e@test.com');
await page.getByLabel('Password').fill('e2e-password-123');
await page.getByRole('button', { name: 'Create Account' }).click();
await expect(page).toHaveURL(BASE + '/');
await expect(page.getByText(/Welcome back/)).toBeVisible();
await page.context().storageState({ path: 'playwright/.auth/state.json' });
});
test('blocks second signup when users exist', async ({ page }) => {
await page.goto(BASE + '/signup');
await expect(page).toHaveURL(/\/login$/);
});
test('existing user can login', async ({ page }) => {
await page.goto(BASE + '/login');
await page.getByLabel('Email').fill('e2e@test.com');
await page.getByLabel('Password').fill('e2e-password-123');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page).toHaveURL(BASE + '/');
await expect(page.getByText(/Welcome back/)).toBeVisible();
});
});
// Dashboard: quick actions and stats
test.describe.serial('dashboard', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('shows stats cards', async ({ page }) => {
await page.goto(BASE + '/');
await expect(page.getByText('Drawings')).toBeVisible();
await expect(page.getByText('Projects')).toBeVisible();
await expect(page.getByText('Teams')).toBeVisible();
});
test('quick action: New Project navigates to files', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Project' }).click();
await expect(page).toHaveURL(/\/files/);
await expect(page.getByRole('navigation', { name: 'Project tree' })).toBeVisible();
await expect(page.getByText('All Projects')).toBeVisible();
});
test('quick action: Invite navigates to team', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'Invite' }).click();
await expect(page).toHaveURL(/\/team/);
await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
});
test('quick action: Library navigates to marketplace', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'Library' }).click();
await expect(page).toHaveURL(/\/library/);
await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
});
test('New Drawing opens template picker', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Drawing' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Choose a Template' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Blank Canvas' })).toBeVisible();
await expect(page.getByRole('button', { name: 'To-Do List' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Checklist' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Bullet List' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Flow Chart' })).toBeVisible();
});
});
// Projects / FileBrowser
test.describe.serial('projects', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('shows Projects label in sidebar and breadcrumb', async ({ page }) => {
await page.goto(BASE + '/files');
await expect(page.getByRole('navigation', { name: 'Main navigation' }).getByText('Projects')).toBeVisible();
await expect(page.getByText('All Projects')).toBeVisible();
});
test('can create a drawing from file browser', async ({ page }) => {
await page.goto(BASE + '/files');
await page.getByRole('button', { name: 'Create new drawing' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Blank Canvas' }).click();
await expect(page).toHaveURL(/\/drawing\//);
await expect(page.getByText('Loading Excalidraw')).toBeVisible();
});
});
// Editor / Canvas
test.describe.serial('editor', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('creates drawing with To-Do template', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Drawing' }).click();
await page.getByRole('button', { name: 'To-Do List' }).click();
await expect(page).toHaveURL(/\/drawing\//);
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
});
test('editor shows save controls and back button', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Drawing' }).click();
await page.getByRole('button', { name: 'Blank Canvas' }).click();
await expect(page).toHaveURL(/\/drawing\//);
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: /Back/i })).toBeVisible();
});
});
// Library Marketplace
test.describe.serial('library', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('loads marketplace with search and categories', async ({ page }) => {
await page.goto(BASE + '/library');
await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
await expect(page.getByPlaceholder('Search libraries...')).toBeVisible();
await expect(page.getByRole('button', { name: 'All' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Open External' })).toBeVisible();
});
test('search filters libraries', async ({ page }) => {
await page.goto(BASE + '/library');
await page.getByPlaceholder('Search libraries...').fill('zzzznonexistent');
await expect(page.getByText('No libraries found')).toBeVisible();
});
});
// Team / Invites
test.describe.serial('team', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('shows owner in members list', async ({ page }) => {
await page.goto(BASE + '/team');
await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
await expect(page.getByText('E2E User')).toBeVisible();
await expect(page.getByText('owner')).toBeVisible();
});
test('can send team invite', async ({ page }) => {
await page.goto(BASE + '/team');
await page.getByLabel('Email address').fill('invited@test.com');
await page.locator('select').selectOption('editor');
await page.getByRole('button', { name: 'Send Invite' }).click();
await expect(page.getByText('Invite sent!')).toBeVisible();
await expect(page.getByText('Pending Invites')).toBeVisible();
await expect(page.getByText('invited@test.com')).toBeVisible();
await expect(page.getByText('editor').first()).toBeVisible();
});
});
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Excalidraw FULL</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3879
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "excalidraw-full-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.17.6",
"clsx": "^2.1.0",
"date-fns": "^4.1.0",
"i18next": "^26.0.7",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-router-dom": "^7.0.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "1.52",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.6.0",
"@types/node": "^25.6.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^25.0.0",
"sass": "^1.81.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vitest": "^3.0.0"
}
}
+24
View File
@@ -0,0 +1,24 @@
/// <reference types="node" />
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
use: {
baseURL: 'http://localhost:3456',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'cd .. && (test -d frontend/dist/assets || (cd frontend && npm run build)) && JWT_SECRET=playwright-test-secret-32-chars-long-go EXCALIDRAW_BACKEND_HOST=localhost:3456 STORAGE_TYPE=postgres DATABASE_URL="${TEST_DATABASE_URL:-${DATABASE_URL:-postgres://excalidraw:excalidraw@localhost:5432/excalidraw?sslmode=disable}}" /tmp/excalidraw-e2e -listen :3456 -loglevel error',
url: 'http://localhost:3456',
timeout: 120 * 1000,
reuseExistingServer: false,
},
});
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000">
<rect width="1000" height="1000" rx="200" ry="200" fill="#fff" />
<svg viewBox="0 0 107 101" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
<path style="fill:none" d="M24 17h121v121H24z" transform="matrix(.8843 0 0 .83471 -21.223 -14.19)" />
<path d="M119.81 105.98a.549.549 0 0 0-.53-.12c-4.19-6.19-9.52-12.06-14.68-17.73l-.85-.93c0-.11-.05-.21-.12-.3a.548.548 0 0 0-.34-.2l-.17-.18-.12-.09c-.15-.32-.53-.56-.95-.35-1.58.81-3 1.97-4.4 3.04-1.87 1.43-3.7 2.92-5.42 4.52-.7.65-1.39 1.33-1.97 2.09-.28.37-.07.72.27.87-1.22 1.2-2.45 2.45-3.68 3.74-.11.12-.17.28-.16.44.01.16.09.31.22.41l2.16 1.65s.01.03.03.04c3.09 3.05 8.51 7.28 14.25 11.76.85.67 1.71 1.34 2.57 2.01.39.47.76.94 1.12 1.4.19.25.55.3.8.11.13.1.26.21.39.31a.57.57 0 0 0 .8-.1c.07-.09.1-.2.11-.31.04 0 .07.03.1.03.15 0 .31-.06.42-.18l10.18-11.12a.56.56 0 0 0-.04-.8l.01-.01Zm-29.23-3.85c.07.09.14.17.21.25 1.16.98 2.4 2.04 3.66 3.12l-5.12-3.91s-.32-.22-.52-.36c-.11-.08-.21-.16-.31-.24l-.38-.32s.07-.07.1-.11l.35-.35c1.72-1.74 4.67-4.64 6.19-6.06-1.61 1.62-4.87 6.37-4.17 7.98h-.01Zm17.53 13.81-4.22-3.22c-1.65-1.71-3.43-3.4-5.24-5.03 2.28 1.76 4.23 3.25 4.52 3.51 2.21 1.97 2.11 1.61 3.63 2.91l1.83 1.33c-.18.16-.36.33-.53.49l.01.01Zm1.06.81-.08-.06c.16-.13.33-.25.49-.38l-.4.44h-.01ZM42.24 51.45c.14.72.27 1.43.4 2.11.69 3.7 1.33 7.03 2.55 9.56l.48 1.92c.19.73.46 1.64.71 1.83 2.85 2.52 7.22 6.28 11.89 9.82.21.16.5.15.7-.01.01.02.03.03.04.04.11.1.24.15.38.15.16 0 .31-.06.42-.19 5.98-6.65 10.43-12.12 13.6-16.7.2-.25.3-.54.29-.84.2-.24.41-.48.6-.68a.558.558 0 0 0-.1-.86.578.578 0 0 0-.17-.36c-1.39-1.34-2.42-2.31-3.46-3.28-1.84-1.72-3.74-3.5-7.77-7.51-.02-.02-.05-.04-.07-.06a.555.555 0 0 0-.22-.14c-1.11-.39-3.39-.78-6.26-1.28-4.22-.72-10-1.72-15.2-3.27h-.04v-.01s-.02 0-.03.02h-.01l.04-.02s-.31.01-.37.04c-.08.04-.14.09-.19.15-.05.06-.09.12-.47.2-.38.08.08 0 .11 0h-.11v.03c.07.34.05.58.16.97-.02.1.21 1.02.24 1.11l1.83 7.26h.03Zm30.95 6.54s-.03.04-.04.05l-.64-.71c.22.21.44.42.68.66Zm-7.09 9.39s-.07.08-.1.12l-.02-.02c.04-.03.08-.07.13-.1h-.01Zm-7.07 8.47Zm3.02-28.57c.35.35 1.74 1.65 2.06 1.97-1.45-.66-5.06-2.34-6.74-2.88 1.65.29 3.93.66 4.68.91Zm-19.18-2.77c.84 1.44 1.5 6.49 2.16 11.4-.37-1.58-.69-3.12-.99-4.6-.52-2.56-1-4.85-1.67-6.88.14.01.31.03.49.05 0 .01 0 .02.02.03h-.01Zm-.29-1.21c-.23-.02-.44-.04-.62-.05-.02-.04-.03-.08-.04-.12l.66.18v-.01Zm-2.22.45v-.02.02ZM118.9 42.57c.04-.23-1.1-1.24-.74-1.26.85-.04.86-1.35 0-1.31-1.13.06-2.27.32-3.37.53-1.98.37-3.95.78-5.92 1.21-4.39.94-8.77 1.93-13.1 3.11-1.36.37-2.86.7-4.11 1.36-.42.22-.4.67-.17.95-.09.05-.18.08-.28.09-.37.07-.74.13-1.11.19a.566.566 0 0 0-.39.86c-2.32 3.1-4.96 6.44-7.82 9.95-2.81 3.21-5.73 6.63-8.72 10.14-9.41 11.06-20.08 23.6-31.9 34.64-.23.21-.24.57-.03.8.05.06.12.1.19.13-.16.15-.32.3-.48.44-.1.09-.14.2-.16.32-.08.08-.16.17-.23.25-.21.23-.2.59.03.8.23.21.59.2.8-.03.04-.04.08-.09.12-.13a.84.84 0 0 1 1.22 0c.69.74 1.34 1.44 1.95 2.09l-1.38-1.15a.57.57 0 0 0-.8.07c-.2.24-.17.6.07.8l14.82 12.43c.11.09.24.13.37.13.15 0 .29-.06.4-.17l.36-.36a.56.56 0 0 0 .63-.12c20.09-20.18 36.27-35.43 54.8-49.06.17-.12.25-.32.23-.51a.57.57 0 0 0 .48-.39c3.42-10.46 4.08-19.72 4.28-24.27 0-.03.01-.05.02-.07.02-.05.03-.1.04-.14.03-.11.05-.19.05-.19.26-.78.17-1.53-.15-2.15v.02ZM82.98 58.94c.9-1.03 1.79-2.04 2.67-3.02-5.76 7.58-15.3 19.26-28.81 33.14 9.2-10.18 18.47-20.73 26.14-30.12Zm-32.55 52.81-.03-.03c.11.02.19.04.2.04a.47.47 0 0 0-.17 0v-.01Zm6.9 6.42-.05-.04.03-.03c.02 0 .03.02.04.02 0 .02-.02.03-.03.05h.01Zm8.36-7.21 1.38-1.44c.01.01.02.03.03.05-.47.46-.94.93-1.42 1.39h.01Zm2.24-2.21c.26-.3.56-.65.87-1.02.01-.01.02-.03.04-.04 3.29-3.39 6.68-6.82 10.18-10.25.02-.02.05-.04.07-.06.86-.66 1.82-1.39 2.72-2.08-4.52 4.32-9.11 8.78-13.88 13.46v-.01Zm21.65-55.88c-1.86 2.42-3.9 5.56-5.63 8.07-5.46 7.91-23.04 27.28-23.43 27.65-2.71 2.62-10.88 10.46-16.09 15.37-.14.13-.25.24-.34.35a.794.794 0 0 1 .03-1.13c24.82-23.4 39.88-42.89 46-51.38-.13.33-.24.69-.55 1.09l.01-.02Zm16.51 7.1-.01.02c0-.02-.02-.07.01-.02Zm-.91-5.13Zm-5.89 9.45c-2.26-1.31-3.32-3.27-2.71-5.25l.19-.66c.08-.19.17-.38.28-.57.59-.98 1.49-1.85 2.52-2.36.05-.02.1-.03.15-.04a.795.795 0 0 1-.04-.43c.05-.31.25-.58.66-.58.67 0 2.75.62 3.54 1.3.24.19.47.4.68.63.3.35.74.92.96 1.33.13.06.23.62.38.91.14.46.2.93.18 1.4 0 .02 0 .02.01.03-.03.07 0 .37-.04.4-.1.72-.36 1.43-.75 2.05-.04.05-.07.11-.11.16 0 .01-.02.02-.03.04-.3.43-.65.83-1.08 1.13-1.26.89-2.73 1.16-4.2.79a6.33 6.33 0 0 1-.57-.25l-.02-.03Zm16.27-1.63c-.49 2.05-1.09 4.19-1.8 6.38-.03.08-.03.16-.03.23-.1.01-.19.05-.27.11-4.44 3.26-8.73 6.62-12.98 10.11 3.67-3.32 7.39-6.62 11.23-9.95a6.409 6.409 0 0 0 2.11-3.74l.56-3.37.03-.1c.25-.71 1.34-.4 1.17.33h-.02Z" style="fill:#6965db;fill-rule:nonzero" transform="matrix(1 0 0 1 -26.41 -29.49)" />
</svg>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+8
View File
@@ -0,0 +1,8 @@
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: var(--text-lg);
color: var(--color-muted);
}
+62
View File
@@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { AppLayout } from './components/Layout/AppLayout';
import { Dashboard } from './pages/Dashboard/Dashboard';
import { Login } from './pages/Auth/Login';
import { Signup } from './pages/Auth/Signup';
import { FileBrowser } from './pages/FileBrowser/FileBrowser';
import { TeamSettings } from './pages/Team/TeamSettings';
import { UserSettings } from './pages/Settings/UserSettings';
import { Editor } from './pages/Editor/Editor';
import { useAuthStore } from './stores';
import { useAuth } from './hooks';
import { CommandPalette } from './components';
import { api } from './services';
import './App.scss';
export const App: React.FC = () => {
useAuth(); // Initialize auth check
const { isAuthenticated, isLoading } = useAuthStore();
const [setupStatus, setSetupStatus] = useState<{ has_users: boolean } | null>(null);
const [setupLoading, setSetupLoading] = useState(true);
useEffect(() => {
if (!isAuthenticated && !isLoading) {
api.auth.setupStatus()
.then(setSetupStatus)
.catch(() => setSetupStatus({ has_users: true }))
.finally(() => setSetupLoading(false));
} else {
setSetupLoading(false);
}
}, [isAuthenticated, isLoading]);
if (isLoading || setupLoading) {
return <div className="loading-screen">Loading...</div>;
}
if (!isAuthenticated) {
const hasUsers = setupStatus?.has_users ?? true;
return (
<Routes>
<Route path="/login" element={hasUsers ? <Login hasUsers={hasUsers} /> : <Navigate to="/signup" replace />} />
<Route path="/signup" element={hasUsers ? <Navigate to="/login" replace /> : <Signup hasUsers={hasUsers} />} />
<Route path="*" element={<Navigate to={hasUsers ? "/login" : "/signup"} replace />} />
</Routes>
);
}
return (
<AppLayout>
<CommandPalette />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/files/*" element={<FileBrowser />} />
<Route path="/team" element={<TeamSettings />} />
<Route path="/settings" element={<UserSettings />} />
<Route path="/drawing/:id" element={<Editor />} />
<Route path="/folder/:folderId/drawing/:id" element={<Editor />} />
</Routes>
</AppLayout>
);
};
@@ -0,0 +1,157 @@
@use '../../styles/variables' as *;
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 0.625rem 1rem;
font-family: var(--ui-font);
font-size: var(--text-sm);
font-weight: 500;
line-height: 1.5;
border-radius: var(--border-radius-lg);
border: 1px solid transparent;
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
text-decoration: none;
white-space: nowrap;
user-select: none;
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--color-primary-light);
}
&:active {
transform: scale(0.98);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
// Size variants
&.size-sm {
padding: 0.375rem 0.75rem;
font-size: var(--text-xs);
}
&.size-lg {
padding: 0.75rem 1.5rem;
font-size: var(--text-base);
}
// Full width
&.fullWidth {
width: 100%;
}
}
// Primary variant
.variant-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
&:hover:not(:disabled) {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
&:active {
background: var(--color-primary-darkest);
border-color: var(--color-primary-darkest);
}
}
// Secondary variant
.variant-secondary {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-color: var(--color-surface-high);
&:hover:not(:disabled) {
background: var(--color-surface-high);
border-color: var(--color-gray-30);
}
&:active {
background: var(--color-gray-20);
}
}
// Ghost variant
.variant-ghost {
background: transparent;
color: var(--color-on-surface);
border-color: transparent;
&:hover:not(:disabled) {
background: var(--color-surface-low);
}
&:active {
background: var(--color-surface-high);
}
}
// Danger variant
.variant-danger {
background: var(--color-danger);
color: white;
border-color: var(--color-danger);
&:hover:not(:disabled) {
background: var(--color-danger-dark);
border-color: var(--color-danger-dark);
}
&:active {
background: var(--color-danger-darker);
border-color: var(--color-danger-darker);
}
}
// Icon only
.iconOnly {
padding: 0.5rem;
&.size-sm {
padding: 0.375rem;
}
&.size-lg {
padding: 0.625rem;
}
}
// Loading state
.loading {
position: relative;
color: transparent !important;
&::after {
content: '';
position: absolute;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { clsx } from 'clsx';
import styles from './Button.module.scss';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
fullWidth?: boolean;
children: React.ReactNode;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({
variant = 'primary',
size = 'md',
loading = false,
fullWidth = false,
children,
className,
disabled,
...props
}, ref) => {
const isIconOnly = React.Children.count(children) === 1 &&
React.isValidElement(children) &&
(children.type === 'svg' || String(children.type).includes('Icon'));
return (
<button
ref={ref}
className={clsx(
styles.button,
styles[`variant-${variant}`],
styles[`size-${size}`],
{
[styles.loading]: loading,
[styles.fullWidth]: fullWidth,
[styles.iconOnly]: isIconOnly,
},
className
)}
disabled={disabled || loading}
{...props}
>
{children}
</button>
);
}
);
Button.displayName = 'Button';
+2
View File
@@ -0,0 +1,2 @@
export { Button } from './Button';
export type { ButtonProps } from './Button';
@@ -0,0 +1,34 @@
@use '../../styles/variables' as *;
.card {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
overflow: hidden;
transition: transform var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.hover {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-island-stronger);
}
}
.header {
padding: var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
}
.content {
padding: var(--space-4);
}
.footer {
padding: var(--space-4);
border-top: 1px solid var(--color-gray-20);
background: var(--color-surface-low);
}
@@ -0,0 +1,22 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Card } from './Card';
describe('Card', () => {
it('renders children', () => {
render(<Card>Hello World</Card>);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('handles click events', () => {
const onClick = vi.fn();
render(<Card onClick={onClick}>Click me</Card>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
it('forwards aria-label', () => {
render(<Card aria-label="Test card">Content</Card>);
expect(screen.getByLabelText('Test card')).toBeInTheDocument();
});
});
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import styles from './Card.module.scss';
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
className?: string;
onClick?: () => void;
hover?: boolean;
}
export const Card: React.FC<CardProps> = ({ children, className, onClick, hover = true, ...rest }) => {
return (
<div
className={`${styles.card} ${hover ? styles.hover : ''} ${className || ''}`}
onClick={onClick}
role={onClick ? 'button' : rest.role}
{...rest}
>
{children}
</div>
);
};
export const CardHeader: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
<div className={`${styles.header} ${className || ''}`}>{children}</div>
);
export const CardContent: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
<div className={`${styles.content} ${className || ''}`}>{children}</div>
);
export const CardFooter: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
<div className={`${styles.footer} ${className || ''}`}>{children}</div>
);
@@ -0,0 +1,126 @@
@use '../../styles/variables' as *;
.panel {
width: 340px;
border-left: 1px solid var(--color-gray-20);
background: var(--island-bg-color);
display: flex;
flex-direction: column;
height: 100%;
@media (max-width: 768px) {
position: fixed;
right: 0;
top: var(--header-height);
bottom: 0;
width: 100%;
z-index: 90;
border-left: none;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
}
.title {
display: flex;
align-items: center;
gap: var(--space-2);
font-weight: 600;
font-size: var(--text-sm);
color: var(--color-gray-85);
}
.closeBtn {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--border-radius-md);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.messages {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.message {
display: flex;
gap: var(--space-2);
align-items: flex-start;
}
.user {
flex-direction: row-reverse;
.bubble {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
}
}
.assistant .bubble {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
.avatar {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--border-radius-full);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-gray-20);
color: var(--color-muted);
flex-shrink: 0;
}
.bubble {
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-lg);
font-size: var(--text-sm);
line-height: 1.5;
max-width: 260px;
word-wrap: break-word;
}
.inputRow {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-gray-20);
align-items: center;
}
.chatInput {
flex: 1;
input {
font-size: var(--text-sm);
}
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@ -0,0 +1,116 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Send, X, Bot, User, Loader2 } from 'lucide-react';
import { Button, Input } from '@/components';
import styles from './ChatPanel.module.scss';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
interface ChatPanelProps {
onClose: () => void;
drawingContext?: string;
}
export const ChatPanel: React.FC<ChatPanelProps> = ({ onClose, drawingContext }) => {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'assistant', content: 'I can help you create or refine diagrams. What would you like to do?' },
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages]);
const handleSend = useCallback(async () => {
if (!input.trim() || isLoading) return;
const userMsg = input.trim();
setInput('');
setMessages((prev) => [...prev, { role: 'user', content: userMsg }]);
setIsLoading(true);
try {
const systemPrompt = drawingContext
? `You are an AI assistant for Excalidraw. The user is working on a diagram. Context: ${drawingContext}. Help them create, refine, or explain their diagram. Respond with concise, actionable suggestions. When suggesting diagram structures, describe elements and their layout clearly.`
: 'You are an AI assistant for Excalidraw. Help users create, refine, or explain diagrams. Respond with concise, actionable suggestions.';
const res = await fetch('/api/v2/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
...messages.slice(-6).map((m) => ({ role: m.role, content: m.content })),
{ role: 'user', content: userMsg },
],
max_tokens: 800,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const assistantContent = data.choices?.[0]?.message?.content || 'Sorry, I could not generate a response.';
setMessages((prev) => [...prev, { role: 'assistant', content: assistantContent }]);
} catch (err) {
setMessages((prev) => [
...prev,
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again later.' },
]);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages, drawingContext]);
return (
<div className={styles.panel} role="complementary" aria-label="AI chat panel">
<div className={styles.header}>
<div className={styles.title}>
<Bot size={18} aria-hidden="true" />
<span>AI Assistant</span>
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close chat panel">
<X size={16} />
</button>
</div>
<div className={styles.messages} ref={scrollRef} role="log" aria-live="polite" aria-atomic="false">
{messages.map((msg, i) => (
<div
key={i}
className={`${styles.message} ${msg.role === 'user' ? styles.user : styles.assistant}`}
>
<div className={styles.avatar} aria-hidden="true">
{msg.role === 'user' ? <User size={14} /> : <Bot size={14} />}
</div>
<div className={styles.bubble}>{msg.content}</div>
</div>
))}
{isLoading && (
<div className={`${styles.message} ${styles.assistant}`}>
<div className={styles.avatar} aria-hidden="true"><Bot size={14} /></div>
<div className={styles.bubble}><Loader2 size={16} className={styles.spinner} /></div>
</div>
)}
</div>
<div className={styles.inputRow}>
<Input
className={styles.chatInput}
placeholder="Ask about your diagram..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
aria-label="Chat input"
/>
<Button size="sm" onClick={handleSend} disabled={isLoading || !input.trim()} aria-label="Send message">
<Send size={16} />
</Button>
</div>
</div>
);
};
@@ -0,0 +1,115 @@
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
}
.dialog {
width: 100%;
max-width: 560px;
background: var(--color-surface-lowest);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
border: 1px solid var(--color-gray-20);
}
.inputRow {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
}
.inputIcon {
color: var(--color-gray-50);
flex-shrink: 0;
}
.input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: var(--text-base);
color: var(--color-gray-95);
padding: 0;
&::placeholder {
color: var(--color-gray-50);
}
}
.kbd {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-gray-50);
background: var(--color-gray-10);
border: 1px solid var(--color-gray-20);
border-radius: var(--radius-sm);
padding: 2px 6px;
flex-shrink: 0;
}
.list {
max-height: 320px;
overflow-y: auto;
padding: var(--space-2);
}
.item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.1s ease;
&:hover,
&.selected {
background: var(--color-gray-10);
}
}
.itemIcon {
color: var(--color-gray-60);
flex-shrink: 0;
}
.itemLabel {
flex: 1;
font-size: var(--text-sm);
font-weight: 500;
}
.itemShortcut {
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-gray-50);
background: var(--color-gray-10);
border: 1px solid var(--color-gray-20);
border-radius: var(--radius-sm);
padding: 1px 5px;
}
.empty {
padding: var(--space-6);
text-align: center;
font-size: var(--text-sm);
color: var(--color-gray-50);
}
@@ -0,0 +1,179 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, Command, FileText, FolderOpen, Users, Settings, FileCode, LayoutDashboard } from 'lucide-react';
import styles from './CommandPalette.module.scss';
interface CommandItem {
id: string;
label: string;
shortcut?: string;
icon?: React.ElementType;
action: () => void;
}
export const CommandPalette: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const commands: CommandItem[] = [
{
id: 'dashboard',
label: t('sidebar.dashboard'),
icon: LayoutDashboard,
action: () => navigate('/'),
},
{
id: 'files',
label: t('sidebar.files'),
icon: FolderOpen,
action: () => navigate('/files'),
},
{
id: 'templates',
label: t('sidebar.templates'),
icon: FileCode,
action: () => navigate('/templates'),
},
{
id: 'team',
label: t('sidebar.team'),
icon: Users,
action: () => navigate('/team'),
},
{
id: 'settings',
label: t('sidebar.settings'),
icon: Settings,
action: () => navigate('/settings'),
},
{
id: 'new-drawing',
label: t('dashboard.newDrawing'),
icon: FileText,
action: () => navigate('/drawing/new'),
},
];
const filtered = query.trim()
? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase()))
: commands;
const openPalette = useCallback(() => {
setIsOpen(true);
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 0);
}, []);
const closePalette = useCallback(() => {
setIsOpen(false);
setQuery('');
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
openPalette();
}
if (e.key === 'Escape') {
closePalette();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [openPalette, closePalette]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const cmd = filtered[selectedIndex];
if (cmd) {
cmd.action();
closePalette();
}
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen, filtered, selectedIndex, closePalette]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
if (!isOpen) return null;
return (
<div
className={styles.overlay}
onClick={closePalette}
role="dialog"
aria-modal="true"
aria-label="Command palette"
>
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
<div className={styles.inputRow}>
<Search size={18} className={styles.inputIcon} aria-hidden="true" />
<input
ref={inputRef}
type="text"
className={styles.input}
placeholder={t('commandPalette.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
aria-label="Search commands"
aria-autocomplete="list"
aria-controls="command-list"
aria-activedescendant={filtered[selectedIndex] ? `cmd-${filtered[selectedIndex].id}` : undefined}
/>
<span className={styles.kbd} aria-label="Keyboard shortcut">
<Command size={12} aria-hidden="true" /> K
</span>
</div>
<div ref={listRef} className={styles.list} id="command-list" role="listbox">
{filtered.length === 0 ? (
<div className={styles.empty}>{t('commandPalette.noResults')}</div>
) : (
filtered.map((cmd, index) => {
const Icon = cmd.icon;
return (
<button
key={cmd.id}
id={`cmd-${cmd.id}`}
className={`${styles.item} ${index === selectedIndex ? styles.selected : ''}`}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
cmd.action();
closePalette();
}}
role="option"
aria-selected={index === selectedIndex}
>
{Icon && <Icon size={16} className={styles.itemIcon} aria-hidden="true" />}
<span className={styles.itemLabel}>{cmd.label}</span>
{cmd.shortcut && <span className={styles.itemShortcut}>{cmd.shortcut}</span>}
</button>
);
})
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,64 @@
@use '../../styles/variables' as *;
.wrapper {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--input-label-color);
}
.input {
padding: 0.5rem 0.75rem;
font-size: var(--text-base);
font-family: var(--ui-font);
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
&:hover:not(:focus):not(:disabled) {
background: var(--input-hover-bg-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&::placeholder {
color: var(--color-gray-50);
}
}
.error {
border-color: var(--color-danger);
&:focus {
border-color: var(--color-danger);
box-shadow: 0 0 0 3px var(--color-danger-background);
}
}
.errorText {
font-size: var(--text-xs);
color: var(--color-danger);
}
.helperText {
font-size: var(--text-xs);
color: var(--color-muted);
}
+45
View File
@@ -0,0 +1,45 @@
import React, { forwardRef } from 'react';
import styles from './Input.module.scss';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, id, ...props }, ref) => {
const inputId = id || React.useId();
return (
<div className={styles.wrapper}>
{label && (
<label htmlFor={inputId} className={styles.label}>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={`${styles.input} ${error ? styles.error : ''} ${className || ''}`}
aria-invalid={error ? 'true' : undefined}
aria-describedby={
error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined
}
{...props}
/>
{error && (
<span id={`${inputId}-error`} className={styles.errorText} role="alert">
{error}
</span>
)}
{helperText && !error && (
<span id={`${inputId}-helper`} className={styles.helperText}>
{helperText}
</span>
)}
</div>
);
}
);
Input.displayName = 'Input';
@@ -0,0 +1,39 @@
import React, { useState, useCallback } from 'react';
import { Menu } from 'lucide-react';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import styles from './Layout.module.scss';
export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const openSidebar = useCallback(() => setSidebarOpen(true), []);
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
return (
<div className={styles.layout}>
<Sidebar open={sidebarOpen} onClose={closeSidebar} />
{sidebarOpen && (
<div
className={styles.sidebarOverlay}
onClick={closeSidebar}
role="presentation"
aria-hidden="true"
/>
)}
<div className={styles.main}>
<Header>
<button
className={styles.mobileMenuToggle}
onClick={openSidebar}
aria-label="Open menu"
aria-expanded={sidebarOpen}
aria-controls="app-sidebar"
>
<Menu size={20} />
</button>
</Header>
<div className={styles.content}>{children}</div>
</div>
</div>
);
};
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, Bell, Plus, FileText, Loader2, Sun, Moon } from 'lucide-react';
import { Button } from '@/components';
import { useThemeStore } from '@/stores';
import { api } from '@/services';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { Drawing } from '@/types';
import styles from './Layout.module.scss';
export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { theme, toggleTheme } = useThemeStore();
const [query, setQuery] = useState('');
const [results, setResults] = useState<Drawing[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const performSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
return;
}
setIsSearching(true);
try {
const res = await api.search.get(q);
setResults(res);
} catch (err) {
console.error('Search failed:', err);
setResults([]);
} finally {
setIsSearching(false);
}
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
setShowResults(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => performSearch(val), 250);
};
const handleSelect = (drawing: Drawing) => {
setQuery('');
setResults([]);
setShowResults(false);
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
};
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!searchRef.current?.contains(e.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
return (
<header className={styles.header}>
{children}
<div className={styles.search} ref={searchRef} role="search" aria-label="Search drawings">
<Search size={18} />
<input
type="text"
placeholder={t('common.search') + '...'}
value={query}
onChange={handleChange}
onFocus={() => query && setShowResults(true)}
aria-label="Search drawings"
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={showResults}
/>
{isSearching && <Loader2 size={14} className={styles.searchSpinner} />}
{showResults && (query.trim() || results.length > 0) && (
<div id="search-results" className={styles.searchDropdown} role="listbox">
{results.length === 0 ? (
<div className={styles.searchEmpty}>
{isSearching ? t('common.loading') : t('search.noResults')}
</div>
) : (
results.map((drawing) => (
<button
key={drawing.id}
className={styles.searchResult}
onClick={() => handleSelect(drawing)}
role="option"
aria-label={`Open drawing ${drawing.title}`}
>
<FileText size={14} aria-hidden="true" />
<span className={styles.searchResultTitle}>{drawing.title}</span>
{drawing.owner?.name && (
<span className={styles.searchResultMeta}>{drawing.owner.name}</span>
)}
</button>
))
)}
</div>
)}
</div>
<div className={styles.actions}>
<button className={styles.iconButton} onClick={toggleTheme} title={t('userSettings.theme')} aria-label={t('userSettings.theme')}>
{theme === 'light' ? <Sun size={20} aria-hidden="true" /> : <Moon size={20} aria-hidden="true" />}
</button>
<button className={styles.iconButton} aria-label="Notifications" title="Notifications">
<Bell size={20} aria-hidden="true" />
</button>
<Button>
<Plus size={18} />
{t('dashboard.newDrawing')}
</Button>
</div>
</header>
);
};
@@ -0,0 +1,340 @@
@use '../../styles/variables' as *;
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-width);
background: var(--island-bg-color);
border-right: 1px solid var(--color-gray-20);
display: flex;
flex-direction: column;
padding: var(--space-4);
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transition: transform var(--duration-normal) var(--ease-out);
@media (max-width: 768px) {
transform: translateX(-100%);
&.open {
transform: translateX(0);
}
}
}
.sidebarOverlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 99;
@media (max-width: 768px) {
display: block;
}
}
.mobileMenuToggle {
display: none;
background: none;
border: none;
color: var(--color-gray-70);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
@media (max-width: 768px) {
display: flex;
align-items: center;
justify-content: center;
}
}
.sidebarHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-8);
padding: var(--space-2) 0;
}
.logo {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.logoImg {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.sidebarCloseBtn {
display: none;
background: none;
border: none;
color: var(--color-gray-70);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
@media (max-width: 768px) {
display: flex;
align-items: center;
justify-content: center;
}
}
.nav {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex: 1;
}
.navItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
color: var(--color-gray-70);
text-decoration: none;
border-radius: var(--border-radius-md);
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
}
.footer {
border-top: 1px solid var(--color-gray-20);
padding-top: var(--space-4);
margin-top: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
}
.user {
display: flex;
align-items: center;
gap: var(--space-3);
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--text-sm);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.userName {
font-size: var(--text-sm);
color: var(--color-gray-70);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logout {
background: none;
border: none;
color: var(--color-gray-50);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-danger);
}
}
.main {
flex: 1;
margin-left: var(--sidebar-width);
display: flex;
flex-direction: column;
@media (max-width: 768px) {
margin-left: 0;
}
}
.header {
height: var(--header-height);
background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
position: sticky;
top: 0;
z-index: 50;
@media (max-width: 768px) {
padding: 0 var(--space-4);
}
}
.search {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-4);
width: 400px;
transition: all var(--duration-fast) var(--ease-out);
@media (max-width: 768px) {
width: auto;
flex: 1;
min-width: 0;
}
input {
border: none;
background: transparent;
outline: none;
font-size: var(--text-sm);
color: var(--color-on-surface);
width: 100%;
&::placeholder {
color: var(--color-gray-50);
}
}
&:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.iconButton {
background: none;
border: none;
color: var(--color-gray-60);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.content {
flex: 1;
padding: var(--space-6);
overflow: auto;
}
.searchSpinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.searchDropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-gray-20);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
max-height: 300px;
overflow-y: auto;
z-index: 100;
}
.searchResult {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.1s ease;
&:hover {
background: var(--color-gray-10);
}
}
.searchResultTitle {
flex: 1;
font-size: var(--text-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.searchResultMeta {
font-size: 11px;
color: var(--color-gray-50);
}
.searchEmpty {
padding: var(--space-4);
text-align: center;
font-size: var(--text-sm);
color: var(--color-gray-50);
}
@@ -0,0 +1,92 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
LayoutDashboard,
FolderOpen,
Users,
Settings,
LogOut,
X,
} from 'lucide-react';
import { useAuthStore } from '@/stores';
import styles from './Layout.module.scss';
interface SidebarProps {
open?: boolean;
onClose?: () => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ open, onClose }) => {
const { t } = useTranslation();
const { user, logout } = useAuthStore();
const navItems = [
{ to: '/', icon: LayoutDashboard, label: t('sidebar.dashboard') },
{ to: '/files', icon: FolderOpen, label: t('sidebar.projects') },
{ to: '/team', icon: Users, label: t('sidebar.team') },
{ to: '/settings', icon: Settings, label: t('sidebar.settings') },
];
return (
<aside
id="app-sidebar"
className={`${styles.sidebar} ${open ? styles.open : ''}`}
role="navigation"
aria-label="Main navigation"
>
<div className={styles.sidebarHeader}>
<div className={styles.logo}>
<img src="https://plus.excalidraw.com/images/logo.svg" alt="Excalidraw" className={styles.logoImg} />
</div>
{onClose && (
<button
className={styles.sidebarCloseBtn}
onClick={onClose}
aria-label="Close menu"
>
<X size={20} />
</button>
)}
</div>
<nav className={styles.nav}>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`${styles.navItem} ${isActive ? styles.active : ''}`
}
onClick={onClose}
aria-label={item.label}
>
<item.icon size={20} aria-hidden="true" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className={styles.footer}>
<div className={styles.user}>
<div className={styles.avatar}>
{user?.avatar_url ? (
<img src={user.avatar_url} alt={user.name} />
) : (
user?.name?.[0] || '?'
)}
</div>
<span className={styles.userName}>{user?.name}</span>
</div>
<button
className={styles.logout}
onClick={logout}
aria-label="Log out"
title="Log out"
>
<LogOut size={18} aria-hidden="true" />
</button>
</div>
</aside>
);
};
@@ -0,0 +1,135 @@
@use '../../styles/variables' as *;
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 420px;
padding: var(--space-6);
animation: slideUp 0.2s ease;
}
.header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
}
.iconWarning {
color: var(--color-warning-darker);
}
.iconDanger {
color: var(--color-danger);
}
.iconInfo {
color: var(--color-primary);
}
.title {
flex: 1;
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-on-surface);
margin: 0;
}
.closeBtn {
background: none;
border: none;
color: var(--color-gray-60);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--border-radius-md);
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.message {
font-size: var(--text-sm);
color: var(--color-gray-70);
line-height: 1.5;
margin-bottom: var(--space-6);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.btnPrimary,
.btnSecondary,
.btnDanger {
padding: var(--space-2) var(--space-4);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
border: none;
transition: all var(--duration-fast) var(--ease-out);
}
.btnPrimary {
background: var(--color-primary);
color: white;
&:hover {
background: var(--color-primary-hover);
}
}
.btnSecondary {
background: var(--color-surface-low);
color: var(--color-on-surface);
border: 1px solid var(--color-gray-20);
&:hover {
background: var(--color-gray-10);
}
}
.btnDanger {
background: var(--color-danger);
color: white;
&:hover {
background: var(--color-danger-dark);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
+101
View File
@@ -0,0 +1,101 @@
import React, { useEffect, useRef } from 'react';
import { X, AlertTriangle, Info } from 'lucide-react';
import styles from './Modal.module.scss';
export type ModalType = 'confirm' | 'alert' | 'info';
interface ModalProps {
isOpen: boolean;
title: string;
message: string;
type?: ModalType;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
onClose?: () => void;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
title,
message,
type = 'info',
confirmText = 'OK',
cancelText = 'Cancel',
onConfirm,
onCancel,
onClose,
}) => {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel?.() ?? onClose?.();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKey);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = '';
};
}, [isOpen, onCancel, onClose]);
if (!isOpen) return null;
const iconMap = {
confirm: <AlertTriangle size={24} className={styles.iconWarning} />,
alert: <AlertTriangle size={24} className={styles.iconDanger} />,
info: <Info size={24} className={styles.iconInfo} />,
};
return (
<div
ref={overlayRef}
className={styles.overlay}
onClick={(e) => {
if (e.target === overlayRef.current) {
onCancel?.() ?? onClose?.();
}
}}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className={styles.modal}>
<div className={styles.header}>
<div className={styles.icon}>{iconMap[type]}</div>
<h3 id="modal-title" className={styles.title}>{title}</h3>
<button
className={styles.closeBtn}
onClick={() => onCancel?.() ?? onClose?.()}
aria-label="Close"
>
<X size={18} />
</button>
</div>
<p className={styles.message}>{message}</p>
<div className={styles.actions}>
{type === 'confirm' && (
<button
className={styles.btnSecondary}
onClick={() => onCancel?.() ?? onClose?.()}
>
{cancelText}
</button>
)}
<button
className={type === 'alert' ? styles.btnDanger : styles.btnPrimary}
onClick={() => onConfirm?.() ?? onClose?.()}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,92 @@
@use '../../styles/variables' as *;
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
}
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 720px;
max-height: 80vh;
overflow-y: auto;
padding: var(--space-6);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
h2 {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-xl);
font-weight: 600;
margin: 0;
}
}
.closeBtn {
background: none;
border: none;
cursor: pointer;
color: var(--color-gray-60);
padding: var(--space-2);
border-radius: var(--border-radius-md);
&:hover {
background: var(--color-gray-10);
color: var(--color-gray-90);
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-4);
}
.card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-6) var(--space-4);
cursor: pointer;
border: 2px solid transparent;
transition: all var(--duration-fast);
&:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
}
}
.iconWrap {
color: var(--color-primary);
margin-bottom: var(--space-3);
}
.title {
font-weight: 600;
margin: 0 0 var(--space-1);
font-size: var(--text-base);
}
.desc {
font-size: var(--text-sm);
color: var(--color-gray-60);
margin: 0;
}
@@ -0,0 +1,173 @@
import React from 'react';
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool } from 'lucide-react';
import { Card } from '@/components';
import styles from './TemplatePicker.module.scss';
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow';
interface TemplatePickerProps {
isOpen: boolean;
onClose: () => void;
onSelect: (template: PickedTemplate) => void;
}
interface TemplateOption {
id: PickedTemplate;
label: string;
description: string;
icon: React.ElementType;
elements: any[];
}
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string) {
return {
id: `el-${Math.random().toString(36).slice(2)}`,
type: 'rectangle',
x, y, width: w, height: h,
angle: 0,
strokeColor: '#1e1e1e',
backgroundColor: 'transparent',
fillStyle: 'hachure',
strokeWidth: 1,
strokeStyle: 'solid',
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3, value: 32 },
seed: Math.floor(Math.random() * 10000),
version: 2,
versionNonce: Math.floor(Math.random() * 100000),
isDeleted: false,
boundElements: text ? [{ id: `txt-${Math.random().toString(36).slice(2)}`, type: 'text' }] : [],
updated: Date.now(),
link: null,
locked: false,
};
}
function makeText(x: number, y: number, text: string, fontSize = 20) {
return {
id: `txt-${Math.random().toString(36).slice(2)}`,
type: 'text',
x, y, width: text.length * (fontSize * 0.55), height: fontSize * 1.4,
angle: 0,
strokeColor: '#1e1e1e',
backgroundColor: 'transparent',
fillStyle: 'hachure',
strokeWidth: 1,
strokeStyle: 'solid',
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: null,
seed: Math.floor(Math.random() * 10000),
version: 2,
versionNonce: Math.floor(Math.random() * 100000),
isDeleted: false,
boundElements: [],
updated: Date.now(),
link: null,
locked: false,
text,
fontSize,
fontFamily: 1,
textAlign: 'left',
verticalAlign: 'top',
baseline: 18,
containerId: null,
originalText: text,
lineHeight: 1.25,
};
}
function makeCheckbox(x: number, y: number, checked = false) {
const box = makeHandDrawnRect(x, y, 20, 20);
(box as any).backgroundColor = checked ? '#a5eba8' : 'transparent';
return box;
}
export const BUILTIN_TEMPLATES: Record<PickedTemplate, any[]> = {
blank: [],
todo: [
makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'To-Do List', 28),
makeCheckbox(60, 130, false),
makeText(90, 130, 'First task'),
makeCheckbox(60, 170, false),
makeText(90, 170, 'Second task'),
makeCheckbox(60, 210, false),
makeText(90, 210, 'Third task'),
makeHandDrawnRect(50, 280, 500, 2),
makeText(60, 300, 'Notes:', 18),
],
checklist: [
makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'Checklist', 28),
makeCheckbox(60, 130, true),
makeText(90, 130, 'Completed item', 18),
makeCheckbox(60, 170, false),
makeText(90, 170, 'Pending item', 18),
makeCheckbox(60, 210, false),
makeText(90, 210, 'Another task', 18),
makeHandDrawnRect(60, 250, 480, 1),
makeText(70, 265, 'Add more items below', 14),
],
list: [
makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'Bullet List', 28),
makeText(60, 130, '- First bullet point'),
makeText(60, 170, '- Second bullet point'),
makeText(60, 210, '- Third bullet point'),
makeText(60, 250, '- Fourth item with details'),
makeHandDrawnRect(50, 300, 500, 2),
makeText(60, 320, 'Add your own items...', 14),
],
flow: [
makeHandDrawnRect(200, 50, 200, 60),
makeText(230, 70, 'Start', 20),
makeHandDrawnRect(200, 150, 200, 60),
makeText(220, 170, 'Process A', 20),
makeHandDrawnRect(200, 250, 200, 60),
makeText(220, 270, 'Process B', 20),
makeHandDrawnRect(200, 350, 200, 60),
makeText(230, 370, 'End', 20),
],
};
const OPTIONS: TemplateOption[] = [
{ id: 'blank', label: 'Blank Canvas', description: 'Start with an empty canvas', icon: PenTool, elements: [] },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks with a title', icon: ListTodo, elements: [] },
{ id: 'checklist', label: 'Checklist', description: 'Simple checklist with status', icon: CheckSquare, elements: [] },
{ id: 'list', label: 'Bullet List', description: 'Bulleted list with notes area', icon: List, elements: [] },
{ id: 'flow', label: 'Flow Chart', description: 'Simple process flow diagram', icon: ArrowRight, elements: [] },
];
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
if (!isOpen) return null;
return (
<div className={styles.overlay} role="dialog" aria-modal="true" aria-labelledby="template-title" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className={styles.modal}>
<div className={styles.header}>
<h2 id="template-title"><LayoutTemplate size={20} /> Choose a Template</h2>
<button onClick={onClose} className={styles.closeBtn} aria-label="Close"><X size={18} /></button>
</div>
<div className={styles.grid}>
{OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<Card key={opt.id} className={styles.card} hover onClick={() => onSelect(opt.id)} role="button" tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(opt.id); }}>
<div className={styles.iconWrap}><Icon size={32} /></div>
<h3 className={styles.title}>{opt.label}</h3>
<p className={styles.desc}>{opt.description}</p>
</Card>
);
})}
</div>
</div>
</div>
);
};
+11
View File
@@ -0,0 +1,11 @@
export { Button } from './Button/Button';
export { Card, CardHeader, CardContent } from './Card/Card';
export { Input } from './Input/Input';
export { AppLayout } from './Layout/AppLayout';
export { CommandPalette } from './CommandPalette/CommandPalette';
export { TemplatePicker } from './TemplatePicker/TemplatePicker';
export { ChatPanel } from './ChatPanel/ChatPanel';
export { Header } from './Layout/Header';
export { Sidebar } from './Layout/Sidebar';
export { Modal } from './Modal/Modal';
export type { PickedTemplate } from './TemplatePicker/TemplatePicker';
+3
View File
@@ -0,0 +1,3 @@
export { useAuth } from './useAuth';
export { useDrawings } from './useDrawings';
export { useTeams } from './useTeams';
+42
View File
@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useAuthStore } from '@/stores';
import { api } from '@/services';
export function useAuth() {
const { setUser, setSession, setLoading, logout, isAuthenticated } = useAuthStore();
useEffect(() => {
const init = async () => {
try {
const user = await api.auth.me();
setUser(user);
} catch {
// Not logged in
} finally {
setLoading(false);
}
};
init();
}, [setUser, setLoading]);
const login = async (email: string, password: string) => {
const { user, session } = await api.auth.login(email, password);
setUser(user);
setSession(session);
return user;
};
const signup = async (name: string, email: string, password: string) => {
const { user, session } = await api.auth.signup(name, email, password);
setUser(user);
setSession(session);
return user;
};
const doLogout = async () => {
await api.auth.logout();
logout();
};
return { login, signup, logout: doLogout, isAuthenticated };
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useCallback } from 'react';
import { useDrawingStore, useTeamStore } from '@/stores';
import { api } from '@/services';
export function useDrawings() {
const { drawings, recentDrawings, setDrawings, setRecentDrawings, setLoading, addDrawing, updateDrawing } = useDrawingStore();
const { currentTeam } = useTeamStore();
const fetchDrawings = useCallback(async () => {
setLoading(true);
try {
const data = await api.drawings.list(currentTeam?.id);
setDrawings(data);
setRecentDrawings(data.slice(0, 10));
} finally {
setLoading(false);
}
}, [currentTeam?.id, setDrawings, setRecentDrawings, setLoading]);
useEffect(() => {
fetchDrawings();
}, [fetchDrawings]);
const createDrawing = async (title: string, folderId?: string) => {
const drawing = await api.drawings.create({
title,
folder_id: folderId,
team_id: currentTeam?.id,
});
addDrawing(drawing);
return drawing;
};
return { drawings, recentDrawings, fetchDrawings, createDrawing, updateDrawing };
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useCallback } from 'react';
import { useTeamStore } from '@/stores';
import { api } from '@/services';
export function useTeams() {
const { teams, members, setTeams, setMembers, setLoading, setCurrentTeam } = useTeamStore();
const fetchTeams = useCallback(async () => {
setLoading(true);
try {
const data = await api.teams.list();
setTeams(data);
if (data.length > 0 && !useTeamStore.getState().currentTeam) {
setCurrentTeam(data[0]);
}
} finally {
setLoading(false);
}
}, [setTeams, setCurrentTeam, setLoading]);
const fetchMembers = useCallback(async (teamId: string) => {
const data = await api.teams.members(teamId);
setMembers(data);
}, [setMembers]);
useEffect(() => {
fetchTeams();
}, [fetchTeams]);
return { teams, members, fetchTeams, fetchMembers, setCurrentTeam };
}
+23
View File
@@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
},
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;
+139
View File
@@ -0,0 +1,139 @@
{
"app": {
"name": "Excalidraw FULL"
},
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"create": "Create",
"edit": "Edit",
"back": "Back",
"search": "Search",
"submit": "Submit",
"close": "Close",
"confirm": "Confirm",
"error": "Error",
"success": "Success",
"or": "or",
"continueWith": "or continue with"
},
"auth": {
"login": {
"title": "Welcome back",
"subtitle": "Sign in to your Excalidraw FULL account",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter your password",
"signIn": "Sign In",
"noAccount": "Don't have an account?",
"signUpLink": "Sign up",
"errorInvalid": "Invalid email or password"
},
"signup": {
"title": "Create account",
"subtitle": "Start your visual workspace journey",
"nameLabel": "Full Name",
"namePlaceholder": "John Doe",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"passwordLabel": "Password",
"passwordPlaceholder": "Create a strong password",
"createAccount": "Create Account",
"hasAccount": "Already have an account?",
"signInLink": "Sign in",
"errorCreate": "Could not create account"
},
"oauth": {
"github": "GitHub"
}
},
"sidebar": {
"dashboard": "Dashboard",
"files": "Files",
"projects": "Projects",
"templates": "Templates",
"library": "Library",
"team": "Team",
"settings": "Settings"
},
"dashboard": {
"welcome": "Welcome back, {{name}}",
"subtitle": "Here's what's happening in your workspace",
"newDrawing": "New Drawing",
"creating": "Creating...",
"stats": {
"drawings": "Drawings",
"projects": "Projects",
"folders": "Folders",
"teams": "Teams",
"revisions": "Revisions",
"storage": "Storage"
},
"recentDrawings": "Recent Drawings",
"noDrawings": "No recent drawings",
"noDrawingsSub": "Create your first drawing to get started"
},
"editor": {
"back": "Back",
"saveNow": "Save Now",
"loadingCanvas": "Loading Excalidraw...",
"errorLoad": "Failed to load drawing",
"errorSave": "Failed to save:",
"saving": "Saving...",
"saved": "Saved",
"unsaved": "Unsaved changes",
"revisions": "revisions",
"revision": "Revision",
"revisionBrowser": "Revision Browser",
"noRevisions": "No revisions yet",
"goToDashboard": "Go to Dashboard",
"notFound": "Drawing not found",
"presenterNotes": "Presenter Notes",
"notesPlaceholder": "Add notes for your presentation..."
},
"fileBrowser": {
"title": "Projects"
},
"templates": {
"title": "Templates"
},
"teamSettings": {
"title": "Team Settings"
},
"userSettings": {
"title": "Settings",
"subtitle": "Manage your account preferences",
"language": "Language",
"theme": "Theme",
"tabProfile": "Profile",
"tabAccount": "Account",
"tabNotifications": "Notifications",
"tabAppearance": "Appearance",
"profileInfo": "Profile Information",
"changeAvatar": "Change Avatar",
"username": "Username",
"saveChanges": "Save Changes",
"accountSecurity": "Account Security",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm New Password",
"updatePassword": "Update Password",
"notificationPrefs": "Notification Preferences",
"emailMentions": "Email notifications for mentions",
"emailInvites": "Email notifications for team invites",
"weeklySummary": "Weekly activity summary",
"appearance": "Appearance",
"light": "Light",
"dark": "Dark"
},
"commandPalette": {
"placeholder": "Search commands...",
"noResults": "No matching commands"
},
"search": {
"noResults": "No results"
}
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './i18n';
import { App } from './App';
import './styles/global.scss';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+81
View File
@@ -0,0 +1,81 @@
@use '../../styles/variables' as *;
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
background:
radial-gradient(ellipse at top left, var(--color-surface-high), transparent 60%),
radial-gradient(ellipse at bottom right, var(--color-gray-10), transparent 60%),
var(--color-surface-low);
}
.card {
width: 100%;
max-width: 400px;
padding: var(--space-8);
}
.header {
text-align: center;
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-2xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
p {
color: var(--color-muted);
}
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.divider {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-6);
color: var(--color-muted);
font-size: var(--text-sm);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-gray-20);
}
}
.footer {
text-align: center;
margin-top: var(--space-6);
font-size: var(--text-sm);
color: var(--color-muted);
a {
color: var(--color-primary);
font-weight: 500;
}
}
.error {
background: var(--color-danger-background);
color: var(--color-danger-text);
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
text-align: center;
}
+96
View File
@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Github } from 'lucide-react';
import { Button, Input, Card } from '@/components';
import { useAuth } from '@/hooks';
import styles from './Auth.module.scss';
const loginStrings = {
title: 'auth.login.title',
subtitle: 'auth.login.subtitle',
emailLabel: 'auth.login.emailLabel',
emailPlaceholder: 'auth.login.emailPlaceholder',
passwordLabel: 'auth.login.passwordLabel',
passwordPlaceholder: 'auth.login.passwordPlaceholder',
signIn: 'auth.login.signIn',
noAccount: 'auth.login.noAccount',
signUpLink: 'auth.login.signUpLink',
};
const commonStrings = {
continueWith: 'common.continueWith',
};
export const Login: React.FC<{ hasUsers: boolean }> = ({ hasUsers }) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await login(email, password);
navigate('/');
} catch {
setError('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.header}>
<h1>{t(loginStrings.title)}</h1>
<p>{t(loginStrings.subtitle)}</p>
</div>
{error && <div className={styles.error}>{error}</div>}
<form onSubmit={handleSubmit} className={styles.form}>
<Input
label={t(loginStrings.emailLabel)}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t(loginStrings.emailPlaceholder)}
required
/>
<Input
label={t(loginStrings.passwordLabel)}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t(loginStrings.passwordPlaceholder)}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{t(loginStrings.signIn)}
</Button>
</form>
<div className={styles.divider}>
<span>{t(commonStrings.continueWith)}</span>
</div>
<Button variant="secondary" fullWidth>
<Github size={18} />
GitHub
</Button>
{!hasUsers && (
<p className={styles.footer}>
{t(loginStrings.noAccount)} <Link to="/signup">{t(loginStrings.signUpLink)}</Link>
</p>
)}
</Card>
</div>
);
};
+89
View File
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Github } from 'lucide-react';
import { Button, Input, Card } from '@/components';
import { useAuth } from '@/hooks';
import styles from './Auth.module.scss';
export const Signup: React.FC<{ hasUsers: boolean }> = ({ hasUsers }) => {
const { t } = useTranslation();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { signup } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await signup(name, email, password);
navigate('/');
} catch {
setError('Could not create account');
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.header}>
<h1>{t('auth.signup.title')}</h1>
<p>{t('auth.signup.subtitle')}</p>
</div>
{error && <div className={styles.error}>{error}</div>}
<form onSubmit={handleSubmit} className={styles.form}>
<Input
label={t('auth.signup.nameLabel')}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('auth.signup.namePlaceholder')}
required
/>
<Input
label={t('auth.signup.emailLabel')}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('auth.signup.emailPlaceholder')}
required
/>
<Input
label={t('auth.signup.passwordLabel')}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('auth.signup.passwordPlaceholder')}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{t('auth.signup.createAccount')}
</Button>
</form>
<div className={styles.divider}>
<span>{t('common.continueWith')}</span>
</div>
<Button variant="secondary" fullWidth>
<Github size={18} />
GitHub
</Button>
{hasUsers && (
<p className={styles.footer}>
{t('auth.signup.hasAccount')} <Link to="/login">{t('auth.signup.signInLink')}</Link>
</p>
)}
</Card>
</div>
);
};
@@ -0,0 +1,310 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
display: flex;
align-items: center;
justify-content: space-between;
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.quickActions {
display: flex;
align-items: center;
gap: var(--space-3);
@media (max-width: 768px) {
flex-wrap: wrap;
}
}
.actionBtn {
display: flex;
align-items: center;
gap: var(--space-2);
}
.createButton {
display: flex;
align-items: center;
gap: var(--space-2);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-6);
margin-bottom: var(--space-8);
@media (max-width: 1024px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
.statCard {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-4);
}
.statIcon {
color: var(--color-primary);
margin-bottom: var(--space-3);
}
.statValue {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--color-gray-85);
line-height: 1;
}
.statLabel {
font-size: var(--text-sm);
color: var(--color-muted);
margin-top: var(--space-1);
}
.chartBarWrap {
position: relative;
width: 100%;
height: 6px;
margin-top: var(--space-3);
border-radius: var(--border-radius-full);
overflow: hidden;
}
.chartBarBg {
position: absolute;
inset: 0;
background: var(--color-gray-20);
border-radius: var(--border-radius-full);
}
.chartBar {
position: absolute;
inset: 0;
border-radius: var(--border-radius-full);
transition: width 0.4s var(--ease-out);
}
.activityResource {
display: inline-block;
padding: 1px 6px;
border-radius: var(--border-radius-sm);
background: var(--color-surface-low);
color: var(--color-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-left: var(--space-1);
}
.twoColumn {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
.column {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.empty {
text-align: center;
padding: var(--space-8);
}
.emptySub {
color: var(--color-muted);
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.drawingList {
list-style: none;
}
.drawingItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.drawingThumb {
width: 48px;
height: 48px;
border-radius: var(--border-radius-md);
overflow: hidden;
background: var(--color-surface-low);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.drawingInfo {
flex: 1;
min-width: 0;
}
.drawingTitle {
font-weight: 500;
color: var(--color-gray-85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawingMeta {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
.templateGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-4);
}
.templateCard {
cursor: pointer;
transition: transform var(--duration-fast) var(--ease-out);
&:hover {
transform: translateY(-2px);
}
}
.templatePreview {
aspect-ratio: 16 / 10;
border-radius: var(--border-radius-md);
overflow: hidden;
background: var(--color-surface-low);
margin-bottom: var(--space-2);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.templatePlaceholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.templateName {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-70);
text-align: center;
}
.activityCard {
margin-top: var(--space-6);
}
.activityList {
list-style: none;
}
.activityItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.activityAvatar {
width: 32px;
height: 32px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: 600;
flex-shrink: 0;
}
.activityInfo {
flex: 1;
}
.activityText {
font-size: var(--text-sm);
color: var(--color-gray-80);
}
.activityTime {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
+269
View File
@@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Clock, Star, Users, FileText, Plus, Loader2, FolderPlus, UserPlus, BookOpen, Activity } from 'lucide-react';
import { Button, Card, CardHeader, CardContent, TemplatePicker } from '@/components';
import { useDrawingStore, useAuthStore } from '@/stores';
import { api } from '@/services';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import type { PickedTemplate } from '@/components/TemplatePicker/TemplatePicker';
import styles from './Dashboard.module.scss';
const StatChart: React.FC<{ value: number; max: number; color?: string }> = ({ value, max, color = '#6965db' }) => {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div className={styles.chartBarWrap} aria-hidden="true">
<div className={styles.chartBarBg} />
<div className={styles.chartBar} style={{ width: `${pct}%`, background: color }} />
</div>
);
};
export const Dashboard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { recentDrawings, setRecentDrawings, activity, setActivity } = useDrawingStore();
const { user } = useAuthStore();
const [isCreating, setIsCreating] = useState(false);
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [statsData, setStatsData] = useState({
teams: 0,
members: 0,
projects: 0,
folders: 0,
drawings: 0,
templates: 0,
revisions: 0,
assets: 0,
storage_bytes: 0,
});
useEffect(() => {
const loadData = async () => {
try {
const [drawings, stats, activityData] = await Promise.all([
api.drawings.list(),
api.stats.get(),
api.activity.list(),
]);
setRecentDrawings(drawings);
setStatsData(stats);
setActivity(activityData);
} catch (err) {
console.error('Failed to load dashboard data:', err);
}
};
loadData();
}, [setRecentDrawings, setActivity]);
const handleCreateDrawing = async (template: PickedTemplate = 'blank') => {
setIsCreating(true);
try {
const newDrawing = await api.drawings.create({
title: template === 'blank' ? 'Untitled Drawing' : `${template.charAt(0).toUpperCase() + template.slice(1)}`,
visibility: 'team',
});
setRecentDrawings([newDrawing, ...recentDrawings]);
if (template !== 'blank' && BUILTIN_TEMPLATES[template]) {
localStorage.setItem(`template_${newDrawing.id}`, JSON.stringify({
elements: BUILTIN_TEMPLATES[template],
appState: {},
files: {},
}));
}
navigate(`/drawing/${newDrawing.id}`);
} catch (err) {
console.error('Failed to create drawing:', err);
} finally {
setIsCreating(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const maxStat = Math.max(statsData.drawings, statsData.projects + statsData.folders, statsData.teams, statsData.revisions, 1);
const stats = [
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, icon: FileText, color: '#6965db' },
{ label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, icon: FolderPlus, color: '#4dabf7' },
{ label: t('dashboard.stats.teams'), value: statsData.teams, icon: Users, color: '#51cf66' },
{ label: t('dashboard.stats.revisions'), value: statsData.revisions, icon: Clock, color: '#fcc419' },
{ label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), raw: statsData.storage_bytes, icon: Star, color: '#ff6b6b' },
];
return (
<div className={styles.container}>
<div className={styles.header}>
<div>
<h1>{t('dashboard.welcome', { name: user?.name || t('common.user') })}</h1>
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
</div>
<div className={styles.quickActions}>
<TemplatePicker
isOpen={showTemplatePicker}
onClose={() => setShowTemplatePicker(false)}
onSelect={(t) => { setShowTemplatePicker(false); handleCreateDrawing(t); }}
/>
<Button
variant="secondary"
onClick={() => navigate('/files')}
className={styles.actionBtn}
>
<FolderPlus size={16} />
New Project
</Button>
<Button
variant="secondary"
onClick={() => navigate('/team')}
className={styles.actionBtn}
>
<UserPlus size={16} />
Invite
</Button>
<Button
variant="secondary"
onClick={() => navigate('/library')}
className={styles.actionBtn}
>
<BookOpen size={16} />
Library
</Button>
<Button
onClick={() => setShowTemplatePicker(true)}
loading={isCreating}
className={styles.createButton}
>
{isCreating ? (
<Loader2 size={18} className={styles.spinner} />
) : (
<Plus size={18} />
)}
{t('dashboard.newDrawing')}
</Button>
</div>
</div>
<div className={styles.statsGrid}>
{stats.map((stat) => (
<Card key={stat.label}>
<CardContent className={styles.statCard}>
<div className={styles.statIcon}>
<stat.icon size={24} />
</div>
<div className={styles.statValue}>{stat.value}</div>
<div className={styles.statLabel}>{stat.label}</div>
<StatChart value={typeof stat.value === 'number' ? stat.value : 0} max={maxStat} color={stat.color} />
</CardContent>
</Card>
))}
</div>
<div className={styles.twoColumn}>
<div className={styles.column}>
<Card>
<CardHeader>
<h3>{t('dashboard.recentDrawings')}</h3>
</CardHeader>
<CardContent>
{recentDrawings.length === 0 ? (
<div className={styles.empty}>
<p>{t('dashboard.noDrawings')}</p>
<p className={styles.emptySub}>{t('dashboard.noDrawingsSub')}</p>
</div>
) : (
<ul className={styles.drawingList} role="list" aria-label="Recent drawings">
{recentDrawings.slice(0, 5).map((drawing) => (
<li
key={drawing.id}
className={styles.drawingItem}
role="listitem"
tabIndex={0}
onClick={() => {
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.drawingThumb}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.drawingInfo}>
<p className={styles.drawingTitle}>{drawing.title}</p>
<p className={styles.drawingMeta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<div className={styles.column}>
<Card className={styles.activityCard}>
<CardHeader>
<h3><Activity size={16} style={{ display: 'inline', marginRight: 8, verticalAlign: 'middle' }} />Recent Activity</h3>
</CardHeader>
<CardContent>
{activity.length === 0 ? (
<div className={styles.empty}>
<p className={styles.emptySub}>No recent activity</p>
</div>
) : (
<ul className={styles.activityList}>
{activity.slice(0, 8).map((event) => (
<li key={event.id} className={styles.activityItem}>
<div className={styles.activityAvatar}>
{event.actor?.name?.[0] || '?'}
</div>
<div className={styles.activityInfo}>
<p className={styles.activityText}>
<strong>{event.actor?.name || 'Unknown'}</strong>{' '}
{event.event_type.replace(/_/g, ' ')}{' '}
<span className={styles.activityResource}>{event.resource_type}</span>
</p>
<p className={styles.activityTime}>
{new Date(event.created_at).toLocaleString()}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
};
@@ -0,0 +1,418 @@
@use '../../styles/variables' as *;
.container {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--color-surface-lowest);
}
.toolbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-4);
background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20);
}
.left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.right {
display: flex;
align-items: center;
gap: var(--space-2);
}
.title {
font-weight: 500;
color: var(--color-gray-85);
font-size: var(--text-md);
}
.saveStatus {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-gray-60);
}
.unsaved {
color: var(--color-warning);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.canvas {
flex: 1;
position: relative;
overflow: hidden;
:global(.excalidraw) {
width: 100%;
height: 100%;
}
}
.loadingCanvas {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-lowest);
color: var(--color-gray-60);
font-size: var(--text-md);
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-muted);
background: repeating-linear-gradient(
45deg,
var(--color-gray-10),
var(--color-gray-10) 10px,
transparent 10px,
transparent 20px
);
p {
margin: 0;
}
}
.loading,
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-4);
color: var(--color-gray-60);
}
.sub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.revisionBadge {
background: var(--color-primary);
color: white;
font-size: 10px;
border-radius: 999px;
padding: 1px 6px;
margin-left: 4px;
}
.canvasWrapper {
display: flex;
flex: 1;
overflow: hidden;
}
.canvasNarrow {
flex: 0 0 calc(100% - 280px);
}
.revisionPanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.revisionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.revisionList {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
}
.revisionItem {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.15s ease;
&:hover {
background: var(--color-gray-10);
}
}
.revisionActive {
background: var(--color-primary-10);
color: var(--color-primary);
&:hover {
background: var(--color-primary-20);
}
}
.revisionMeta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.revisionLabel {
font-size: var(--text-sm);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.revisionDate {
font-size: 11px;
color: var(--color-gray-60);
}
.revisionEditor {
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-gray-50);
flex-shrink: 0;
}
.notesPanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.notesHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.notesTextarea {
flex: 1;
resize: none;
border: none;
padding: var(--space-3) var(--space-4);
font-family: inherit;
font-size: var(--text-sm);
line-height: 1.5;
background: var(--color-surface-lowest);
color: var(--color-on-surface);
outline: none;
&::placeholder {
color: var(--color-gray-50);
}
}
.revisionEmpty {
text-align: center;
color: var(--color-gray-50);
font-size: var(--text-sm);
padding: var(--space-4);
}
.sidePanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.sidePanelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.sidePanelContent {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
}
.sidePanelItem {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.15s ease;
margin-bottom: var(--space-1);
&:hover {
background: var(--color-gray-10);
}
}
.sidePanelItemTitle {
font-size: var(--text-sm);
font-weight: 500;
}
.sidePanelItemDesc {
font-size: 11px;
color: var(--color-gray-50);
}
.sidePanelSearch {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
background: var(--color-surface-low);
border-radius: var(--border-radius-md);
border: 1px solid var(--color-gray-20);
svg {
color: var(--color-gray-50);
flex-shrink: 0;
}
}
.sidePanelInput {
background: transparent;
border: none;
outline: none;
color: var(--color-on-surface);
font-size: var(--text-sm);
width: 100%;
}
.sidePanelSelect {
width: 100%;
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
font-size: var(--text-sm);
cursor: pointer;
}
.sidePanelLoading,
.sidePanelEmpty,
.sidePanelError {
text-align: center;
padding: var(--space-4);
font-size: var(--text-sm);
color: var(--color-gray-50);
}
.sidePanelError {
color: var(--color-danger);
}
@media (max-width: 768px) {
.toolbar {
height: auto;
padding: var(--space-2) var(--space-3);
gap: var(--space-2);
flex-wrap: wrap;
}
.left {
flex: 1;
min-width: 0;
gap: var(--space-2);
}
.title {
font-size: var(--text-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.canvasNarrow {
flex: 1 !important;
}
.revisionPanel,
.notesPanel,
.sidePanel {
position: fixed;
right: 0;
top: 48px;
bottom: 0;
width: 100%;
max-width: 340px;
z-index: 80;
}
}
+616
View File
@@ -0,0 +1,616 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, Bot, StickyNote, LayoutTemplate, BookOpen, Search } from 'lucide-react';
import { Button, ChatPanel } from '@/components';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import { useThemeStore } from '@/stores';
import { api } from '@/services';
import type { Drawing, DrawingRevision } from '@/types';
import styles from './Editor.module.scss';
// Dynamic import for Excalidraw to avoid SSR issues
const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw })));
interface ExcalidrawElement {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
[key: string]: unknown;
}
interface ExcalidrawState {
elements: ExcalidrawElement[];
appState: Record<string, unknown>;
files: Record<string, { dataURL: string; mimeType: string }>;
}
function prepareElementsForImport(sourceElements: any[], offsetX: number, offsetY: number): any[] {
if (!sourceElements || !sourceElements.length) return [];
const idMap = new Map<string, string>();
sourceElements.forEach((el: any) => {
idMap.set(el.id, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
});
return sourceElements.map((el: any) => {
const newEl = { ...el };
newEl.id = idMap.get(el.id) || el.id;
newEl.x = (el.x || 0) + offsetX;
newEl.y = (el.y || 0) + offsetY;
newEl.version = (el.version || 1) + 1;
newEl.versionNonce = Math.floor(Math.random() * 1000000);
newEl.updated = Date.now();
newEl.seed = Math.floor(Math.random() * 100000);
if (newEl.boundElements) {
newEl.boundElements = newEl.boundElements.map((be: any) => ({
...be,
id: idMap.get(be.id) || be.id,
}));
}
if (newEl.containerId && idMap.has(newEl.containerId)) {
newEl.containerId = idMap.get(newEl.containerId);
}
return newEl;
});
}
export const Editor: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [drawing, setDrawing] = useState<Drawing | null>(null);
const [revisions, setRevisions] = useState<DrawingRevision[]>([]);
const [initialData, setInitialData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
const [error, setError] = useState<string | null>(null);
const [showRevisions, setShowRevisions] = useState(false);
const [showChat, setShowChat] = useState(false);
const [showNotes, setShowNotes] = useState(false);
const [notes, setNotes] = useState('');
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
const { theme: appTheme } = useThemeStore();
const currentStateRef = useRef<ExcalidrawState | null>(null);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedDataRef = useRef<string>('');
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null);
const [showTemplates, setShowTemplates] = useState(false);
const [showLibrary, setShowLibrary] = useState(false);
const [libraryItems, setLibraryItems] = useState<any[]>([]);
const [libraryFiltered, setLibraryFiltered] = useState<any[]>([]);
const [libraryLoading, setLibraryLoading] = useState(false);
const [libraryError, setLibraryError] = useState('');
const [librarySearch, setLibrarySearch] = useState('');
const [libraryCategory, setLibraryCategory] = useState('All');
// Load drawing data
useEffect(() => {
const loadDrawing = async () => {
if (!id) return;
try {
setIsLoading(true);
const [drawingData, revisionsData] = await Promise.all([
api.drawings.get(id),
api.revisions.list(id),
]);
setDrawing(drawingData);
setRevisions(revisionsData);
// Load latest revision data if available
if (revisionsData.length > 0 && revisionsData[0].snapshot) {
const snapshot = JSON.parse(String(revisionsData[0].snapshot));
setInitialData({
elements: snapshot.elements || [],
appState: snapshot.appState || {},
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
} else {
// Check for pending template from dashboard
const pendingTemplate = localStorage.getItem(`template_${id}`);
if (pendingTemplate) {
const tpl = JSON.parse(pendingTemplate);
setInitialData({
elements: tpl.elements || [],
appState: tpl.appState || {},
files: tpl.files || {},
});
lastSavedDataRef.current = JSON.stringify(tpl);
localStorage.removeItem(`template_${id}`);
} else {
// Start with empty canvas
setInitialData({
elements: [],
appState: {},
files: {},
});
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
}
}
} catch (err) {
setError('Failed to load drawing');
console.error(err);
} finally {
setIsLoading(false);
}
};
loadDrawing();
}, [id]);
// Handle changes from Excalidraw
const handleExcalidrawChange = useCallback((elements: readonly unknown[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
currentStateRef.current = {
elements: elements as ExcalidrawElement[],
appState,
files,
};
setSaveStatus('unsaved');
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
saveDrawing();
}, 2000);
}, []);
// Auto-save functionality
const saveDrawing = useCallback(async () => {
if (!id || !currentStateRef.current || isSaving) return;
const { elements, appState, files } = currentStateRef.current;
const snapshot = {
type: 'excalidraw',
version: 2,
source: window.location.hostname,
elements,
appState: {
viewBackgroundColor: appState.viewBackgroundColor,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
theme: appState.theme,
zenModeEnabled: appState.zenModeEnabled,
viewModeEnabled: appState.viewModeEnabled,
editingGroup: appState.editingGroup,
selectedElementIds: appState.selectedElementIds,
},
files,
};
const snapshotJson = JSON.stringify(snapshot);
if (snapshotJson === lastSavedDataRef.current) {
setSaveStatus('saved');
return;
}
try {
setIsSaving(true);
setSaveStatus('saving');
await api.revisions.create(id, snapshot, 'Auto-save');
lastSavedDataRef.current = snapshotJson;
setSaveStatus('saved');
} catch (err) {
console.error('Failed to save:', err);
setSaveStatus('unsaved');
} finally {
setIsSaving(false);
}
}, [id, isSaving]);
// Remove unused revisions warning by displaying count in UI
const revisionCount = revisions.length;
// Restore a specific revision
const handleRestoreRevision = (revision: DrawingRevision) => {
if (!revision.snapshot) return;
try {
const snapshot = JSON.parse(String(revision.snapshot));
setInitialData({
elements: snapshot.elements || [],
appState: snapshot.appState || {},
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
setSelectedRevision(revision.id);
setSaveStatus('saved');
} catch (err) {
console.error('Failed to restore revision:', err);
}
};
// Manual save
const handleManualSave = async () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
await saveDrawing();
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
// Load library marketplace when panel opens
useEffect(() => {
if (!showLibrary || libraryItems.length > 0) return;
const load = async () => {
setLibraryLoading(true);
try {
const res = await fetch('https://libraries.excalidraw.com/libraries.json', {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error('Failed to load libraries');
const data = await res.json();
const items = Object.entries(data).map(([key, lib]: [string, any]) => ({
key,
name: lib.name || key,
description: lib.description || '',
authors: lib.authors || [{ name: 'Unknown' }],
source: `https://libraries.excalidraw.com/${key}.excalidrawlib`,
preview: lib.preview?.startsWith('http') ? lib.preview : `https://libraries.excalidraw.com/${key}.png`,
tags: lib.tags || [],
downloads: lib.downloads || 0,
}));
setLibraryItems(items);
setLibraryFiltered(items);
} catch (err) {
console.error(err);
setLibraryError('Could not load library marketplace.');
} finally {
setLibraryLoading(false);
}
};
load();
}, [showLibrary, libraryItems.length]);
// Filter library items
useEffect(() => {
let result = libraryItems;
if (librarySearch.trim()) {
const q = librarySearch.toLowerCase();
result = result.filter((l: any) =>
l.name.toLowerCase().includes(q) ||
l.description.toLowerCase().includes(q) ||
l.tags.some((t: string) => t.toLowerCase().includes(q))
);
}
if (libraryCategory !== 'All') {
result = result.filter((l: any) => l.tags.some((t: string) => t.toLowerCase() === libraryCategory.toLowerCase()));
}
setLibraryFiltered(result);
}, [librarySearch, libraryCategory, libraryItems]);
const handleLoadTemplate = (templateKey: string) => {
const templateElements = BUILTIN_TEMPLATES[templateKey as keyof typeof BUILTIN_TEMPLATES];
if (!templateElements || !excalidrawAPI) return;
const currentElements = excalidrawAPI.getSceneElements?.() || [];
let offsetX = 100;
let offsetY = 100;
if (currentElements.length > 0) {
const maxX = Math.max(...currentElements.map((el: any) => (el.x || 0) + (el.width || 0)));
offsetX = maxX + 100;
}
const newElements = prepareElementsForImport(templateElements, offsetX, offsetY);
const mergedElements = [...currentElements, ...newElements];
excalidrawAPI.updateScene({ elements: mergedElements });
setShowTemplates(false);
setSaveStatus('unsaved');
};
const handleLoadLibraryItem = async (item: any) => {
if (!excalidrawAPI || !item.source) return;
try {
const res = await fetch(item.source);
if (!res.ok) throw new Error('Failed to load library');
const libData = await res.json();
let sourceElements: any[] = [];
if (libData.libraryItems && Array.isArray(libData.libraryItems)) {
sourceElements = libData.libraryItems[0]?.elements || [];
} else if (Array.isArray(libData)) {
sourceElements = libData;
} else if (libData.elements && Array.isArray(libData.elements)) {
sourceElements = libData.elements;
}
if (!sourceElements.length) {
alert('This library appears to be empty');
return;
}
const currentElements = excalidrawAPI.getSceneElements?.() || [];
let offsetX = 100;
let offsetY = 100;
if (currentElements.length > 0) {
const maxX = Math.max(...currentElements.map((el: any) => (el.x || 0) + (el.width || 0)));
offsetX = maxX + 100;
}
const newElements = prepareElementsForImport(sourceElements, offsetX, offsetY);
const mergedElements = [...currentElements, ...newElements];
excalidrawAPI.updateScene({ elements: mergedElements });
setShowLibrary(false);
setSaveStatus('unsaved');
} catch (err) {
console.error('Failed to load library item:', err);
alert('Failed to load library item');
}
};
const templateOptions = [
{ id: 'blank', label: 'Blank', description: 'Empty canvas start', icon: null },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks', icon: null },
{ id: 'checklist', label: 'Checklist', description: 'Status checklist', icon: null },
{ id: 'list', label: 'Bullet List', description: 'Bulleted notes', icon: null },
{ id: 'flow', label: 'Flow Chart', description: 'Process diagram', icon: null },
];
const libraryCategories = ['All', 'Arrows', 'Charts', 'Cloud', 'Devops', 'Diagrams', 'Education', 'Food', 'Frames', 'Gaming', 'Icons', 'Illustrations', 'Machines', 'Misc', 'People', 'Software', 'Systems', 'Tech', 'Workflow'];
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>{t('common.loading')}</p>
</div>
</div>
);
}
if (error || !drawing) {
return (
<div className={styles.container}>
<div className={styles.error}>
<p>{error || t('editor.notFound')}</p>
<Button onClick={() => navigate('/')}>{t('editor.goToDashboard')}</Button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.toolbar}>
<div className={styles.left}>
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft size={18} />
{t('editor.back')}
</Button>
<span className={styles.title}>{drawing.title}</span>
<span className={styles.saveStatus}>
{saveStatus === 'saving' && <><Loader2 size={14} className={styles.spinner} /> {t('editor.saving')}</>}
{saveStatus === 'saved' && <><Check size={14} /> {t('editor.saved')} {revisionCount > 0 && `(${revisionCount} ${t('editor.revisions')})`}</>}
{saveStatus === 'unsaved' && <span className={styles.unsaved}>{t('editor.unsaved')}</span>}
</span>
</div>
<div className={styles.right}>
<Button
variant="ghost"
size="sm"
onClick={() => setShowChat(!showChat)}
title="AI Assistant"
aria-pressed={showChat}
aria-label="Toggle AI chat panel"
>
<Bot size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowNotes(!showNotes)}
title="Presenter notes"
aria-pressed={showNotes}
aria-label="Toggle presenter notes"
>
<StickyNote size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowRevisions(!showRevisions)}
title={t('editor.revisionBrowser')}
aria-pressed={showRevisions}
aria-label="Toggle revision browser"
>
<History size={16} />
{revisionCount > 0 && <span className={styles.revisionBadge}>{revisionCount}</span>}
</Button>
<Button
size="sm"
onClick={handleManualSave}
loading={isSaving}
disabled={saveStatus === 'saved'}
>
<Save size={16} />
{t('editor.saveNow')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowTemplates(!showTemplates); setShowLibrary(false); }}
title="Templates"
aria-pressed={showTemplates}
aria-label="Toggle templates panel"
>
<LayoutTemplate size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowLibrary(!showLibrary); setShowTemplates(false); }}
title="Library Marketplace"
aria-pressed={showLibrary}
aria-label="Toggle library panel"
>
<BookOpen size={16} />
</Button>
</div>
</div>
<div className={styles.canvasWrapper}>
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates || showLibrary) ? styles.canvasNarrow : ''}`}>
{initialData && (
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
<Excalidraw
excalidrawAPI={(api: any) => setExcalidrawAPI(api)}
initialData={initialData}
onChange={handleExcalidrawChange}
theme={appTheme === 'dark' ? 'dark' : 'light'}
gridModeEnabled={true}
UIOptions={{
canvasActions: {
saveToActiveFile: false,
loadScene: false,
export: { saveFileToDisk: false },
},
}}
/>
</React.Suspense>
)}
</div>
{showRevisions && (
<div className={styles.revisionPanel}>
<div className={styles.revisionHeader}>
<h3>{t('editor.revisionBrowser')}</h3>
<Button variant="ghost" size="sm" onClick={() => setShowRevisions(false)}>
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.revisionList}>
{revisions.length === 0 ? (
<p className={styles.revisionEmpty}>{t('editor.noRevisions')}</p>
) : (
revisions.map((rev) => (
<button
key={rev.id}
className={`${styles.revisionItem} ${selectedRevision === rev.id ? styles.revisionActive : ''}`}
onClick={() => handleRestoreRevision(rev)}
>
<div className={styles.revisionMeta}>
<span className={styles.revisionLabel}>{rev.change_summary || t('editor.revision')}</span>
<span className={styles.revisionDate}>
{new Date(rev.created_at).toLocaleString()}
</span>
</div>
{rev.created_by && (
<span className={styles.revisionEditor}>{rev.created_by.slice(0, 8)}</span>
)}
</button>
))
)}
</div>
</div>
)}
{showNotes && (
<div className={styles.notesPanel} role="complementary" aria-label={t('editor.presenterNotes')}>
<div className={styles.notesHeader}>
<h3>{t('editor.presenterNotes')}</h3>
<Button variant="ghost" size="sm" onClick={() => setShowNotes(false)} aria-label={t('common.close')}>
<ChevronRight size={16} />
</Button>
</div>
<textarea
className={styles.notesTextarea}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('editor.notesPlaceholder')}
aria-label={t('editor.presenterNotes')}
/>
</div>
)}
{showTemplates && (
<div className={styles.sidePanel}>
<div className={styles.sidePanelHeader}>
<h3>Templates</h3>
<Button variant="ghost" size="sm" onClick={() => setShowTemplates(false)} aria-label="Close">
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.sidePanelContent}>
{templateOptions.map((opt) => (
<button
key={opt.id}
className={styles.sidePanelItem}
onClick={() => handleLoadTemplate(opt.id)}
>
<span className={styles.sidePanelItemTitle}>{opt.label}</span>
<span className={styles.sidePanelItemDesc}>{opt.description}</span>
</button>
))}
</div>
</div>
)}
{showLibrary && (
<div className={styles.sidePanel}>
<div className={styles.sidePanelHeader}>
<h3>Library Marketplace</h3>
<Button variant="ghost" size="sm" onClick={() => setShowLibrary(false)} aria-label="Close">
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.sidePanelContent}>
<div className={styles.sidePanelSearch}>
<Search size={14} />
<input
type="text"
placeholder="Search libraries..."
value={librarySearch}
onChange={(e) => setLibrarySearch(e.target.value)}
className={styles.sidePanelInput}
/>
</div>
<select
className={styles.sidePanelSelect}
value={libraryCategory}
onChange={(e) => setLibraryCategory(e.target.value)}
>
{libraryCategories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{libraryLoading && (
<div className={styles.sidePanelLoading}>
<Loader2 size={20} className={styles.spinner} />
<span>Loading...</span>
</div>
)}
{libraryError && (
<div className={styles.sidePanelError}>{libraryError}</div>
)}
{!libraryLoading && !libraryError && libraryFiltered.length === 0 && (
<div className={styles.sidePanelEmpty}>No libraries found</div>
)}
{!libraryLoading && libraryFiltered.map((item: any) => (
<button
key={item.key}
className={styles.sidePanelItem}
onClick={() => handleLoadLibraryItem(item)}
>
<span className={styles.sidePanelItemTitle}>{item.name}</span>
<span className={styles.sidePanelItemDesc}>{item.description || item.tags.slice(0, 3).join(', ')}</span>
</button>
))}
</div>
</div>
)}
{showChat && (
<ChatPanel
onClose={() => setShowChat(false)}
drawingContext={drawing?.title}
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,398 @@
@use '../../styles/variables' as *;
.container {
height: 100%;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
flex-wrap: wrap;
@media (max-width: 640px) {
flex-direction: column;
align-items: flex-start;
}
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-lg);
font-weight: 500;
color: var(--color-gray-85);
svg {
color: var(--color-muted);
}
}
.breadcrumbLink {
background: none;
border: none;
color: var(--color-primary);
font-size: var(--text-lg);
font-weight: 500;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
.breadcrumbCurrent {
color: var(--color-gray-85);
}
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.filterSelect {
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.viewToggle {
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
padding: var(--space-2);
color: var(--color-muted);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
&:hover, &.active {
background: var(--color-surface-primary-container);
color: var(--color-primary);
border-color: var(--color-primary-light);
}
}
.content {
display: flex;
gap: var(--space-6);
flex: 1;
@media (max-width: 768px) {
flex-direction: column;
gap: var(--space-4);
}
}
.sidebar {
width: 200px;
flex-shrink: 0;
@media (max-width: 768px) {
width: 100%;
}
}
.folderTree {
display: flex;
flex-direction: column;
gap: var(--space-1);
list-style: none;
padding: 0;
margin: 0;
}
.folderItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
color: var(--color-gray-70);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
background: none;
border: none;
width: 100%;
text-align: left;
font-size: var(--text-sm);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.folderActive {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
svg {
color: var(--color-primary);
flex-shrink: 0;
}
}
.grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
align-content: start;
@media (max-width: 640px) {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
}
.list {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-16);
color: var(--color-muted);
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
height: 100%;
color: var(--color-muted);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.drawingCard {
position: relative;
}
.thumbnail {
aspect-ratio: 4 / 3;
background: var(--color-surface-low);
border-radius: var(--border-radius-md);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.info {
padding: var(--space-3);
}
.title {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
.more {
position: absolute;
top: var(--space-2);
right: var(--space-2);
background: var(--island-bg-color);
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-1);
color: var(--color-muted);
cursor: pointer;
opacity: 0;
transition: all var(--duration-fast) var(--ease-out);
.drawingCard:hover & {
opacity: 1;
}
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.moreWrap {
position: absolute;
top: var(--space-2);
right: var(--space-2);
}
.dropdown {
position: absolute;
top: calc(100% + var(--space-1));
right: 0;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-island);
min-width: 160px;
z-index: 10;
display: flex;
flex-direction: column;
padding: var(--space-1);
}
.dropdownItem {
background: none;
border: none;
text-align: left;
padding: var(--space-2) var(--space-3);
cursor: pointer;
border-radius: var(--border-radius-sm);
color: var(--color-on-surface);
font-size: var(--text-sm);
&:hover {
background: var(--color-surface-low);
}
}
.dropdownDanger {
color: #e03131;
&:hover {
background: rgba(224, 49, 49, 0.08);
}
}
.dropdownDivider {
height: 1px;
background: var(--default-border-color);
margin: var(--space-1) 0;
}
.dropdownSubmenu {
display: flex;
flex-direction: column;
}
.dropdownSubheader {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.newProjectForm {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.newProjectInput {
flex: 1;
min-width: 120px;
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
color: var(--text-primary-color);
font-size: var(--text-sm);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.newProjectBtn {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
font-weight: 500;
&:hover {
background: var(--color-primary-darkest);
}
}
.newProjectBtnCancel {
background: none;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-on-surface);
&:hover {
background: var(--color-surface-low);
}
}
.renameInput {
width: 100%;
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-1) var(--space-2);
color: var(--text-primary-color);
font-size: var(--text-sm);
&:focus {
outline: none;
border-color: var(--color-primary);
}
}
@@ -0,0 +1,476 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2 } from 'lucide-react';
import { Card, Button, Modal } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Drawing } from '@/types';
import styles from './FileBrowser.module.scss';
export const FileBrowser: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const urlParams = useParams<{ folderId?: string }>();
const { drawings, folders, setDrawings, setFolders } = useDrawingStore();
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortBy, setSortBy] = useState<'name' | 'updated' | 'created'>('updated');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [visibilityFilter, setVisibilityFilter] = useState<'all' | 'private' | 'team' | 'public-link'>('all');
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [activeFolderId, setActiveFolderId] = useState<string | null>(urlParams.folderId || null);
// Dropdown menu state
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
// New project (folder) state
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
// Rename state
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// Move state
const [movingId, setMovingId] = useState<string | null>(null);
// Modal state
const [modal, setModal] = useState<{
open: boolean;
type: 'confirm' | 'alert' | 'info';
title: string;
message: string;
onConfirm?: () => void;
onCancel?: () => void;
}>({ open: false, type: 'info', title: '', message: '' });
const showModal = (type: 'confirm' | 'alert' | 'info', title: string, message: string, onConfirm?: () => void) => {
setModal({ open: true, type, title, message, onConfirm, onCancel: () => setModal(m => ({ ...m, open: false })) });
};
// Load real data on mount
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
const [drawingsData, foldersData] = await Promise.all([
api.drawings.list(),
api.folders.list(),
]);
setDrawings(drawingsData);
setFolders(foldersData);
} catch (err) {
console.error('Failed to load file browser data:', err);
} finally {
setIsLoading(false);
}
};
loadData();
}, [setDrawings, setFolders]);
// Update active folder when URL changes
useEffect(() => {
setActiveFolderId(urlParams.folderId || null);
}, [urlParams.folderId]);
const activeFolder = folders.find((f) => f.id === activeFolderId);
// Filter drawings by active folder + visibility, then sort
let visibleDrawings = activeFolderId
? drawings.filter((d) => d.folder_id === activeFolderId)
: drawings;
if (visibilityFilter !== 'all') {
visibleDrawings = visibleDrawings.filter((d) => d.visibility === visibilityFilter);
}
visibleDrawings = [...visibleDrawings].sort((a, b) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.title.localeCompare(b.title);
else if (sortBy === 'updated') cmp = new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime();
else if (sortBy === 'created') cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
return sortOrder === 'asc' ? cmp : -cmp;
});
const handleFolderClick = useCallback(
(folderId: string | null) => {
setActiveFolderId(folderId);
if (folderId) {
navigate(`/files/folder/${folderId}`);
} else {
navigate('/files');
}
},
[navigate]
);
const handleDrawingClick = useCallback(
(drawing: Drawing) => {
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
},
[navigate]
);
const handleCreateDrawing = async () => {
setIsCreating(true);
try {
const newDrawing = await api.drawings.create({
title: 'Untitled Drawing',
visibility: 'team',
folder_id: activeFolderId || null,
});
setDrawings([newDrawing, ...drawings]);
if (newDrawing.folder_id) {
navigate(`/folder/${newDrawing.folder_id}/drawing/${newDrawing.id}`);
} else {
navigate(`/drawing/${newDrawing.id}`);
}
} catch (err) {
console.error('Failed to create drawing:', err);
showModal('alert', 'Error', 'Failed to create drawing. Please try again.');
} finally {
setIsCreating(false);
}
};
const handleCreateFolder = async () => {
const name = newProjectName.trim();
if (!name) return;
try {
const newFolder = await api.folders.create({ name });
setFolders([...folders, newFolder]);
setShowNewProject(false);
setNewProjectName('');
navigate(`/files/folder/${newFolder.id}`);
} catch (err) {
console.error('Failed to create project:', err);
showModal('alert', 'Error', 'Failed to create project. Please try again.');
}
};
const handleDeleteDrawing = (drawing: Drawing) => {
showModal('confirm', 'Delete Drawing', `Delete "${drawing.title}"? This cannot be undone.`, async () => {
try {
await api.drawings.delete(drawing.id);
setDrawings(drawings.filter(d => d.id !== drawing.id));
setActiveMenu(null);
setModal(m => ({ ...m, open: false }));
} catch (err) {
console.error('Failed to delete drawing:', err);
setModal(m => ({ ...m, open: false }));
setTimeout(() => showModal('alert', 'Error', 'Failed to delete drawing.'), 100);
}
});
};
const handleDuplicateDrawing = async (drawing: Drawing) => {
try {
const newDrawing = await api.drawings.create({
title: `Copy of ${drawing.title}`,
visibility: drawing.visibility,
folder_id: drawing.folder_id || null,
});
setDrawings([newDrawing, ...drawings]);
setActiveMenu(null);
navigate(`/drawing/${newDrawing.id}`);
} catch (err) {
console.error('Failed to duplicate drawing:', err);
showModal('alert', 'Error', 'Failed to duplicate drawing. Please try again.');
}
};
const handleRenameDrawing = async (drawing: Drawing) => {
const title = renameValue.trim();
if (!title || title === drawing.title) {
setRenamingId(null);
return;
}
try {
await api.drawings.update(drawing.id, { title });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, title } : d));
setRenamingId(null);
} catch (err) {
console.error('Failed to rename drawing:', err);
showModal('alert', 'Error', 'Failed to rename drawing. Please try again.');
}
};
const handleMoveDrawing = async (drawing: Drawing, folderId: string | null) => {
try {
await api.drawings.update(drawing.id, { folder_id: folderId });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
setMovingId(null);
setActiveMenu(null);
} catch (err) {
console.error('Failed to move drawing:', err);
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
}
};
// Close menu on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setActiveMenu(null);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>{t('common.loading')}</p>
</div>
</div>
);
}
return (
<>
<Modal
isOpen={modal.open}
type={modal.type}
title={modal.title}
message={modal.message}
onConfirm={modal.onConfirm}
onCancel={modal.onCancel}
confirmText={modal.type === 'confirm' ? 'Delete' : 'OK'}
/>
<div className={styles.container} role="region" aria-label={t('fileBrowser.title')}>
<div className={styles.header}>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<button
className={styles.breadcrumbLink}
onClick={() => handleFolderClick(null)}
aria-current={!activeFolderId ? 'page' : undefined}
>
All Projects
</button>
{activeFolder && (
<>
<ChevronRight size={16} aria-hidden="true" />
<span className={styles.breadcrumbCurrent} aria-current="page">
{activeFolder.name}
</span>
</>
)}
</nav>
<div className={styles.actions}>
<select
className={styles.filterSelect}
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value as any)}
aria-label="Filter by visibility"
title="Filter by visibility"
>
<option value="all">All</option>
<option value="private">Private</option>
<option value="team">Team</option>
<option value="public-link">Public</option>
</select>
<select
className={styles.filterSelect}
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [sb, so] = e.target.value.split('-');
setSortBy(sb as any);
setSortOrder(so as any);
}}
aria-label="Sort drawings"
title="Sort drawings"
>
<option value="updated-desc">Recently updated</option>
<option value="updated-asc">Oldest updated</option>
<option value="created-desc">Recently created</option>
<option value="created-asc">Oldest created</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
</select>
<button
className={`${styles.viewToggle} ${viewMode === 'grid' ? styles.active : ''}`}
onClick={() => setViewMode('grid')}
aria-label="Grid view"
aria-pressed={viewMode === 'grid'}
>
<Grid size={18} />
</button>
<button
className={`${styles.viewToggle} ${viewMode === 'list' ? styles.active : ''}`}
onClick={() => setViewMode('list')}
aria-label="List view"
aria-pressed={viewMode === 'list'}
>
<List size={18} />
</button>
<Button onClick={handleCreateDrawing} loading={isCreating} aria-label="Create new drawing">
<Plus size={16} />
New Drawing
</Button>
<Button variant="secondary" onClick={() => { setShowNewProject(true); setNewProjectName(''); }} aria-label="Create new project">
<Folder size={16} />
New Project
</Button>
</div>
</div>
<div className={styles.content}>
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
{showNewProject && (
<div className={styles.newProjectForm}>
<input
type="text"
autoFocus
placeholder="Project name..."
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder();
if (e.key === 'Escape') { setShowNewProject(false); setNewProjectName(''); }
}}
className={styles.newProjectInput}
/>
<button className={styles.newProjectBtn} onClick={handleCreateFolder}>Create</button>
<button className={styles.newProjectBtnCancel} onClick={() => { setShowNewProject(false); setNewProjectName(''); }}>Cancel</button>
</div>
)}
<ul className={styles.folderTree} role="tree">
<li>
<button
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(null)}
aria-current={!activeFolderId ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>All Projects</span>
</button>
</li>
{folders.map((folder) => (
<li key={folder.id}>
<button
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(folder.id)}
aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>{folder.name}</span>
</button>
</li>
))}
</ul>
</aside>
<main className={viewMode === 'grid' ? styles.grid : styles.list} role="list" aria-label="Drawing list">
{visibleDrawings.length === 0 ? (
<div className={styles.empty} role="status">
<p>No drawings yet</p>
<p className={styles.emptySub}>
{activeFolder ? 'Create a new drawing in this project' : 'Create a new drawing or import existing files'}
</p>
</div>
) : (
visibleDrawings.map((drawing) => (
<Card
key={drawing.id}
className={styles.drawingCard}
hover
role="listitem"
tabIndex={0}
onClick={() => handleDrawingClick(drawing)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDrawingClick(drawing);
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.thumbnail}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.info}>
{renamingId === drawing.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameDrawing(drawing);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameDrawing(drawing)}
/>
) : (
<>
<h4 className={styles.title}>{drawing.title}</h4>
<p className={styles.meta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</>
)}
</div>
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
<button
className={styles.more}
onClick={(e) => {
e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
setRenamingId(null);
}}
aria-label={`More options for ${drawing.title}`}
aria-expanded={activeMenu === drawing.id}
>
<MoreVertical size={16} />
</button>
{activeMenu === drawing.id && (
<div className={styles.dropdown}>
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
{movingId === drawing.id ? (
<div className={styles.dropdownSubmenu}>
<button className={styles.dropdownSubheader}>Move to:</button>
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
{folders.map(f => (
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
))}
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
</div>
) : (
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
)}
<div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div>
)}
</div>
</Card>
))
)}
</main>
</div>
</div>
</>
);
};
@@ -0,0 +1,186 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-8);
h1 {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-2xl);
font-weight: 700;
margin: 0;
}
}
.subtitle {
color: var(--color-gray-60);
margin-top: var(--space-2);
}
.errorBanner {
background: var(--color-danger-background);
color: var(--color-danger-text);
padding: var(--space-4);
border-radius: var(--border-radius-lg);
margin-bottom: var(--space-6);
}
.filters {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-8);
}
.searchBox {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-4);
input {
border: none;
background: transparent;
outline: none;
flex: 1;
font-size: var(--text-base);
}
}
.categories {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.categoryChip {
padding: var(--space-1) var(--space-3);
border-radius: var(--border-radius-full);
border: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--duration-fast);
&.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
&:hover:not(.active) {
background: var(--color-surface-low);
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
.libraryCard {
overflow: hidden;
}
.preview {
height: 160px;
background: var(--color-gray-10);
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
color: var(--color-gray-50);
}
.info {
padding: var(--space-4);
}
.name {
font-weight: 600;
margin: 0 0 var(--space-2);
}
.description {
font-size: var(--text-sm);
color: var(--color-gray-60);
margin: 0 0 var(--space-3);
}
.meta {
display: flex;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--color-gray-50);
margin-bottom: var(--space-3);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--border-radius-full);
background: var(--color-primary-light);
color: var(--color-primary-darkest);
}
.importBtn {
width: 100%;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-12);
color: var(--color-gray-50);
}
.emptySub {
font-size: var(--text-sm);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -0,0 +1,183 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Download, Loader2, BookOpen, ExternalLink, Heart, Filter } from 'lucide-react';
import { Button, Card, CardContent, Input } from '@/components';
import { api } from '@/services';
import styles from './LibraryMarketplace.module.scss';
interface LibraryItem {
name: string;
description: string;
authors: { name: string; github?: string }[];
source: string;
preview?: string;
tags: string[];
downloads: number;
}
const CATEGORIES = ['All', 'Arrows', 'Charts', 'Cloud', 'Devops', 'Diagrams', 'Education', 'Food', 'Frames', 'Gaming', 'Icons', 'Illustrations', 'Machines', 'Misc', 'People', 'Software', 'Systems', 'Tech', 'Workflow'];
export const LibraryMarketplace: React.FC = () => {
const navigate = useNavigate();
const [libraries, setLibraries] = useState<LibraryItem[]>([]);
const [filtered, setFiltered] = useState<LibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState('All');
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
// Try to fetch from excalidraw libraries
const res = await fetch('https://libraries.excalidraw.com/libraries.json', {
headers: { Accept: 'application/json' }
});
if (!res.ok) throw new Error('Failed to load libraries');
const data = await res.json();
const items: LibraryItem[] = Object.entries(data).map(([key, lib]: [string, any]) => ({
name: lib.name || key,
description: lib.description || '',
authors: lib.authors || [{ name: 'Unknown' }],
source: `https://libraries.excalidraw.com/${key}.excalidrawlib`,
preview: lib.preview?.startsWith('http') ? lib.preview : `https://libraries.excalidraw.com/${key}.png`,
tags: lib.tags || [],
downloads: lib.downloads || 0,
}));
setLibraries(items);
setFiltered(items);
} catch (err) {
console.error(err);
setError('Could not load library marketplace. You can still browse libraries at libraries.excalidraw.com');
// Fallback: show some popular libraries as placeholders
setLibraries([
{ name: 'Software Architecture', description: 'Common architecture diagrams and icons', authors: [{ name: 'Excalidraw Community' }], source: '', preview: '', tags: ['Software', 'Architecture'], downloads: 0 },
{ name: 'AWS Icons', description: 'Amazon Web Services icons', authors: [{ name: 'AWS' }], source: '', preview: '', tags: ['Cloud', 'AWS'], downloads: 0 },
{ name: 'Kubernetes', description: 'K8s components and diagrams', authors: [{ name: 'K8s Community' }], source: '', preview: '', tags: ['Devops', 'Cloud'], downloads: 0 },
]);
} finally {
setLoading(false);
}
};
load();
}, []);
useEffect(() => {
let result = libraries;
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter(l => l.name.toLowerCase().includes(q) || l.description.toLowerCase().includes(q) || l.tags.some(t => t.toLowerCase().includes(q)));
}
if (activeCategory !== 'All') {
result = result.filter(l => l.tags.some(t => t.toLowerCase() === activeCategory.toLowerCase()));
}
setFiltered(result);
}, [search, activeCategory, libraries]);
const handleImport = useCallback(async (lib: LibraryItem) => {
if (!lib.source) {
window.open('https://libraries.excalidraw.com', '_blank');
return;
}
try {
// Create a new drawing and navigate to it, the library will be loaded client-side
const drawing = await api.drawings.create({
title: lib.name,
visibility: 'team',
});
// Store selected library in localStorage for the editor to pick up
localStorage.setItem('pending_library', JSON.stringify({ drawingId: drawing.id, source: lib.source }));
navigate(`/drawing/${drawing.id}`);
} catch (err) {
console.error('Failed to create drawing from library:', err);
}
}, [navigate]);
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>Loading library marketplace...</p>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<div>
<h1><BookOpen size={24} /> Library Marketplace</h1>
<p className={styles.subtitle}>Browse and import templates from the Excalidraw community library</p>
</div>
<Button variant="secondary" onClick={() => window.open('https://libraries.excalidraw.com', '_blank')}>
<ExternalLink size={16} /> Open External
</Button>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
<div className={styles.filters}>
<div className={styles.searchBox}>
<Search size={16} />
<Input
placeholder="Search libraries..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className={styles.searchInput}
/>
</div>
<div className={styles.categories}>
<Filter size={16} />
{CATEGORIES.map(cat => (
<button
key={cat}
className={`${styles.categoryChip} ${activeCategory === cat ? styles.active : ''}`}
onClick={() => setActiveCategory(cat)}
>
{cat}
</button>
))}
</div>
</div>
<div className={styles.grid}>
{filtered.length === 0 ? (
<div className={styles.empty}>
<BookOpen size={48} />
<p>No libraries found</p>
<p className={styles.emptySub}>Try a different search or category</p>
</div>
) : filtered.map((lib, idx) => (
<Card key={idx} className={styles.libraryCard} hover>
<div className={styles.preview}>
{lib.preview ? (
<img src={lib.preview} alt={lib.name} loading="lazy" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
) : (
<div className={styles.placeholder}><BookOpen size={32} /></div>
)}
</div>
<CardContent className={styles.info}>
<h4 className={styles.name}>{lib.name}</h4>
<p className={styles.description}>{lib.description || 'No description'}</p>
<div className={styles.meta}>
<span className={styles.authors}>{lib.authors.map(a => a.name).join(', ')}</span>
{lib.downloads > 0 && <span className={styles.downloads}><Download size={12} /> {lib.downloads}</span>}
</div>
<div className={styles.tags}>
{lib.tags.slice(0, 4).map(tag => (
<span key={tag} className={styles.tag}>{tag}</span>
))}
</div>
<Button size="sm" className={styles.importBtn} onClick={() => handleImport(lib)}>
<Heart size={14} /> Import
</Button>
</CardContent>
</Card>
))}
</div>
</div>
);
};
@@ -0,0 +1,158 @@
@use '../../styles/variables' as *;
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--space-8);
}
.sidebar {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.tab {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-md);
color: var(--color-gray-70);
background: none;
border: none;
cursor: pointer;
font-size: var(--text-sm);
transition: all var(--duration-fast) var(--ease-out);
text-align: left;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.avatarSection {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.avatar {
width: 64px;
height: 64px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-2xl);
font-weight: 600;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: var(--space-4);
}
.toggleList {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.toggle {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-gray-70);
input {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
}
.themeSelect {
margin-bottom: var(--space-4);
}
.label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--input-label-color);
margin-bottom: var(--space-3);
}
.themeOptions {
display: flex;
gap: var(--space-3);
}
.themeOption {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gray-30);
border-radius: var(--border-radius-md);
background: var(--island-bg-color);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
&.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
}
@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { User, Key, Bell, Palette, Sun, Moon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardContent, Button, Input } from '@/components';
import { useAuthStore, useThemeStore } from '@/stores';
import styles from './Settings.module.scss';
export const UserSettings: React.FC = () => {
const { t } = useTranslation();
const { user } = useAuthStore();
const { theme, setTheme } = useThemeStore();
const [activeTab, setActiveTab] = useState('profile');
const tabs = [
{ id: 'profile', label: t('userSettings.tabProfile'), icon: User },
{ id: 'account', label: t('userSettings.tabAccount'), icon: Key },
{ id: 'notifications', label: t('userSettings.tabNotifications'), icon: Bell },
{ id: 'appearance', label: t('userSettings.tabAppearance'), icon: Palette },
];
return (
<div className={styles.container}>
<div className={styles.header}>
<h1>{t('userSettings.title')}</h1>
<p className={styles.subtitle}>{t('userSettings.subtitle')}</p>
</div>
<div className={styles.layout}>
<div className={styles.sidebar} role="tablist" aria-label="Settings tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
onClick={() => setActiveTab(tab.id)}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
aria-label={tab.label}
>
<tab.icon size={18} aria-hidden="true" />
<span>{tab.label}</span>
</button>
))}
</div>
<div className={styles.content}>
{activeTab === 'profile' && (
<Card role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
<CardHeader>
<h3>{t('userSettings.profileInfo')}</h3>
</CardHeader>
<CardContent>
<div className={styles.form}>
<div className={styles.avatarSection}>
<div className={styles.avatar}>
{user?.avatar_url ? (
<img src={user.avatar_url} alt={user.name} />
) : (
user?.name?.[0] || '?'
)}
</div>
<Button variant="secondary" size="sm">{t('userSettings.changeAvatar')}</Button>
</div>
<Input label={t('auth.signup.nameLabel')} defaultValue={user?.name} />
<Input label={t('userSettings.username')} defaultValue={user?.username} />
<Input label={t('auth.login.emailLabel')} type="email" defaultValue={user?.email} />
<div className={styles.actions}>
<Button>{t('userSettings.saveChanges')}</Button>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === 'account' && (
<Card role="tabpanel" id="panel-account" aria-labelledby="tab-account">
<CardHeader>
<h3>{t('userSettings.accountSecurity')}</h3>
</CardHeader>
<CardContent>
<div className={styles.form}>
<Input label={t('userSettings.currentPassword')} type="password" />
<Input label={t('userSettings.newPassword')} type="password" />
<Input label={t('userSettings.confirmPassword')} type="password" />
<div className={styles.actions}>
<Button>{t('userSettings.updatePassword')}</Button>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === 'notifications' && (
<Card role="tabpanel" id="panel-notifications" aria-labelledby="tab-notifications">
<CardHeader>
<h3>{t('userSettings.notificationPrefs')}</h3>
</CardHeader>
<CardContent>
<div className={styles.toggleList}>
<label className={styles.toggle}>
<input type="checkbox" defaultChecked />
<span>{t('userSettings.emailMentions')}</span>
</label>
<label className={styles.toggle}>
<input type="checkbox" defaultChecked />
<span>{t('userSettings.emailInvites')}</span>
</label>
<label className={styles.toggle}>
<input type="checkbox" />
<span>{t('userSettings.weeklySummary')}</span>
</label>
</div>
</CardContent>
</Card>
)}
{activeTab === 'appearance' && (
<Card role="tabpanel" id="panel-appearance" aria-labelledby="tab-appearance">
<CardHeader>
<h3>{t('userSettings.appearance')}</h3>
</CardHeader>
<CardContent>
<div className={styles.themeSelect}>
<p className={styles.label}>{t('userSettings.theme')}</p>
<div className={styles.themeOptions}>
<button
className={`${styles.themeOption} ${theme === 'light' ? styles.active : ''}`}
onClick={() => setTheme('light')}
>
<Sun size={16} />
{t('userSettings.light')}
</button>
<button
className={`${styles.themeOption} ${theme === 'dark' ? styles.active : ''}`}
onClick={() => setTheme('dark')}
>
<Moon size={16} />
{t('userSettings.dark')}
</button>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
};
+207
View File
@@ -0,0 +1,207 @@
@use '../../styles/variables' as *;
.container {
max-width: 900px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-6);
}
.sidePanel {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.membersList {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.empty {
text-align: center;
padding: var(--space-8);
color: var(--color-muted);
svg {
margin-bottom: var(--space-4);
opacity: 0.5;
}
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.memberItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.memberAvatar {
width: 40px;
height: 40px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
}
.memberInfo {
flex: 1;
}
.memberName {
font-weight: 500;
color: var(--color-gray-85);
}
.memberEmail {
font-size: var(--text-sm);
color: var(--color-muted);
}
.memberRole {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--color-surface-low);
border-radius: var(--border-radius-full);
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-gray-70);
text-transform: capitalize;
}
.inviteForm {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.inviteInput {
padding: var(--space-3);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
background: var(--input-bg-color);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.pendingCard {
margin-top: var(--space-2);
}
.inviteItem {
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.inviteEmail {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-80);
}
.inviteRole {
font-size: var(--text-xs);
color: var(--color-muted);
text-transform: capitalize;
margin-top: var(--space-1);
}
.roleLabel {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-gray-70);
}
.roleSelect {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
background: var(--input-bg-color);
cursor: pointer;
}
.error {
color: var(--color-danger-text);
font-size: var(--text-sm);
background: var(--color-danger-background);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
}
.success {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-success-text);
font-size: var(--text-sm);
background: var(--color-success);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+185
View File
@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { Users, Crown, Shield, User, Loader2, Check, UserPlus } from 'lucide-react';
import { Card, CardHeader, CardContent, Button, Input } from '@/components';
import { useTeamStore } from '@/stores';
import { api } from '@/services';
import styles from './Team.module.scss';
const roleIcons: Record<string, React.ElementType> = {
owner: Crown,
admin: Shield,
editor: User,
viewer: User,
};
const ROLES = ['viewer', 'editor', 'admin'];
export const TeamSettings: React.FC = () => {
const { currentTeam, members, setMembers, setCurrentTeam } = useTeamStore();
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('editor');
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
const [teamsData, membersData] = await Promise.all([
api.teams.list(),
currentTeam ? api.teams.members(currentTeam.id) : Promise.resolve([]),
]);
if (teamsData.length > 0) {
setCurrentTeam(teamsData[0]);
}
setMembers(membersData);
} catch (err) {
console.error('Failed to load team data:', err);
} finally {
setLoading(false);
}
};
load();
}, [currentTeam?.id, setMembers, setCurrentTeam]);
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim() || !newEmail.trim() || !newPassword.trim() || !currentTeam) return;
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setSending(true);
setError('');
setSent(false);
try {
await api.teams.createUser(currentTeam.id, {
name: newName.trim(),
email: newEmail.trim(),
password: newPassword,
role: newRole,
});
const membersData = await api.teams.members(currentTeam.id);
setMembers(membersData);
setSent(true);
setNewName('');
setNewEmail('');
setNewPassword('');
setNewRole('editor');
} catch (err: any) {
setError(err?.message || 'Failed to create user');
} finally {
setSending(false);
}
};
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}><Loader2 size={32} className={styles.spinner} /><p>Loading team...</p></div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h1>Team Settings</h1>
<p className={styles.subtitle} aria-label="Current team">{currentTeam?.name || 'My Team'}</p>
</div>
<div className={styles.grid}>
<Card role="region" aria-label="Team members">
<CardHeader>
<h3>Members ({members.length})</h3>
</CardHeader>
<CardContent>
<div className={styles.membersList} role="list" aria-label="Team members list">
{members.length === 0 ? (
<div className={styles.empty}>
<Users size={32} />
<p>No team members yet</p>
<p className={styles.emptySub}>Add members to collaborate</p>
</div>
) : (
members.map((member) => {
const RoleIcon = roleIcons[member.role] || User;
return (
<div key={member.id} className={styles.memberItem} role="listitem" aria-label={`Member ${member.user?.name || 'Unknown'}`}>
<div className={styles.memberAvatar} aria-hidden="true">
{member.user?.name?.[0] || '?'}
</div>
<div className={styles.memberInfo}>
<p className={styles.memberName}>{member.user?.name || 'Unknown'}</p>
<p className={styles.memberEmail}>{member.user?.email}</p>
</div>
<div className={styles.memberRole} aria-label={`Role: ${member.role}`}>
<RoleIcon size={14} aria-hidden="true" />
<span>{member.role}</span>
</div>
</div>
);
})
)}
</div>
</CardContent>
</Card>
<div className={styles.sidePanel}>
<Card role="region" aria-label="Add team member">
<CardHeader>
<h3>Add Member</h3>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateUser} className={styles.inviteForm}>
<Input
type="text"
label="Full name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Jane Doe"
required
className={styles.inviteInput}
/>
<Input
type="email"
label="Email address"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="jane@company.com"
required
className={styles.inviteInput}
/>
<Input
type="password"
label="Initial password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 8 characters"
required
className={styles.inviteInput}
/>
<label className={styles.roleLabel}>
Role
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className={styles.roleSelect}>
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</label>
{error && <p className={styles.error}>{error}</p>}
{sent && <p className={styles.success}><Check size={14} /> User created!</p>}
<Button fullWidth type="submit" loading={sending} disabled={!newName.trim() || !newEmail.trim() || !newPassword.trim()}>
<UserPlus size={16} />
Create User
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
);
};
@@ -0,0 +1,228 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
}
.categories {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-6);
border-bottom: 1px solid var(--color-gray-20);
padding-bottom: var(--space-4);
}
.category {
padding: var(--space-2) var(--space-4);
border: none;
background: none;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
border-radius: var(--border-radius-md);
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary);
font-weight: 500;
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-16);
color: var(--color-muted);
svg {
margin-bottom: var(--space-4);
opacity: 0.5;
}
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.templateCard {
overflow: hidden;
}
.preview {
aspect-ratio: 16 / 10;
background: var(--color-surface-low);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
color: var(--color-gray-50);
}
.info {
padding: var(--space-4);
}
.name {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-1);
}
.description {
font-size: var(--text-sm);
color: var(--color-muted);
margin-bottom: var(--space-3);
}
.meta {
display: flex;
gap: var(--space-2);
}
.scope, .type {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
border-radius: var(--border-radius-full);
text-transform: capitalize;
font-weight: 500;
}
.scope {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
}
.type {
background: var(--color-gray-20);
color: var(--color-gray-70);
}
.useBtn {
margin-top: var(--space-3);
width: 100%;
}
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--space-4);
}
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 420px;
padding: var(--space-6);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
h2 {
font-size: var(--text-xl);
font-weight: 600;
color: var(--color-gray-85);
}
button {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--border-radius-md);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
}
.modalBody {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
align-self: center;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
}
.categories {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.grid {
grid-template-columns: 1fr;
}
}
+114
View File
@@ -0,0 +1,114 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Sparkles, X, Loader2, FilePlus } from 'lucide-react';
import { Card, CardContent, Button, Input } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Template, TemplateScope } from '@/types';
import styles from './Templates.module.scss';
const categories: { id: TemplateScope | 'all'; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'system', label: 'System' },
{ id: 'team', label: 'Team' },
{ id: 'personal', label: 'Personal' },
];
export const Templates: React.FC = () => {
const navigate = useNavigate();
const { templates, setTemplates, addDrawing } = useDrawingStore();
const [active, setActive] = useState<TemplateScope | 'all'>('all');
const [showModal, setShowModal] = useState(false);
const [creating, setCreating] = useState(false);
const [applyingId, setApplyingId] = useState<string | null>(null);
const [name, setName] = useState('');
const [error, setError] = useState('');
useEffect(() => {
api.templates.list().then(setTemplates).catch(console.error);
}, [setTemplates]);
const filtered = active === 'all' ? templates : templates.filter((t) => t.scope === active);
const handleCreate = async () => {
if (!name.trim()) { setError('Name required'); return; }
setCreating(true); setError('');
try {
const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' });
setTemplates([t, ...templates]); setShowModal(false); setName('');
} catch (err) { setError('Create failed'); }
finally { setCreating(false); }
};
const handleUseTemplate = async (template: Template) => {
setApplyingId(template.id);
try {
const drawing = await api.drawings.create({
title: template.name,
visibility: 'team',
});
addDrawing(drawing);
navigate(`/drawing/${drawing.id}`);
} catch (err) {
console.error('Failed to create drawing from template:', err);
} finally {
setApplyingId(null);
}
};
return (
<div className={styles.container}>
<div className={styles.header}>
<div><h1>Templates</h1><p className={styles.subtitle}>Start from a template or create your own</p></div>
<Button onClick={() => setShowModal(true)}><Plus size={18} />Create</Button>
</div>
<div className={styles.categories} role="tablist">
{categories.map((c) => (
<button key={c.id} className={`${styles.category} ${active === c.id ? styles.active : ''}`}
onClick={() => setActive(c.id)} role="tab" aria-selected={active === c.id}>{c.label}</button>
))}
</div>
<div className={styles.grid} role="tabpanel">
{filtered.length === 0 ? (
<div className={styles.empty} role="status"><Sparkles size={48} aria-hidden="true" />
<p>No templates</p><p className={styles.emptySub}>Create your first template</p></div>
) : filtered.map((t) => (
<Card key={t.id} className={styles.templateCard} hover>
<div className={styles.preview}>
{t.preview_url ? <img src={t.preview_url} alt="" loading="lazy" /> : <div className={styles.placeholder} role="img" aria-label="No preview"><Sparkles size={32} aria-hidden="true" /></div>}
</div>
<CardContent className={styles.info}>
<h4 className={styles.name}>{t.name}</h4>
<p className={styles.description}>{t.description || 'No description'}</p>
<div className={styles.meta}>
<span className={styles.scope}>{t.scope}</span>
<span className={styles.type}>{t.type}</span>
</div>
<Button
size="sm"
className={styles.useBtn}
onClick={() => handleUseTemplate(t)}
loading={applyingId === t.id}
aria-label={`Use template ${t.name}`}
>
<FilePlus size={14} />
Use Template
</Button>
</CardContent>
</Card>
))}
</div>
{showModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="tm-title" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className={styles.modal}>
<div className={styles.modalHeader}><h2 id="tm-title">Create Template</h2><button onClick={() => setShowModal(false)} aria-label="Close"><X size={18} /></button></div>
<div className={styles.modalBody}>
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} error={error} />
{creating ? <Loader2 className={styles.spinner} size={20} /> : <Button onClick={handleCreate}>Create</Button>}
</div>
</div>
</div>
)}
</div>
);
};
+7
View File
@@ -0,0 +1,7 @@
export { Dashboard } from './Dashboard/Dashboard';
export { Login } from './Auth/Login';
export { Signup } from './Auth/Signup';
export { FileBrowser } from './FileBrowser/FileBrowser';
export { TeamSettings } from './Team/TeamSettings';
export { UserSettings } from './Settings/UserSettings';
export { Templates } from './Templates/Templates';
+91
View File
@@ -0,0 +1,91 @@
import type { User, Session, Drawing, DrawingRevision, Team, TeamMembership, TeamInvite, Template, Folder, ActivityEvent } from '@/types';
const API_BASE = '/api';
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json();
}
export const api = {
auth: {
me: (): Promise<User> => fetchApi('/auth/me'),
setupStatus: (): Promise<{ has_users: boolean }> => fetchApi('/auth/setup-status'),
login: (email: string, password: string): Promise<{ user: User; session: Session }> =>
fetchApi('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
signup: (name: string, email: string, password: string): Promise<{ user: User; session: Session }> =>
fetchApi('/auth/signup', { method: 'POST', body: JSON.stringify({ name, email, password }) }),
logout: (): Promise<void> => fetchApi('/auth/logout', { method: 'POST' }),
},
drawings: {
list: (teamId?: string): Promise<Drawing[]> =>
fetchApi(`/drawings${teamId ? `?team_id=${teamId}` : ''}`),
get: (id: string): Promise<Drawing> => fetchApi(`/drawings/${id}`),
create: (data: object): Promise<Drawing> =>
fetchApi('/drawings', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: object): Promise<Drawing> =>
fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string): Promise<void> =>
fetchApi(`/drawings/${id}`, { method: 'DELETE' }),
},
revisions: {
list: (drawingId: string): Promise<DrawingRevision[]> =>
fetchApi(`/drawings/${drawingId}/revisions`),
create: (drawingId: string, snapshot: object, changeSummary?: string): Promise<DrawingRevision> =>
fetchApi(`/drawings/${drawingId}/revisions`, {
method: 'POST',
body: JSON.stringify({ snapshot, change_summary: changeSummary }),
}),
},
folders: {
list: (): Promise<Folder[]> => fetchApi('/folders'),
create: (data: object): Promise<Folder> =>
fetchApi('/folders', { method: 'POST', body: JSON.stringify(data) }),
},
teams: {
list: (): Promise<Team[]> => fetchApi('/teams'),
create: (data: { name: string; slug: string }): Promise<Team> => fetchApi('/teams', { method: 'POST', body: JSON.stringify(data) }),
members: (teamId: string): Promise<TeamMembership[]> => fetchApi(`/teams/${teamId}/members`),
invites: (teamId: string): Promise<TeamInvite[]> => fetchApi(`/teams/${teamId}/invites`),
createInvite: (teamId: string, data: { email: string; role: string }): Promise<TeamInvite> => fetchApi(`/teams/${teamId}/invites`, { method: 'POST', body: JSON.stringify(data) }),
acceptInvite: (token: string): Promise<void> => fetchApi('/invites/accept', { method: 'POST', body: JSON.stringify({ token }) }),
createUser: (teamId: string, data: { name: string; email: string; password: string; role: string }): Promise<User> => fetchApi(`/teams/${teamId}/users`, { method: 'POST', body: JSON.stringify(data) }),
},
templates: {
list: (): Promise<Template[]> => fetchApi('/templates'),
create: (data: { name: string; type: string; scope: string }): Promise<Template> =>
fetchApi('/templates', { method: 'POST', body: JSON.stringify(data) }),
},
stats: {
get: (teamId?: string): Promise<{
teams: number;
members: number;
projects: number;
folders: number;
drawings: number;
templates: number;
revisions: number;
assets: number;
storage_bytes: number;
}> => fetchApi(`/stats${teamId ? `?team_id=${teamId}` : ''}`),
},
activity: {
list: (): Promise<ActivityEvent[]> => fetchApi('/activity'),
},
search: {
get: (q: string): Promise<Drawing[]> => fetchApi(`/search?q=${encodeURIComponent(q)}`),
},
};
+1
View File
@@ -0,0 +1 @@
export { api } from './api';
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand';
import type { User, Session } from '@/types';
interface AuthState {
user: User | null;
session: Session | null;
isLoading: boolean;
isAuthenticated: boolean;
setUser: (user: User | null) => void;
setSession: (session: Session | null) => void;
setLoading: (loading: boolean) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
session: null,
isLoading: true,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setSession: (session) => set({ session }),
setLoading: (isLoading) => set({ isLoading }),
logout: () => set({ user: null, session: null, isAuthenticated: false }),
}));
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand';
import type { Drawing, Folder, Project, Template, ActivityEvent } from '@/types';
interface DrawingState {
drawings: Drawing[];
folders: Folder[];
projects: Project[];
templates: Template[];
recentDrawings: Drawing[];
activity: ActivityEvent[];
isLoading: boolean;
setDrawings: (drawings: Drawing[]) => void;
setFolders: (folders: Folder[]) => void;
setProjects: (projects: Project[]) => void;
setTemplates: (templates: Template[]) => void;
setRecentDrawings: (drawings: Drawing[]) => void;
setActivity: (activity: ActivityEvent[]) => void;
addDrawing: (drawing: Drawing) => void;
updateDrawing: (id: string, updates: Partial<Drawing>) => void;
removeDrawing: (id: string) => void;
setLoading: (loading: boolean) => void;
}
export const useDrawingStore = create<DrawingState>((set) => ({
drawings: [],
folders: [],
projects: [],
templates: [],
recentDrawings: [],
activity: [],
isLoading: false,
setDrawings: (drawings) => set({ drawings }),
setFolders: (folders) => set({ folders }),
setProjects: (projects) => set({ projects }),
setTemplates: (templates) => set({ templates }),
setRecentDrawings: (recentDrawings) => set({ recentDrawings }),
setActivity: (activity) => set({ activity }),
addDrawing: (drawing) => set((state) => ({ drawings: [drawing, ...state.drawings] })),
updateDrawing: (id, updates) =>
set((state) => ({
drawings: state.drawings.map((d) => (d.id === id ? { ...d, ...updates } : d)),
})),
removeDrawing: (id) =>
set((state) => ({
drawings: state.drawings.filter((d) => d.id !== id),
})),
setLoading: (isLoading) => set({ isLoading }),
}));
+4
View File
@@ -0,0 +1,4 @@
export { useAuthStore } from './authStore';
export { useTeamStore } from './teamStore';
export { useDrawingStore } from './drawingStore';
export { useThemeStore } from './themeStore';
+32
View File
@@ -0,0 +1,32 @@
import { create } from 'zustand';
import type { Team, TeamMembership, TeamInvite } from '@/types';
interface TeamState {
currentTeam: Team | null;
teams: Team[];
members: TeamMembership[];
invites: TeamInvite[];
isLoading: boolean;
setCurrentTeam: (team: Team | null) => void;
setTeams: (teams: Team[]) => void;
addTeam: (team: Team) => void;
removeTeam: (teamId: string) => void;
setMembers: (members: TeamMembership[]) => void;
setInvites: (invites: TeamInvite[]) => void;
setLoading: (loading: boolean) => void;
}
export const useTeamStore = create<TeamState>((set) => ({
currentTeam: null,
teams: [],
members: [],
invites: [],
isLoading: false,
setCurrentTeam: (team) => set({ currentTeam: team }),
setTeams: (teams) => set({ teams }),
addTeam: (team) => set((state) => ({ teams: [...state.teams, team] })),
removeTeam: (teamId) => set((state) => ({ teams: state.teams.filter((t) => t.id !== teamId) })),
setMembers: (members) => set({ members }),
setInvites: (invites) => set({ invites }),
setLoading: (isLoading) => set({ isLoading }),
}));
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useThemeStore } from './themeStore';
describe('themeStore', () => {
beforeEach(() => {
useThemeStore.setState({ theme: 'light' });
});
it('defaults to light', () => {
expect(useThemeStore.getState().theme).toBe('light');
});
it('toggles to dark', () => {
useThemeStore.getState().toggleTheme();
expect(useThemeStore.getState().theme).toBe('dark');
});
it('toggles back to light', () => {
useThemeStore.getState().setTheme('dark');
useThemeStore.getState().toggleTheme();
expect(useThemeStore.getState().theme).toBe('light');
});
});
+55
View File
@@ -0,0 +1,55 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark';
const getInitialTheme = (): Theme => {
if (typeof document !== 'undefined') {
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'dark' || attr === 'light') return attr;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
};
const applyTheme = (theme: Theme) => {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', theme);
}
};
interface ThemeState {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
_hasHydrated: boolean;
setHasHydrated: (hasHydrated: boolean) => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: getInitialTheme(),
_hasHydrated: false,
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
setTheme: (theme) => {
applyTheme(theme);
set({ theme });
},
toggleTheme: () => {
const next = get().theme === 'light' ? 'dark' : 'light';
applyTheme(next);
set({ theme: next });
},
}),
{
name: 'excalidraw-theme',
onRehydrateStorage: () => (state) => {
if (state) {
applyTheme(state.theme);
state.setHasHydrated(true);
}
},
}
)
);
+118
View File
@@ -0,0 +1,118 @@
@use './variables' as *;
// ============================================
// Reset & Base
// ============================================
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--ui-font);
font-size: var(--text-base);
line-height: 1.5;
color: var(--color-on-surface);
background-color: var(--color-surface-low);
min-height: 100vh;
}
// ============================================
// Typography
// ============================================
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.25;
color: var(--color-gray-85);
}
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-2xl); }
h4 { font-size: var(--text-xl); }
h5 { font-size: var(--text-lg); }
h6 { font-size: var(--text-base); }
p {
margin-bottom: var(--space-4);
}
a {
color: var(--link-color);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
&:hover {
color: var(--link-color-hover);
text-decoration: underline;
}
}
// ============================================
// Utilities
// ============================================
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
// ============================================
// Scrollbar
// ============================================
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: var(--border-radius-full);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
border: 2px solid transparent;
background-clip: padding-box;
}
// ============================================
// Focus Styles
// ============================================
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
// ============================================
// Selection
// ============================================
::selection {
background: var(--color-primary-light);
color: var(--color-primary-darkest);
}
+293
View File
@@ -0,0 +1,293 @@
// Excalidraw FULL - Design Tokens
// Based on Excalidraw's hand-drawn aesthetic
// ============================================
// Color Palette
// ============================================
// Primary Purple
$color-primary: #6965db;
$color-primary-darker: #5b57d1;
$color-primary-darkest: #4a47b1;
$color-primary-light: #e3e2fe;
$color-primary-light-darker: #d7d5ff;
$color-primary-hover: #5753d0;
// Grays
$color-gray-10: #f5f5f5;
$color-gray-20: #ebebeb;
$color-gray-30: #d6d6d6;
$color-gray-40: #b8b8b8;
$color-gray-50: #999999;
$color-gray-60: #7a7a7a;
$color-gray-70: #5c5c5c;
$color-gray-80: #3d3d3d;
$color-gray-85: #242424;
$color-gray-90: #1e1e1e;
$color-gray-100: #121212;
// Semantic Colors
$color-success: #cafccc;
$color-success-darker: #bafabc;
$color-success-darkest: #a5eba8;
$color-success-text: #268029;
$color-success-contrast: #65bb6a;
$color-warning: #fceeca;
$color-warning-dark: #f5c354;
$color-warning-darker: #f3ab2c;
$color-warning-darkest: #ec8b14;
$color-danger: #db6965;
$color-danger-dark: #d65550;
$color-danger-darker: #d1413c;
$color-danger-text: #700000;
$color-danger-background: #fff0f0;
$color-danger-icon-background: #ffdad6;
$color-danger-icon-color: #700000;
// ============================================
// CSS Variables
// ============================================
:root {
// Primary
--color-primary: #{$color-primary};
--color-primary-darker: #{$color-primary-darker};
--color-primary-darkest: #{$color-primary-darkest};
--color-primary-light: #{$color-primary-light};
--color-primary-light-darker: #{$color-primary-light-darker};
--color-primary-hover: #{$color-primary-hover};
--color-brand-active: var(--color-primary-darkest);
// Grays
--color-gray-10: #{$color-gray-10};
--color-gray-20: #{$color-gray-20};
--color-gray-30: #{$color-gray-30};
--color-gray-40: #{$color-gray-40};
--color-gray-50: #{$color-gray-50};
--color-gray-60: #{$color-gray-60};
--color-gray-70: #{$color-gray-70};
--color-gray-80: #{$color-gray-80};
--color-gray-85: #{$color-gray-85};
--color-gray-90: #{$color-gray-90};
--color-gray-100: #{$color-gray-100};
// Surfaces
--island-bg-color: #ffffff;
--island-bg-color-alt: #fff;
--color-surface-lowest: #ffffff;
--color-surface-low: #f8f9fa;
--color-surface-high: #e9ecef;
--color-surface-primary-container: #{$color-primary-light};
--color-on-surface: #{$color-gray-90};
--color-on-primary-container: #{$color-primary-darkest};
// Semantic
--color-success: #{$color-success};
--color-success-darker: #{$color-success-darker};
--color-success-darkest: #{$color-success-darkest};
--color-success-text: #{$color-success-text};
--color-success-contrast: #{$color-success-contrast};
--color-warning: #{$color-warning};
--color-warning-dark: #{$color-warning-dark};
--color-warning-darker: #{$color-warning-darker};
--color-danger: #{$color-danger};
--color-danger-dark: #{$color-danger-dark};
--color-danger-darker: #{$color-danger-darker};
--color-danger-text: #{$color-danger-text};
--color-danger-background: #{$color-danger-background};
--color-disabled: var(--color-gray-40);
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--color-selection: #6965db;
--color-icon-white: #fff;
--color-logo-icon: var(--color-primary);
--color-logo-text: #190064;
// Text
--text-primary-color: var(--color-on-surface);
--link-color: #1c7ed6;
--link-color-hover: #1971c2;
// Inputs
--input-bg-color: #fff;
--input-border-color: #{$color-gray-30};
--input-hover-bg-color: #{$color-gray-10};
--input-label-color: #{$color-gray-70};
// Borders
--default-border-color: var(--color-gray-30);
--dialog-border-color: var(--color-gray-20);
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
--border-radius-xl: 0.75rem;
--border-radius-full: 9999px;
// Shadows (Island Pattern)
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgb(0 0 0 / 18%);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
// Spacing
--space-factor: 0.25rem;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
// Typography
--ui-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
// Sizing
--default-button-size: 2rem;
--default-icon-size: 1rem;
--lg-button-size: 2.25rem;
--lg-icon-size: 1rem;
--avatar-size: 2rem;
--sidebar-width: 260px;
--header-height: 64px;
// Timing
--duration-fast: 0.15s;
--duration-normal: 0.2s;
--duration-slow: 0.3s;
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
// Scrollbar
--scrollbar-thumb: var(--color-gray-30);
--scrollbar-thumb-hover: var(--color-gray-40);
}
// Dark Mode
@media (prefers-color-scheme: dark) {
:root {
// Grays (inverted)
--color-gray-10: #1a1a1a;
--color-gray-20: #2d2d2d;
--color-gray-30: #3d3d3d;
--color-gray-40: #5c5c5c;
--color-gray-50: #7a7a7a;
--color-gray-60: #999999;
--color-gray-70: #b8b8b8;
--color-gray-80: #d6d6d6;
--color-gray-85: #ebebeb;
--color-gray-90: #f5f5f5;
--color-gray-100: #ffffff;
--island-bg-color: #252525;
--island-bg-color-alt: #2d2d2d;
--color-surface-lowest: #121212;
--color-surface-low: #1a1a1a;
--color-surface-high: #2d2d2d;
--color-on-surface: #f5f5f5;
--color-on-primary-container: #e3e2fe;
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--input-bg-color: #2d2d2d;
--input-border-color: #3d3d3d;
--input-hover-bg-color: #363636;
--input-label-color: var(--color-gray-70);
--default-border-color: #3d3d3d;
--dialog-border-color: #2d2d2d;
--text-primary-color: #f5f5f5;
--link-color: #74c0fc;
--link-color-hover: #a5d8ff;
--scrollbar-thumb: var(--color-gray-40);
--scrollbar-thumb-hover: var(--color-gray-60);
// Shadows need less opacity in dark mode
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.15);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.35);
}
}
// Manual dark mode override
[data-theme="dark"] {
// Grays (inverted)
--color-gray-10: #1a1a1a;
--color-gray-20: #2d2d2d;
--color-gray-30: #3d3d3d;
--color-gray-40: #5c5c5c;
--color-gray-50: #7a7a7a;
--color-gray-60: #999999;
--color-gray-70: #b8b8b8;
--color-gray-80: #d6d6d6;
--color-gray-85: #ebebeb;
--color-gray-90: #f5f5f5;
--color-gray-100: #ffffff;
--island-bg-color: #252525;
--island-bg-color-alt: #2d2d2d;
--color-surface-lowest: #121212;
--color-surface-low: #1a1a1a;
--color-surface-high: #2d2d2d;
--color-on-surface: #f5f5f5;
--color-on-primary-container: #e3e2fe;
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--input-bg-color: #2d2d2d;
--input-border-color: #3d3d3d;
--input-hover-bg-color: #363636;
--input-label-color: var(--color-gray-70);
--default-border-color: #3d3d3d;
--dialog-border-color: #2d2d2d;
--text-primary-color: #f5f5f5;
--link-color: #74c0fc;
--link-color-hover: #a5d8ff;
--scrollbar-thumb: var(--color-gray-40);
--scrollbar-thumb-hover: var(--color-gray-60);
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.15);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.35);
}
+283
View File
@@ -0,0 +1,283 @@
// ============================================
// User & Auth Types
// ============================================
export interface User {
id: string;
name: string;
username: string;
email: string;
avatar_url: string | null;
locale: string;
timezone: string;
created_at: string;
updated_at: string;
}
export interface AuthIdentity {
id: string;
user_id: string;
provider: 'github' | 'password' | 'google';
provider_user_id: string;
email_verified_at: string | null;
created_at: string;
}
export interface Session {
id: string;
user_id: string;
expires_at: string;
created_at: string;
}
// ============================================
// Team Types
// ============================================
export type TeamRole = 'owner' | 'admin' | 'editor' | 'viewer';
export interface Team {
id: string;
name: string;
slug: string;
owner_user_id: string;
plan_type: 'free' | 'pro';
created_at: string;
updated_at: string;
}
export interface TeamMembership {
id: string;
team_id: string;
user_id: string;
role: TeamRole;
joined_at: string;
user?: User;
}
export interface TeamInvite {
id: string;
team_id: string;
email: string;
role: TeamRole;
invited_by: string;
expires_at: string;
created_at: string;
}
// ============================================
// Project & Folder Types
// ============================================
export interface Project {
id: string;
team_id: string;
name: string;
slug: string;
description: string | null;
created_by: string;
created_at: string;
updated_at: string;
}
export interface Folder {
id: string;
team_id: string;
project_id: string | null;
parent_folder_id: string | null;
name: string;
slug: string;
path_cache: string;
visibility: 'private' | 'team';
created_by: string;
created_at: string;
updated_at: string;
}
// ============================================
// Drawing Types
// ============================================
export type DrawingVisibility = 'private' | 'team' | 'restricted' | 'public-link';
export interface Drawing {
id: string;
team_id: string;
folder_id: string | null;
project_id: string | null;
slug: string | null;
title: string;
description: string | null;
owner_user_id: string;
latest_revision_id: string | null;
visibility: DrawingVisibility;
is_archived: boolean;
thumbnail_asset_id: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
// Joined fields
owner?: User;
folder?: Folder;
project?: Project;
thumbnail_url?: string;
}
export interface DrawingRevision {
id: string;
drawing_id: string;
revision_number: number;
snapshot_path: string;
snapshot_size: number;
content_hash: string;
created_by: string;
created_at: string;
change_summary: string | null;
snapshot?: string | Record<string, unknown>;
created_by_user?: User;
}
export interface DrawingAsset {
id: string;
drawing_id: string;
kind: 'image' | 'export' | 'attachment' | 'thumbnail';
path: string;
mime_type: string;
size: number;
width: number | null;
height: number | null;
uploaded_by: string;
created_at: string;
url?: string;
}
// ============================================
// Template Types
// ============================================
export type TemplateScope = 'system' | 'team' | 'personal';
export type TemplateType =
| 'todo'
| 'kanban'
| 'brainstorm'
| 'flowchart'
| 'meeting-notes'
| 'architecture'
| 'mindmap'
| 'wireframe'
| 'empty';
export interface Template {
id: string;
team_id: string | null;
scope: TemplateScope;
type: TemplateType;
name: string;
description: string | null;
snapshot_path: string;
metadata_json: Record<string, unknown>;
created_by: string;
created_at: string;
updated_at: string;
preview_url?: string;
}
// ============================================
// Share & Permission Types
// ============================================
export type Permission = 'view' | 'comment' | 'edit' | 'manage' | 'share' | 'invite';
export interface ShareLink {
id: string;
resource_type: 'drawing' | 'folder' | 'project';
resource_id: string;
token_hash: string;
permission: Permission;
expires_at: string | null;
password_hash: string | null;
created_by: string;
revoked_at: string | null;
created_at: string;
}
export interface PermissionGrant {
id: string;
resource_type: string;
resource_id: string;
subject_type: 'user' | 'team' | 'link';
subject_id: string;
permission: Permission;
inherited_from: string | null;
created_at: string;
}
// ============================================
// Activity Types
// ============================================
export type ActivityEventType =
| 'drawing_created'
| 'drawing_updated'
| 'drawing_deleted'
| 'drawing_moved'
| 'drawing_renamed'
| 'drawing_shared'
| 'folder_created'
| 'folder_updated'
| 'folder_deleted'
| 'folder_shared'
| 'member_joined'
| 'member_left'
| 'member_invited'
| 'member_role_changed'
| 'revision_created'
| 'revision_restored'
| 'template_applied';
export interface ActivityEvent {
id: string;
actor_user_id: string | null;
team_id: string | null;
resource_type: string;
resource_id: string;
event_type: ActivityEventType;
metadata_json: Record<string, unknown>;
created_at: string;
actor?: User;
}
// ============================================
// UI Types
// ============================================
export type Theme = 'light' | 'dark' | 'system';
export interface BreadcrumbItem {
label: string;
href?: string;
}
export interface FilterOptions {
sortBy: 'name' | 'updated' | 'created';
sortOrder: 'asc' | 'desc';
view: 'grid' | 'list';
}
// ============================================
// API Response Types
// ============================================
export interface ApiResponse<T> {
data: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
has_more: boolean;
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.scss' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.sass' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.css' {
const classes: { [key: string]: string };
export default classes;
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
dedupe: ['react', 'react-dom'],
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3002',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3002',
changeOrigin: true,
ws: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
})