This commit is contained in:
Tomas Dvorak
2026-04-29 11:31:56 +02:00
parent 5fae9779ad
commit ef0b519058
24 changed files with 419 additions and 1162 deletions
-8
View File
@@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+89 -22
View File
@@ -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
+15 -38
View File
@@ -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
View File
@@ -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>
);
};
+17 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+5 -4
View File
@@ -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 }) => {
-1
View File
@@ -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 {
+22 -39
View File
@@ -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] || '?'}
+75 -186
View File
@@ -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);
+15 -5
View File
@@ -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>
);
};
-207
View File
@@ -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
View File
@@ -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
View File
@@ -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,