mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +00:00
feat: full project sync - CI fixes, frontend, workspace API, and all changes
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
Generated
+3879
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useAuth } from './useAuth';
|
||||
export { useDrawings } from './useDrawings';
|
||||
export { useTeams } from './useTeams';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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)}`),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { api } from './api';
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useAuthStore } from './authStore';
|
||||
export { useTeamStore } from './teamStore';
|
||||
export { useDrawingStore } from './drawingStore';
|
||||
export { useThemeStore } from './themeStore';
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+16
@@ -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;
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user