mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 13:52:56 +00:00
update
This commit is contained in:
Generated
-8
@@ -1,8 +0,0 @@
|
|||||||
# 默认忽略的文件
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# 基于编辑器的 HTTP 客户端请求
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
@@ -14,7 +14,6 @@ Excalidraw FULL is a production-grade visual workspace platform. It is no longer
|
|||||||
- **Activity history and auditability** — every action is tracked
|
- **Activity history and auditability** — every action is tracked
|
||||||
- **Templates and structured productivity** — system + team + personal templates
|
- **Templates and structured productivity** — system + team + personal templates
|
||||||
- **Rich linking between canvases** — embeds, references, knowledge graph
|
- **Rich linking between canvases** — embeds, references, knowledge graph
|
||||||
- **AI chat integration** — OpenAI proxy for diagram generation assistance
|
|
||||||
- **Command palette** — global `Cmd/Ctrl+K` for power users
|
- **Command palette** — global `Cmd/Ctrl+K` for power users
|
||||||
- **Fulltext search** — find drawings from anywhere
|
- **Fulltext search** — find drawings from anywhere
|
||||||
- **Revision browser** — time-travel through drawing history with one-click restore
|
- **Revision browser** — time-travel through drawing history with one-click restore
|
||||||
@@ -39,6 +38,93 @@ make docker-up # Or run via Docker Compose
|
|||||||
|
|
||||||
The application will be available at `http://localhost:3002`.
|
The application will be available at `http://localhost:3002`.
|
||||||
|
|
||||||
|
## Quick Start with Docker (Pre-built Image)
|
||||||
|
|
||||||
|
Run the latest pre-built image without cloning or building:
|
||||||
|
|
||||||
|
### 1. Create the Docker Compose file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: excalidraw-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-excalidraw}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-excalidraw}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-excalidraw}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
excalidraw:
|
||||||
|
image: ghcr.io/dvorinka/excalidraw-full:latest
|
||||||
|
container_name: excalidraw-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3002}:3002"
|
||||||
|
environment:
|
||||||
|
- LISTEN_ADDR=:3002
|
||||||
|
- STORAGE_TYPE=postgres
|
||||||
|
- DATABASE_URL=postgres://${POSTGRES_USER:-excalidraw}:${POSTGRES_PASSWORD:-excalidraw}@postgres:5432/${POSTGRES_DB:-excalidraw}?sslmode=disable
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
# Optional: GitHub OAuth
|
||||||
|
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
|
||||||
|
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
|
||||||
|
# Optional: Generic OIDC
|
||||||
|
- OIDC_ISSUER_URL=${OIDC_ISSUER_URL:-}
|
||||||
|
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Environment Variables
|
||||||
|
|
||||||
|
**For Dokploy/CasaOS:** Configure these in the UI under Environment Variables.
|
||||||
|
|
||||||
|
**For CLI/Terminal:** Create a `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > .env << EOF
|
||||||
|
# Required: Generate with: openssl rand -base64 32
|
||||||
|
JWT_SECRET=your-secure-random-string-min-32-chars
|
||||||
|
|
||||||
|
# Optional: Change defaults or leave as-is
|
||||||
|
POSTGRES_USER=excalidraw
|
||||||
|
POSTGRES_PASSWORD=excalidraw
|
||||||
|
POSTGRES_DB=excalidraw
|
||||||
|
PORT=3002
|
||||||
|
|
||||||
|
# Optional: GitHub OAuth (for social login)
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Optional: Generic OIDC (for SSO)
|
||||||
|
OIDC_ISSUER_URL=
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will be available at `http://localhost:3002` (or your configured `PORT`).
|
||||||
|
|
||||||
## Docker Images
|
## Docker Images
|
||||||
|
|
||||||
Pushing to GitHub automatically builds and publishes the production image to GitHub Container Registry:
|
Pushing to GitHub automatically builds and publishes the production image to GitHub Container Registry:
|
||||||
@@ -72,8 +158,6 @@ All configuration is via environment variables. See `.env.example` for the full
|
|||||||
| `OIDC_ISSUER_URL` | No* | Generic OIDC issuer for SSO |
|
| `OIDC_ISSUER_URL` | No* | Generic OIDC issuer for SSO |
|
||||||
| `OIDC_CLIENT_ID` | No* | OIDC client ID |
|
| `OIDC_CLIENT_ID` | No* | OIDC client ID |
|
||||||
| `OIDC_CLIENT_SECRET` | No* | OIDC client secret |
|
| `OIDC_CLIENT_SECRET` | No* | OIDC client secret |
|
||||||
| `OPENAI_API_KEY` | No | Enables AI chat/completion proxy |
|
|
||||||
| `OPENAI_BASE_URL` | No | OpenAI-compatible API base URL |
|
|
||||||
| `ALLOWED_ORIGINS` | No | Comma-separated CORS origins |
|
| `ALLOWED_ORIGINS` | No | Comma-separated CORS origins |
|
||||||
| `LISTEN_ADDR` | No | Server bind address (default `:3002`) |
|
| `LISTEN_ADDR` | No | Server bind address (default `:3002`) |
|
||||||
|
|
||||||
@@ -158,13 +242,13 @@ make help # Show all targets
|
|||||||
│ ├── rate_limiter.go # Auth endpoint rate limiting
|
│ ├── rate_limiter.go # Auth endpoint rate limiting
|
||||||
│ └── *_test.go # Go unit tests
|
│ └── *_test.go # Go unit tests
|
||||||
├── middleware/ # Auth, security headers
|
├── middleware/ # Auth, security headers
|
||||||
├── handlers/ # Legacy firebase, kv, openai, auth
|
├── handlers/ # Legacy firebase, kv, auth
|
||||||
├── frontend/ # React + Vite frontend
|
├── frontend/ # React + Vite frontend
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── pages/ # Dashboard, Editor, Auth, Settings, etc.
|
│ │ ├── pages/ # Dashboard, Editor, Auth, Settings, etc.
|
||||||
│ │ ├── components/ # Reusable UI (Button, Card, CommandPalette, etc.)
|
│ │ ├── components/ # Reusable UI (Button, Card, CommandPalette, etc.)
|
||||||
│ │ ├── stores/ # Zustand state management
|
│ │ ├── stores/ # Zustand state management
|
||||||
│ │ ├── services/ # API client + OpenAI proxy
|
│ │ ├── services/ # API client
|
||||||
│ │ ├── i18n/ # Translation files (en.json)
|
│ │ ├── i18n/ # Translation files (en.json)
|
||||||
│ │ └── styles/ # Global SCSS + CSS variables
|
│ │ └── styles/ # Global SCSS + CSS variables
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
@@ -191,23 +275,6 @@ make help # Show all targets
|
|||||||
|
|
||||||
Frontend uses `react-i18next` with `i18next-browser-languagedetector`. All UI strings are externalized to `frontend/src/i18n/locales/en.json`. Add new keys there and reference via `t('key')`.
|
Frontend uses `react-i18next` with `i18next-browser-languagedetector`. All UI strings are externalized to `frontend/src/i18n/locales/en.json`. Add new keys there and reference via `t('key')`.
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
See `plus-roadmap.md` for upcoming features. Shipped highlights:
|
|
||||||
|
|
||||||
- Archive (trash) instead of delete
|
|
||||||
- Activity feed with full audit trail
|
|
||||||
- Command palette for whole app (`Cmd/Ctrl+K`)
|
|
||||||
- Fulltext search
|
|
||||||
- Versioning with revision browser
|
|
||||||
- Public API (OpenAPI + TS client generation)
|
|
||||||
- Self-hosting via Docker
|
|
||||||
- Presenter notes
|
|
||||||
- Scene filtering and sorting
|
|
||||||
- Template gallery with apply flow
|
|
||||||
- Dark mode sync with canvas
|
|
||||||
- Mobile-responsive navigation
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
+1
-1
Submodule excalidraw updated: 2e1a529c67...278cd35772
+15
-38
@@ -64,23 +64,12 @@ test.describe.serial('dashboard', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('quick action: Library navigates to marketplace', async ({ page }) => {
|
test('New Drawing opens a blank fullscreen editor', 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.goto(BASE + '/');
|
||||||
await page.getByRole('button', { name: 'New Drawing' }).click();
|
await page.getByRole('button', { name: 'New Drawing' }).click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page).toHaveURL(/\/drawing\//);
|
||||||
await expect(page.getByRole('heading', { name: 'Choose a Template' })).toBeVisible();
|
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.getByRole('button', { name: 'Blank Canvas' })).toBeVisible();
|
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeHidden();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,11 +86,18 @@ test.describe.serial('projects', () => {
|
|||||||
test('can create a drawing from file browser', async ({ page }) => {
|
test('can create a drawing from file browser', async ({ page }) => {
|
||||||
await page.goto(BASE + '/files');
|
await page.goto(BASE + '/files');
|
||||||
await page.getByRole('button', { name: 'Create new drawing' }).click();
|
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).toHaveURL(/\/drawing\//);
|
||||||
await expect(page.getByText('Loading Excalidraw')).toBeVisible();
|
await expect(page.getByText('Loading Excalidraw')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('can create a project', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/files');
|
||||||
|
await page.getByRole('button', { name: 'Create new project' }).click();
|
||||||
|
await page.getByPlaceholder('Project name...').fill('Product sketches');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/files\/folder\//);
|
||||||
|
await expect(page.getByText('Product sketches')).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editor / Canvas
|
// Editor / Canvas
|
||||||
@@ -111,40 +107,21 @@ test.describe.serial('editor', () => {
|
|||||||
test('creates drawing with To-Do template', async ({ page }) => {
|
test('creates drawing with To-Do template', async ({ page }) => {
|
||||||
await page.goto(BASE + '/');
|
await page.goto(BASE + '/');
|
||||||
await page.getByRole('button', { name: 'New Drawing' }).click();
|
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).toHaveURL(/\/drawing\//);
|
||||||
|
await page.getByRole('button', { name: 'Toggle templates panel' }).click();
|
||||||
|
await page.getByText('To-Do List').click();
|
||||||
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('editor shows save controls and back button', async ({ page }) => {
|
test('editor shows save controls and back button', async ({ page }) => {
|
||||||
await page.goto(BASE + '/');
|
await page.goto(BASE + '/');
|
||||||
await page.getByRole('button', { name: 'New Drawing' }).click();
|
await page.getByRole('button', { name: 'New Drawing' }).click();
|
||||||
await page.getByRole('button', { name: 'Blank Canvas' }).click();
|
|
||||||
await expect(page).toHaveURL(/\/drawing\//);
|
await expect(page).toHaveURL(/\/drawing\//);
|
||||||
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.getByRole('button', { name: /Back/i })).toBeVisible();
|
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
|
// Team / Invites
|
||||||
test.describe.serial('team', () => {
|
test.describe.serial('team', () => {
|
||||||
test.use({ storageState: 'playwright/.auth/state.json' });
|
test.use({ storageState: 'playwright/.auth/state.json' });
|
||||||
|
|||||||
+19
-11
@@ -47,16 +47,24 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<Routes>
|
||||||
<CommandPalette />
|
<Route path="/drawing/:id" element={<Editor />} />
|
||||||
<Routes>
|
<Route path="/folder/:folderId/drawing/:id" element={<Editor />} />
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route
|
||||||
<Route path="/files/*" element={<FileBrowser />} />
|
path="*"
|
||||||
<Route path="/team" element={<TeamSettings />} />
|
element={(
|
||||||
<Route path="/settings" element={<UserSettings />} />
|
<AppLayout>
|
||||||
<Route path="/drawing/:id" element={<Editor />} />
|
<CommandPalette />
|
||||||
<Route path="/folder/:folderId/drawing/:id" element={<Editor />} />
|
<Routes>
|
||||||
</Routes>
|
<Route path="/" element={<Dashboard />} />
|
||||||
</AppLayout>
|
<Route path="/files" element={<FileBrowser />} />
|
||||||
|
<Route path="/files/folder/:folderId" element={<FileBrowser />} />
|
||||||
|
<Route path="/team" element={<TeamSettings />} />
|
||||||
|
<Route path="/settings" element={<UserSettings />} />
|
||||||
|
</Routes>
|
||||||
|
</AppLayout>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
@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); }
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -16,6 +16,7 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
|
|||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<Drawing[]>([]);
|
const [results, setResults] = useState<Drawing[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
const searchRef = useRef<HTMLDivElement>(null);
|
const searchRef = useRef<HTMLDivElement>(null);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
@@ -56,6 +57,21 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateDrawing = async () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const drawing = await api.drawings.create({
|
||||||
|
title: 'Untitled Drawing',
|
||||||
|
visibility: 'team',
|
||||||
|
});
|
||||||
|
navigate(`/drawing/${drawing.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create drawing:', err);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onClick = (e: MouseEvent) => {
|
const onClick = (e: MouseEvent) => {
|
||||||
if (!searchRef.current?.contains(e.target as Node)) {
|
if (!searchRef.current?.contains(e.target as Node)) {
|
||||||
@@ -117,7 +133,7 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
|
|||||||
<button className={styles.iconButton} aria-label="Notifications" title="Notifications">
|
<button className={styles.iconButton} aria-label="Notifications" title="Notifications">
|
||||||
<Bell size={20} aria-hidden="true" />
|
<Bell size={20} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<Button>
|
<Button onClick={handleCreateDrawing} loading={isCreating}>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
{t('dashboard.newDrawing')}
|
{t('dashboard.newDrawing')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -65,20 +65,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-size: var(--text-xl);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-primary);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoImg {
|
.logoMark {
|
||||||
width: 28px;
|
width: 32px;
|
||||||
height: 28px;
|
height: 32px;
|
||||||
|
border: 2px solid var(--color-gray-85);
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--color-gray-85);
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 800;
|
||||||
|
transform: rotate(-4deg);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logoText {
|
||||||
|
color: var(--color-gray-85);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebarCloseBtn {
|
.sidebarCloseBtn {
|
||||||
display: none;
|
display: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ open, onClose }) => {
|
|||||||
>
|
>
|
||||||
<div className={styles.sidebarHeader}>
|
<div className={styles.sidebarHeader}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img src="https://plus.excalidraw.com/images/logo.svg" alt="Excalidraw" className={styles.logoImg} />
|
<span className={styles.logoMark} aria-hidden="true">E</span>
|
||||||
|
<span className={styles.logoText}>Excalidraw</span>
|
||||||
</div>
|
</div>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
.modal {
|
.modal {
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
|
border: 1px solid var(--color-gray-20);
|
||||||
box-shadow: var(--modal-shadow);
|
box-shadow: var(--modal-shadow);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
}, [isOpen, onCancel, onClose]);
|
}, [isOpen, onCancel, onClose]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
const close = () => onCancel?.() ?? onClose?.();
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
confirm: <AlertTriangle size={24} className={styles.iconWarning} />,
|
confirm: <AlertTriangle size={24} className={styles.iconWarning} />,
|
||||||
@@ -59,7 +60,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
className={styles.overlay}
|
className={styles.overlay}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === overlayRef.current) {
|
if (e.target === overlayRef.current) {
|
||||||
onCancel?.() ?? onClose?.();
|
close();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -72,7 +73,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
<h3 id="modal-title" className={styles.title}>{title}</h3>
|
<h3 id="modal-title" className={styles.title}>{title}</h3>
|
||||||
<button
|
<button
|
||||||
className={styles.closeBtn}
|
className={styles.closeBtn}
|
||||||
onClick={() => onCancel?.() ?? onClose?.()}
|
onClick={close}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@@ -83,14 +84,14 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
{type === 'confirm' && (
|
{type === 'confirm' && (
|
||||||
<button
|
<button
|
||||||
className={styles.btnSecondary}
|
className={styles.btnSecondary}
|
||||||
onClick={() => onCancel?.() ?? onClose?.()}
|
onClick={close}
|
||||||
>
|
>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className={type === 'alert' ? styles.btnDanger : styles.btnPrimary}
|
className={type === 'alert' ? styles.btnDanger : styles.btnPrimary}
|
||||||
onClick={() => onConfirm?.() ?? onClose?.()}
|
onClick={() => onConfirm?.() ?? close()}
|
||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool } from 'lucide-react';
|
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool, KanbanSquare, MessageSquare, PanelsTopLeft, GitFork } from 'lucide-react';
|
||||||
import { Card } from '@/components';
|
import { Card } from '@/components';
|
||||||
import styles from './TemplatePicker.module.scss';
|
import styles from './TemplatePicker.module.scss';
|
||||||
|
|
||||||
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow';
|
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow' | 'kanban' | 'meeting' | 'wireframe' | 'mindmap';
|
||||||
|
|
||||||
interface TemplatePickerProps {
|
interface TemplatePickerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -85,6 +85,10 @@ function makeText(x: number, y: number, text: string, fontSize = 20) {
|
|||||||
function makeCheckbox(x: number, y: number, checked = false) {
|
function makeCheckbox(x: number, y: number, checked = false) {
|
||||||
const box = makeHandDrawnRect(x, y, 20, 20);
|
const box = makeHandDrawnRect(x, y, 20, 20);
|
||||||
(box as any).backgroundColor = checked ? '#a5eba8' : 'transparent';
|
(box as any).backgroundColor = checked ? '#a5eba8' : 'transparent';
|
||||||
|
(box as any).customData = {
|
||||||
|
templateRole: 'checkbox',
|
||||||
|
checked,
|
||||||
|
};
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +138,58 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, any[]> = {
|
|||||||
makeHandDrawnRect(200, 350, 200, 60),
|
makeHandDrawnRect(200, 350, 200, 60),
|
||||||
makeText(230, 370, 'End', 20),
|
makeText(230, 370, 'End', 20),
|
||||||
],
|
],
|
||||||
|
kanban: [
|
||||||
|
makeText(50, 40, 'Kanban Board', 30),
|
||||||
|
makeHandDrawnRect(50, 100, 180, 320),
|
||||||
|
makeHandDrawnRect(260, 100, 180, 320),
|
||||||
|
makeHandDrawnRect(470, 100, 180, 320),
|
||||||
|
makeText(75, 120, 'Backlog', 20),
|
||||||
|
makeText(285, 120, 'Doing', 20),
|
||||||
|
makeText(495, 120, 'Done', 20),
|
||||||
|
makeHandDrawnRect(70, 170, 140, 70),
|
||||||
|
makeText(85, 190, 'User research', 16),
|
||||||
|
makeHandDrawnRect(280, 170, 140, 70),
|
||||||
|
makeText(295, 190, 'Sketch flow', 16),
|
||||||
|
makeHandDrawnRect(490, 170, 140, 70),
|
||||||
|
makeText(505, 190, 'Project brief', 16),
|
||||||
|
],
|
||||||
|
meeting: [
|
||||||
|
makeText(50, 40, 'Meeting Notes', 30),
|
||||||
|
makeHandDrawnRect(50, 100, 560, 70),
|
||||||
|
makeText(70, 120, 'Agenda', 20),
|
||||||
|
makeText(70, 150, '- Topic one'),
|
||||||
|
makeHandDrawnRect(50, 200, 560, 100),
|
||||||
|
makeText(70, 220, 'Decisions', 20),
|
||||||
|
makeText(70, 250, '- Decision made'),
|
||||||
|
makeHandDrawnRect(50, 330, 560, 120),
|
||||||
|
makeText(70, 350, 'Action Items', 20),
|
||||||
|
makeCheckbox(70, 390, false),
|
||||||
|
makeText(105, 390, 'Owner and next step', 18),
|
||||||
|
],
|
||||||
|
wireframe: [
|
||||||
|
makeText(50, 35, 'Page Wireframe', 30),
|
||||||
|
makeHandDrawnRect(50, 90, 620, 60),
|
||||||
|
makeText(75, 110, 'Navigation', 18),
|
||||||
|
makeHandDrawnRect(50, 180, 280, 170),
|
||||||
|
makeText(75, 205, 'Hero copy', 22),
|
||||||
|
makeHandDrawnRect(360, 180, 310, 170),
|
||||||
|
makeText(385, 205, 'Preview area', 22),
|
||||||
|
makeHandDrawnRect(50, 380, 190, 110),
|
||||||
|
makeHandDrawnRect(265, 380, 190, 110),
|
||||||
|
makeHandDrawnRect(480, 380, 190, 110),
|
||||||
|
],
|
||||||
|
mindmap: [
|
||||||
|
makeHandDrawnRect(240, 200, 200, 70),
|
||||||
|
makeText(275, 220, 'Main idea', 22),
|
||||||
|
makeHandDrawnRect(50, 80, 150, 55),
|
||||||
|
makeText(75, 96, 'Research', 18),
|
||||||
|
makeHandDrawnRect(490, 80, 150, 55),
|
||||||
|
makeText(520, 96, 'Design', 18),
|
||||||
|
makeHandDrawnRect(50, 350, 150, 55),
|
||||||
|
makeText(80, 366, 'Build', 18),
|
||||||
|
makeHandDrawnRect(490, 350, 150, 55),
|
||||||
|
makeText(520, 366, 'Review', 18),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const OPTIONS: TemplateOption[] = [
|
const OPTIONS: TemplateOption[] = [
|
||||||
@@ -142,6 +198,10 @@ const OPTIONS: TemplateOption[] = [
|
|||||||
{ id: 'checklist', label: 'Checklist', description: 'Simple checklist with status', icon: CheckSquare, 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: '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: [] },
|
{ id: 'flow', label: 'Flow Chart', description: 'Simple process flow diagram', icon: ArrowRight, elements: [] },
|
||||||
|
{ id: 'kanban', label: 'Kanban Board', description: 'Three editable work columns', icon: KanbanSquare, elements: [] },
|
||||||
|
{ id: 'meeting', label: 'Meeting Notes', description: 'Agenda, decisions, actions', icon: MessageSquare, elements: [] },
|
||||||
|
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: PanelsTopLeft, elements: [] },
|
||||||
|
{ id: 'mindmap', label: 'Mind Map', description: 'Branching idea map', icon: GitFork, elements: [] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
|
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export { Input } from './Input/Input';
|
|||||||
export { AppLayout } from './Layout/AppLayout';
|
export { AppLayout } from './Layout/AppLayout';
|
||||||
export { CommandPalette } from './CommandPalette/CommandPalette';
|
export { CommandPalette } from './CommandPalette/CommandPalette';
|
||||||
export { TemplatePicker } from './TemplatePicker/TemplatePicker';
|
export { TemplatePicker } from './TemplatePicker/TemplatePicker';
|
||||||
export { ChatPanel } from './ChatPanel/ChatPanel';
|
|
||||||
export { Header } from './Layout/Header';
|
export { Header } from './Layout/Header';
|
||||||
export { Sidebar } from './Layout/Sidebar';
|
export { Sidebar } from './Layout/Sidebar';
|
||||||
export { Modal } from './Modal/Modal';
|
export { Modal } from './Modal/Modal';
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
@use '../../styles/variables' as *;
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
margin-bottom: var(--space-8);
|
margin-bottom: var(--space-6);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: var(--space-6);
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
background: var(--island-bg-color);
|
||||||
|
border: 1px solid var(--color-gray-20);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow-island);
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
@@ -61,12 +67,12 @@
|
|||||||
|
|
||||||
.statsGrid {
|
.statsGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: var(--space-6);
|
gap: var(--space-4);
|
||||||
margin-bottom: var(--space-8);
|
margin-bottom: var(--space-8);
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1180px) {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
@@ -77,13 +83,21 @@
|
|||||||
.statCard {
|
.statCard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
padding: var(--space-4);
|
padding: var(--space-5);
|
||||||
|
min-height: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statIcon {
|
.statIcon {
|
||||||
color: var(--color-primary);
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-primary-darkest);
|
||||||
|
background: var(--color-primary-light);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +134,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: var(--border-radius-full);
|
||||||
|
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-darkest));
|
||||||
transition: width 0.4s var(--ease-out);
|
transition: width 0.4s var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +276,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.activityCard {
|
.activityCard {
|
||||||
margin-top: var(--space-6);
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityList {
|
.activityList {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
max-height: 340px;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityItem {
|
.activityItem {
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Clock, Star, Users, FileText, Plus, Loader2, FolderPlus, UserPlus, BookOpen, Activity } from 'lucide-react';
|
import { Clock, Database, Users, FileText, Plus, Loader2, FolderPlus, UserPlus, Activity } from 'lucide-react';
|
||||||
import { Button, Card, CardHeader, CardContent, TemplatePicker } from '@/components';
|
import { Button, Card, CardHeader, CardContent } from '@/components';
|
||||||
import { useDrawingStore, useAuthStore } from '@/stores';
|
import { useDrawingStore, useAuthStore } from '@/stores';
|
||||||
import { api } from '@/services';
|
import { api } from '@/services';
|
||||||
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
|
|
||||||
import type { PickedTemplate } from '@/components/TemplatePicker/TemplatePicker';
|
|
||||||
import styles from './Dashboard.module.scss';
|
import styles from './Dashboard.module.scss';
|
||||||
|
|
||||||
const StatChart: React.FC<{ value: number; max: number; color?: string }> = ({ value, max, color = '#6965db' }) => {
|
const ACTIVITY_LIMIT = 5;
|
||||||
const pct = max > 0 ? (value / max) * 100 : 0;
|
|
||||||
|
const StatChart: React.FC<{ value: number; max: number }> = ({ value, max }) => {
|
||||||
|
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div className={styles.chartBarWrap} aria-hidden="true">
|
<div className={styles.chartBarWrap} aria-hidden="true">
|
||||||
<div className={styles.chartBarBg} />
|
<div className={styles.chartBarBg} />
|
||||||
<div className={styles.chartBar} style={{ width: `${pct}%`, background: color }} />
|
<div className={styles.chartBar} style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -25,7 +25,6 @@ export const Dashboard: React.FC = () => {
|
|||||||
const { recentDrawings, setRecentDrawings, activity, setActivity } = useDrawingStore();
|
const { recentDrawings, setRecentDrawings, activity, setActivity } = useDrawingStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
|
||||||
const [statsData, setStatsData] = useState({
|
const [statsData, setStatsData] = useState({
|
||||||
teams: 0,
|
teams: 0,
|
||||||
members: 0,
|
members: 0,
|
||||||
@@ -56,21 +55,14 @@ export const Dashboard: React.FC = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [setRecentDrawings, setActivity]);
|
}, [setRecentDrawings, setActivity]);
|
||||||
|
|
||||||
const handleCreateDrawing = async (template: PickedTemplate = 'blank') => {
|
const handleCreateDrawing = async () => {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
try {
|
try {
|
||||||
const newDrawing = await api.drawings.create({
|
const newDrawing = await api.drawings.create({
|
||||||
title: template === 'blank' ? 'Untitled Drawing' : `${template.charAt(0).toUpperCase() + template.slice(1)}`,
|
title: 'Untitled Drawing',
|
||||||
visibility: 'team',
|
visibility: 'team',
|
||||||
});
|
});
|
||||||
setRecentDrawings([newDrawing, ...recentDrawings]);
|
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}`);
|
navigate(`/drawing/${newDrawing.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create drawing:', err);
|
console.error('Failed to create drawing:', err);
|
||||||
@@ -88,14 +80,18 @@ export const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const maxStat = Math.max(statsData.drawings, statsData.projects + statsData.folders, statsData.teams, statsData.revisions, 1);
|
const maxStat = Math.max(statsData.drawings, statsData.projects + statsData.folders, statsData.teams, statsData.revisions, 1);
|
||||||
|
const storageMax = Math.max(Number(statsData.storage_bytes), 1024 * 1024);
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, icon: FileText, color: '#6965db' },
|
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, chartValue: statsData.drawings, max: maxStat, icon: FileText },
|
||||||
{ label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, icon: FolderPlus, color: '#4dabf7' },
|
{ label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, chartValue: statsData.projects + statsData.folders, max: maxStat, icon: FolderPlus },
|
||||||
{ label: t('dashboard.stats.teams'), value: statsData.teams, icon: Users, color: '#51cf66' },
|
{ label: t('dashboard.stats.teams'), value: statsData.teams, chartValue: statsData.teams, max: maxStat, icon: Users },
|
||||||
{ label: t('dashboard.stats.revisions'), value: statsData.revisions, icon: Clock, color: '#fcc419' },
|
{ label: t('dashboard.stats.revisions'), value: statsData.revisions, chartValue: statsData.revisions, max: maxStat, icon: Clock },
|
||||||
{ label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), raw: statsData.storage_bytes, icon: Star, color: '#ff6b6b' },
|
{ label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), chartValue: Number(statsData.storage_bytes), max: storageMax, icon: Database },
|
||||||
];
|
];
|
||||||
|
const visibleActivity = activity
|
||||||
|
.filter((event) => event.event_type !== 'revision_created')
|
||||||
|
.slice(0, ACTIVITY_LIMIT);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -105,11 +101,6 @@ export const Dashboard: React.FC = () => {
|
|||||||
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
|
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.quickActions}>
|
<div className={styles.quickActions}>
|
||||||
<TemplatePicker
|
|
||||||
isOpen={showTemplatePicker}
|
|
||||||
onClose={() => setShowTemplatePicker(false)}
|
|
||||||
onSelect={(t) => { setShowTemplatePicker(false); handleCreateDrawing(t); }}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => navigate('/files')}
|
onClick={() => navigate('/files')}
|
||||||
@@ -127,15 +118,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
Invite
|
Invite
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
onClick={handleCreateDrawing}
|
||||||
onClick={() => navigate('/library')}
|
|
||||||
className={styles.actionBtn}
|
|
||||||
>
|
|
||||||
<BookOpen size={16} />
|
|
||||||
Library
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowTemplatePicker(true)}
|
|
||||||
loading={isCreating}
|
loading={isCreating}
|
||||||
className={styles.createButton}
|
className={styles.createButton}
|
||||||
>
|
>
|
||||||
@@ -158,7 +141,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.statValue}>{stat.value}</div>
|
<div className={styles.statValue}>{stat.value}</div>
|
||||||
<div className={styles.statLabel}>{stat.label}</div>
|
<div className={styles.statLabel}>{stat.label}</div>
|
||||||
<StatChart value={typeof stat.value === 'number' ? stat.value : 0} max={maxStat} color={stat.color} />
|
<StatChart value={stat.chartValue} max={stat.max} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -235,13 +218,13 @@ export const Dashboard: React.FC = () => {
|
|||||||
<h3><Activity size={16} style={{ display: 'inline', marginRight: 8, verticalAlign: 'middle' }} />Recent Activity</h3>
|
<h3><Activity size={16} style={{ display: 'inline', marginRight: 8, verticalAlign: 'middle' }} />Recent Activity</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activity.length === 0 ? (
|
{visibleActivity.length === 0 ? (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
<p className={styles.emptySub}>No recent activity</p>
|
<p className={styles.emptySub}>No recent activity</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className={styles.activityList}>
|
<ul className={styles.activityList}>
|
||||||
{activity.slice(0, 8).map((event) => (
|
{visibleActivity.map((event) => (
|
||||||
<li key={event.id} className={styles.activityItem}>
|
<li key={event.id} className={styles.activityItem}>
|
||||||
<div className={styles.activityAvatar}>
|
<div className={styles.activityAvatar}>
|
||||||
{event.actor?.name?.[0] || '?'}
|
{event.actor?.name?.[0] || '?'}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, Bot, StickyNote, LayoutTemplate, BookOpen, Search } from 'lucide-react';
|
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, StickyNote, LayoutTemplate } from 'lucide-react';
|
||||||
import { Button, ChatPanel } from '@/components';
|
import { Button } from '@/components';
|
||||||
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
|
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
import { api } from '@/services';
|
import { api } from '@/services';
|
||||||
@@ -56,6 +56,15 @@ function prepareElementsForImport(sourceElements: any[], offsetX: number, offset
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appStateWithoutGrid(appState: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
...appState,
|
||||||
|
gridModeEnabled: false,
|
||||||
|
gridSize: null,
|
||||||
|
gridStep: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const Editor: React.FC = () => {
|
export const Editor: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -68,7 +77,6 @@ export const Editor: React.FC = () => {
|
|||||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showRevisions, setShowRevisions] = useState(false);
|
const [showRevisions, setShowRevisions] = useState(false);
|
||||||
const [showChat, setShowChat] = useState(false);
|
|
||||||
const [showNotes, setShowNotes] = useState(false);
|
const [showNotes, setShowNotes] = useState(false);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
|
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
|
||||||
@@ -76,16 +84,10 @@ export const Editor: React.FC = () => {
|
|||||||
const currentStateRef = useRef<ExcalidrawState | null>(null);
|
const currentStateRef = useRef<ExcalidrawState | null>(null);
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastSavedDataRef = useRef<string>('');
|
const lastSavedDataRef = useRef<string>('');
|
||||||
|
const lastToggledCheckboxRef = useRef<string | null>(null);
|
||||||
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null);
|
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null);
|
||||||
|
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
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
|
// Load drawing data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,7 +107,7 @@ export const Editor: React.FC = () => {
|
|||||||
const snapshot = JSON.parse(String(revisionsData[0].snapshot));
|
const snapshot = JSON.parse(String(revisionsData[0].snapshot));
|
||||||
setInitialData({
|
setInitialData({
|
||||||
elements: snapshot.elements || [],
|
elements: snapshot.elements || [],
|
||||||
appState: snapshot.appState || {},
|
appState: appStateWithoutGrid(snapshot.appState || {}),
|
||||||
files: snapshot.files || {},
|
files: snapshot.files || {},
|
||||||
});
|
});
|
||||||
lastSavedDataRef.current = JSON.stringify(snapshot);
|
lastSavedDataRef.current = JSON.stringify(snapshot);
|
||||||
@@ -116,7 +118,7 @@ export const Editor: React.FC = () => {
|
|||||||
const tpl = JSON.parse(pendingTemplate);
|
const tpl = JSON.parse(pendingTemplate);
|
||||||
setInitialData({
|
setInitialData({
|
||||||
elements: tpl.elements || [],
|
elements: tpl.elements || [],
|
||||||
appState: tpl.appState || {},
|
appState: appStateWithoutGrid(tpl.appState || {}),
|
||||||
files: tpl.files || {},
|
files: tpl.files || {},
|
||||||
});
|
});
|
||||||
lastSavedDataRef.current = JSON.stringify(tpl);
|
lastSavedDataRef.current = JSON.stringify(tpl);
|
||||||
@@ -125,7 +127,7 @@ export const Editor: React.FC = () => {
|
|||||||
// Start with empty canvas
|
// Start with empty canvas
|
||||||
setInitialData({
|
setInitialData({
|
||||||
elements: [],
|
elements: [],
|
||||||
appState: {},
|
appState: appStateWithoutGrid(),
|
||||||
files: {},
|
files: {},
|
||||||
});
|
});
|
||||||
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
|
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
|
||||||
@@ -143,9 +145,48 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Handle changes from Excalidraw
|
// Handle changes from Excalidraw
|
||||||
const handleExcalidrawChange = useCallback((elements: readonly unknown[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
|
const handleExcalidrawChange = useCallback((elements: readonly unknown[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
|
||||||
|
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
|
||||||
|
const selectedCheckbox = selectedIds.length === 1
|
||||||
|
? (elements as any[]).find((el) => (
|
||||||
|
el.id === selectedIds[0] &&
|
||||||
|
!el.isDeleted &&
|
||||||
|
el.customData?.templateRole === 'checkbox'
|
||||||
|
))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!selectedCheckbox) {
|
||||||
|
lastToggledCheckboxRef.current = null;
|
||||||
|
} else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) {
|
||||||
|
lastToggledCheckboxRef.current = selectedCheckbox.id;
|
||||||
|
const nextChecked = !selectedCheckbox.customData?.checked;
|
||||||
|
const nextElements = (elements as any[]).map((el) => (
|
||||||
|
el.id === selectedCheckbox.id
|
||||||
|
? {
|
||||||
|
...el,
|
||||||
|
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
|
||||||
|
customData: {
|
||||||
|
...(el.customData || {}),
|
||||||
|
checked: nextChecked,
|
||||||
|
},
|
||||||
|
version: (el.version || 1) + 1,
|
||||||
|
versionNonce: Math.floor(Math.random() * 1000000),
|
||||||
|
updated: Date.now(),
|
||||||
|
}
|
||||||
|
: el
|
||||||
|
));
|
||||||
|
excalidrawAPI.updateScene({ elements: nextElements });
|
||||||
|
currentStateRef.current = {
|
||||||
|
elements: nextElements,
|
||||||
|
appState: appStateWithoutGrid(appState),
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
currentStateRef.current = {
|
currentStateRef.current = {
|
||||||
elements: elements as ExcalidrawElement[],
|
elements: elements as ExcalidrawElement[],
|
||||||
appState,
|
appState: appStateWithoutGrid(appState),
|
||||||
files,
|
files,
|
||||||
};
|
};
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
@@ -155,7 +196,7 @@ export const Editor: React.FC = () => {
|
|||||||
saveTimeoutRef.current = setTimeout(() => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
saveDrawing();
|
saveDrawing();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}, []);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
// Auto-save functionality
|
// Auto-save functionality
|
||||||
const saveDrawing = useCallback(async () => {
|
const saveDrawing = useCallback(async () => {
|
||||||
@@ -212,7 +253,7 @@ export const Editor: React.FC = () => {
|
|||||||
const snapshot = JSON.parse(String(revision.snapshot));
|
const snapshot = JSON.parse(String(revision.snapshot));
|
||||||
setInitialData({
|
setInitialData({
|
||||||
elements: snapshot.elements || [],
|
elements: snapshot.elements || [],
|
||||||
appState: snapshot.appState || {},
|
appState: appStateWithoutGrid(snapshot.appState || {}),
|
||||||
files: snapshot.files || {},
|
files: snapshot.files || {},
|
||||||
});
|
});
|
||||||
lastSavedDataRef.current = JSON.stringify(snapshot);
|
lastSavedDataRef.current = JSON.stringify(snapshot);
|
||||||
@@ -240,56 +281,6 @@ export const Editor: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 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 handleLoadTemplate = (templateKey: string) => {
|
||||||
const templateElements = BUILTIN_TEMPLATES[templateKey as keyof typeof BUILTIN_TEMPLATES];
|
const templateElements = BUILTIN_TEMPLATES[templateKey as keyof typeof BUILTIN_TEMPLATES];
|
||||||
if (!templateElements || !excalidrawAPI) return;
|
if (!templateElements || !excalidrawAPI) return;
|
||||||
@@ -307,51 +298,29 @@ export const Editor: React.FC = () => {
|
|||||||
setSaveStatus('unsaved');
|
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 = [
|
const templateOptions = [
|
||||||
{ id: 'blank', label: 'Blank', description: 'Empty canvas start', icon: null },
|
{ id: 'blank', label: 'Blank', description: 'Empty canvas start', icon: null },
|
||||||
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks', icon: null },
|
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks', icon: null },
|
||||||
{ id: 'checklist', label: 'Checklist', description: 'Status checklist', icon: null },
|
{ id: 'checklist', label: 'Checklist', description: 'Status checklist', icon: null },
|
||||||
{ id: 'list', label: 'Bullet List', description: 'Bulleted notes', icon: null },
|
{ id: 'list', label: 'Bullet List', description: 'Bulleted notes', icon: null },
|
||||||
{ id: 'flow', label: 'Flow Chart', description: 'Process diagram', icon: null },
|
{ id: 'flow', label: 'Flow Chart', description: 'Process diagram', icon: null },
|
||||||
|
{ id: 'kanban', label: 'Kanban Board', description: 'Backlog, doing, done columns', icon: null },
|
||||||
|
{ id: 'meeting', label: 'Meeting Notes', description: 'Agenda, decisions, actions', icon: null },
|
||||||
|
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: null },
|
||||||
|
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with branches', icon: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
const libraryCategories = ['All', 'Arrows', 'Charts', 'Cloud', 'Devops', 'Diagrams', 'Education', 'Food', 'Frames', 'Gaming', 'Icons', 'Illustrations', 'Machines', 'Misc', 'People', 'Software', 'Systems', 'Tech', 'Workflow'];
|
useEffect(() => {
|
||||||
|
if (!excalidrawAPI?.onPointerUp) return undefined;
|
||||||
|
|
||||||
|
return excalidrawAPI.onPointerUp((activeTool: { type?: string; locked?: boolean }) => {
|
||||||
|
if ((activeTool.type === 'line' || activeTool.type === 'arrow') && !activeTool.locked) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
excalidrawAPI.setActiveTool?.({ type: 'selection' });
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -391,16 +360,6 @@ export const Editor: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.right}>
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -434,27 +393,17 @@ export const Editor: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { setShowTemplates(!showTemplates); setShowLibrary(false); }}
|
onClick={() => setShowTemplates(!showTemplates)}
|
||||||
title="Templates"
|
title="Templates"
|
||||||
aria-pressed={showTemplates}
|
aria-pressed={showTemplates}
|
||||||
aria-label="Toggle templates panel"
|
aria-label="Toggle templates panel"
|
||||||
>
|
>
|
||||||
<LayoutTemplate size={16} />
|
<LayoutTemplate size={16} />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
<div className={styles.canvasWrapper}>
|
<div className={styles.canvasWrapper}>
|
||||||
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates || showLibrary) ? styles.canvasNarrow : ''}`}>
|
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates) ? styles.canvasNarrow : ''}`}>
|
||||||
{initialData && (
|
{initialData && (
|
||||||
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
|
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
@@ -462,7 +411,7 @@ export const Editor: React.FC = () => {
|
|||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
onChange={handleExcalidrawChange}
|
onChange={handleExcalidrawChange}
|
||||||
theme={appTheme === 'dark' ? 'dark' : 'light'}
|
theme={appTheme === 'dark' ? 'dark' : 'light'}
|
||||||
gridModeEnabled={true}
|
gridModeEnabled={false}
|
||||||
UIOptions={{
|
UIOptions={{
|
||||||
canvasActions: {
|
canvasActions: {
|
||||||
saveToActiveFile: false,
|
saveToActiveFile: false,
|
||||||
@@ -550,66 +499,6 @@ export const Editor: React.FC = () => {
|
|||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
max-width: 1320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -13,6 +16,11 @@
|
|||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
padding: var(--space-5);
|
||||||
|
background: var(--island-bg-color);
|
||||||
|
border: 1px solid var(--color-gray-20);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow-island);
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -94,6 +102,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -102,8 +111,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 200px;
|
width: 240px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
background: var(--island-bg-color);
|
||||||
|
border: 1px solid var(--color-gray-20);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: var(--space-3);
|
||||||
|
align-self: flex-start;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -157,6 +171,7 @@
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
@@ -175,6 +190,9 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-16);
|
padding: var(--space-16);
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
|
border: 1px dashed var(--color-gray-30);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
background: var(--island-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptySub {
|
.emptySub {
|
||||||
@@ -334,6 +352,10 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-surface-low);
|
||||||
|
border: 1px solid var(--color-gray-20);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.newProjectInput {
|
.newProjectInput {
|
||||||
@@ -382,6 +404,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inlineError {
|
||||||
|
flex-basis: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--color-danger-text);
|
||||||
|
background: var(--color-danger-background);
|
||||||
|
border: 1px solid var(--color-danger-icon-background);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.renameInput {
|
.renameInput {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--input-bg-color);
|
background: var(--input-bg-color);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2 } from 'lucide-react';
|
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle } from 'lucide-react';
|
||||||
import { Card, Button, Modal } from '@/components';
|
import { Card, Button, Modal } from '@/components';
|
||||||
import { useDrawingStore } from '@/stores';
|
import { useDrawingStore } from '@/stores';
|
||||||
import { api } from '@/services';
|
import { api } from '@/services';
|
||||||
@@ -28,6 +28,7 @@ export const FileBrowser: React.FC = () => {
|
|||||||
// New project (folder) state
|
// New project (folder) state
|
||||||
const [showNewProject, setShowNewProject] = useState(false);
|
const [showNewProject, setShowNewProject] = useState(false);
|
||||||
const [newProjectName, setNewProjectName] = useState('');
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
|
const [projectError, setProjectError] = useState('');
|
||||||
|
|
||||||
// Rename state
|
// Rename state
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
@@ -142,14 +143,16 @@ export const FileBrowser: React.FC = () => {
|
|||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
const name = newProjectName.trim();
|
const name = newProjectName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
setProjectError('');
|
||||||
try {
|
try {
|
||||||
const newFolder = await api.folders.create({ name });
|
const newFolder = await api.folders.create({ name, visibility: 'team' });
|
||||||
setFolders([...folders, newFolder]);
|
setFolders([...folders, newFolder]);
|
||||||
setShowNewProject(false);
|
setShowNewProject(false);
|
||||||
setNewProjectName('');
|
setNewProjectName('');
|
||||||
navigate(`/files/folder/${newFolder.id}`);
|
navigate(`/files/folder/${newFolder.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create project:', err);
|
console.error('Failed to create project:', err);
|
||||||
|
setProjectError('We could not create that project. Check the name and try again.');
|
||||||
showModal('alert', 'Error', 'Failed to create project. Please try again.');
|
showModal('alert', 'Error', 'Failed to create project. Please try again.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -244,6 +247,7 @@ export const FileBrowser: React.FC = () => {
|
|||||||
message={modal.message}
|
message={modal.message}
|
||||||
onConfirm={modal.onConfirm}
|
onConfirm={modal.onConfirm}
|
||||||
onCancel={modal.onCancel}
|
onCancel={modal.onCancel}
|
||||||
|
onClose={() => setModal(m => ({ ...m, open: false }))}
|
||||||
confirmText={modal.type === 'confirm' ? 'Delete' : 'OK'}
|
confirmText={modal.type === 'confirm' ? 'Delete' : 'OK'}
|
||||||
/>
|
/>
|
||||||
<div className={styles.container} role="region" aria-label={t('fileBrowser.title')}>
|
<div className={styles.container} role="region" aria-label={t('fileBrowser.title')}>
|
||||||
@@ -316,7 +320,7 @@ export const FileBrowser: React.FC = () => {
|
|||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
New Drawing
|
New Drawing
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => { setShowNewProject(true); setNewProjectName(''); }} aria-label="Create new project">
|
<Button variant="secondary" onClick={() => { setShowNewProject(true); setNewProjectName(''); setProjectError(''); }} aria-label="Create new project">
|
||||||
<Folder size={16} />
|
<Folder size={16} />
|
||||||
New Project
|
New Project
|
||||||
</Button>
|
</Button>
|
||||||
@@ -335,12 +339,18 @@ export const FileBrowser: React.FC = () => {
|
|||||||
onChange={(e) => setNewProjectName(e.target.value)}
|
onChange={(e) => setNewProjectName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') handleCreateFolder();
|
if (e.key === 'Enter') handleCreateFolder();
|
||||||
if (e.key === 'Escape') { setShowNewProject(false); setNewProjectName(''); }
|
if (e.key === 'Escape') { setShowNewProject(false); setNewProjectName(''); setProjectError(''); }
|
||||||
}}
|
}}
|
||||||
className={styles.newProjectInput}
|
className={styles.newProjectInput}
|
||||||
/>
|
/>
|
||||||
<button className={styles.newProjectBtn} onClick={handleCreateFolder}>Create</button>
|
<button className={styles.newProjectBtn} onClick={handleCreateFolder}>Create</button>
|
||||||
<button className={styles.newProjectBtnCancel} onClick={() => { setShowNewProject(false); setNewProjectName(''); }}>Cancel</button>
|
<button className={styles.newProjectBtnCancel} onClick={() => { setShowNewProject(false); setNewProjectName(''); setProjectError(''); }}>Cancel</button>
|
||||||
|
{projectError && (
|
||||||
|
<div className={styles.inlineError} role="alert">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
{projectError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className={styles.folderTree} role="tree">
|
<ul className={styles.folderTree} role="tree">
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
@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); }
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package openai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"excalidraw-complete/handlers/auth"
|
|
||||||
"excalidraw-complete/middleware"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
openaiAPIKey string
|
|
||||||
openaiBaseURL string
|
|
||||||
)
|
|
||||||
|
|
||||||
func Init() {
|
|
||||||
openaiAPIKey = os.Getenv("OPENAI_API_KEY")
|
|
||||||
openaiBaseURL = os.Getenv("OPENAI_BASE_URL")
|
|
||||||
if openaiBaseURL == "" {
|
|
||||||
openaiBaseURL = "https://api.openai.com" // Default value
|
|
||||||
}
|
|
||||||
if openaiAPIKey == "" {
|
|
||||||
log.Println("WARNING: OPENAI_API_KEY environment variable not set. OpenAI proxy will not work.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structures for OpenAI compatibility
|
|
||||||
|
|
||||||
type LiteralType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
LiteralTypeText LiteralType = "text"
|
|
||||||
LiteralTypeImageURL LiteralType = "image_url"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UserTextContentPart corresponds to a part of a multi-part message with text.
|
|
||||||
type UserTextContentPart struct {
|
|
||||||
Type LiteralType `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageURL details the URL and detail level of an image.
|
|
||||||
type ImageURL struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Detail string `json:"detail,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserImageContentPart corresponds to a part of a multi-part message with an image.
|
|
||||||
type UserImageContentPart struct {
|
|
||||||
Type LiteralType `json:"type"`
|
|
||||||
ImageURL ImageURL `json:"image_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserContentPart struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserContext struct {
|
|
||||||
UserID int `json:"user_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatMessage struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content any `json:"content"` // Can be string or a slice of UserTextContentPart/UserImageContentPart
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatCompletionRequest struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Messages []ChatMessage `json:"messages"`
|
|
||||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
|
||||||
Stream *bool `json:"stream"`
|
|
||||||
// Other fields like temperature, max_tokens etc. are ignored for this mock
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatCompletionChoice struct {
|
|
||||||
Index int `json:"index"`
|
|
||||||
Message ChatMessage `json:"message"`
|
|
||||||
FinishReason string `json:"finish_reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Usage struct {
|
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatCompletionResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Object string `json:"object"`
|
|
||||||
Created int64 `json:"created"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
Choices []ChatCompletionChoice `json:"choices"`
|
|
||||||
Usage Usage `json:"usage"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FlusherWriter is a helper to ensure that data is flushed to the client for streaming
|
|
||||||
type FlusherWriter struct {
|
|
||||||
w http.ResponseWriter
|
|
||||||
f http.Flusher
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fw *FlusherWriter) Write(p []byte) (int, error) {
|
|
||||||
n, err := fw.w.Write(p)
|
|
||||||
if fw.f != nil {
|
|
||||||
fw.f.Flush()
|
|
||||||
}
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleChatCompletion() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Verify user is authenticated
|
|
||||||
_, ok := r.Context().Value(middleware.ClaimsContextKey).(*auth.AppClaims)
|
|
||||||
if !ok {
|
|
||||||
render.Status(r, http.StatusUnauthorized)
|
|
||||||
render.JSON(w, r, map[string]string{"error": "User claims not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if openaiAPIKey == "" {
|
|
||||||
render.Status(r, http.StatusInternalServerError)
|
|
||||||
render.JSON(w, r, map[string]string{"error": "OpenAI API key is not configured on the server"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the original request body
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
render.Status(r, http.StatusInternalServerError)
|
|
||||||
render.JSON(w, r, map[string]string{"error": "Failed to read request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer r.Body.Close()
|
|
||||||
|
|
||||||
// Unmarshal to check if it's a streaming request
|
|
||||||
var req ChatCompletionRequest
|
|
||||||
if err := json.Unmarshal(body, &req); err != nil {
|
|
||||||
render.Status(r, http.StatusBadRequest)
|
|
||||||
render.JSON(w, r, map[string]string{"error": "Invalid JSON in request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the proxy request to OpenAI
|
|
||||||
proxyURL := openaiBaseURL + "/v1/chat/completions"
|
|
||||||
proxyReq, err := http.NewRequestWithContext(r.Context(), "POST", proxyURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
render.Status(r, http.StatusInternalServerError)
|
|
||||||
render.JSON(w, r, map[string]string{"error": "Failed to create proxy request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set necessary headers
|
|
||||||
proxyReq.Header.Set("Authorization", "Bearer "+openaiAPIKey)
|
|
||||||
proxyReq.Header.Set("Content-Type", "application/json")
|
|
||||||
proxyReq.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
// Send the request to OpenAI
|
|
||||||
client := &http.Client{Timeout: 5 * time.Minute}
|
|
||||||
resp, err := client.Do(proxyReq)
|
|
||||||
if err != nil {
|
|
||||||
render.Status(r, http.StatusBadGateway)
|
|
||||||
render.JSON(w, r, map[string]string{"error": "Failed to communicate with OpenAI API"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Handle the response based on whether it's a stream or not
|
|
||||||
if req.Stream != nil && *req.Stream {
|
|
||||||
// Streaming response
|
|
||||||
flusher, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy headers from OpenAI response to our response
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
|
|
||||||
fw := &FlusherWriter{w: w, f: flusher}
|
|
||||||
if _, err := io.Copy(fw, resp.Body); err != nil {
|
|
||||||
// Log error, but the response is likely already sent/broken.
|
|
||||||
log.Printf("Error streaming response from OpenAI: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Non-streaming response
|
|
||||||
// Copy headers from OpenAI response
|
|
||||||
for key, values := range resp.Header {
|
|
||||||
for _, value := range values {
|
|
||||||
w.Header().Add(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
io.Copy(w, resp.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
"excalidraw-complete/handlers/api/firebase"
|
"excalidraw-complete/handlers/api/firebase"
|
||||||
"excalidraw-complete/handlers/api/kv"
|
"excalidraw-complete/handlers/api/kv"
|
||||||
"excalidraw-complete/handlers/api/openai"
|
|
||||||
"excalidraw-complete/handlers/auth"
|
"excalidraw-complete/handlers/auth"
|
||||||
authMiddleware "excalidraw-complete/middleware"
|
authMiddleware "excalidraw-complete/middleware"
|
||||||
"excalidraw-complete/stores"
|
"excalidraw-complete/stores"
|
||||||
@@ -159,9 +158,6 @@ func setupRouter(store stores.Store, workspaceAPI *workspace.API) *chi.Mux {
|
|||||||
r.Delete("/", kv.HandleDeleteCanvas(store))
|
r.Delete("/", kv.HandleDeleteCanvas(store))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
r.Route("/chat", func(r chi.Router) {
|
|
||||||
r.Post("/completions", openai.HandleChatCompletion())
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Legacy anonymous document routes removed per project.md Phase 1.
|
// Legacy anonymous document routes removed per project.md Phase 1.
|
||||||
@@ -389,7 +385,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
auth.InitAuth()
|
auth.InitAuth()
|
||||||
openai.Init()
|
|
||||||
store := stores.GetStore()
|
store := stores.GetStore()
|
||||||
workspaceStore, err := workspace.NewStore(os.Getenv("DATABASE_URL"))
|
workspaceStore, err := workspace.NewStore(os.Getenv("DATABASE_URL"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+10
-2
@@ -822,7 +822,15 @@ func (s *Store) ListFolders(ctx context.Context, userID, teamID string) ([]Folde
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolderRequest) (*Folder, error) {
|
func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolderRequest) (*Folder, error) {
|
||||||
if ok, err := s.UserCanAccessTeam(ctx, userID, req.TeamID); err != nil || !ok {
|
teamID := strings.TrimSpace(req.TeamID)
|
||||||
|
if teamID == "" {
|
||||||
|
var err error
|
||||||
|
teamID, err = s.defaultTeamID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||||
return nil, ErrForbidden
|
return nil, ErrForbidden
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(req.Name)
|
name := strings.TrimSpace(req.Name)
|
||||||
@@ -836,7 +844,7 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
folder := &Folder{
|
folder := &Folder{
|
||||||
ID: newID(),
|
ID: newID(),
|
||||||
TeamID: req.TeamID,
|
TeamID: teamID,
|
||||||
ProjectID: req.ProjectID,
|
ProjectID: req.ProjectID,
|
||||||
ParentFolderID: req.ParentFolderID,
|
ParentFolderID: req.ParentFolderID,
|
||||||
Name: name,
|
Name: name,
|
||||||
|
|||||||
Reference in New Issue
Block a user