Compare commits

...

8 Commits

Author SHA1 Message Date
Tomas Dvorak 54c8088404 fix(db): handle explicit null values in update requests
Docker Images / Build and push (push) Failing after 16s
Introduce a `NullString` type to distinguish between JSON `null` and
absent fields in `UpdateDrawingRequest`. This ensures that when a
field is explicitly set to `null` in a request, the database can
correctly process the update.

Additionally, refactor the folder order migration to include proper
`goose` up/down instructions.
2026-05-21 14:17:58 +02:00
Tomas Dvorak cd22ee1ee8 feat(ui,api): implement multi-select and folder management enhancements
Implements multi-select functionality in the file browser, allowing users to
perform batch actions such as deleting or moving multiple drawings at once.
Adds full CRUD support for folders, including updating folder properties
and reordering folders via a new `sort_order` column in the database.

- feat(ui): add multi-select, batch delete, and batch move in FileBrowser
- feat(api): add endpoints for updating, deleting, and reordering folders
- feat(db): add `sort_order` column and index to `workspace_folders`
- fix(editor): integrate `useHandleLibrary` for better library management
- chore(deps): update excalidraw subproject
2026-05-21 13:20:44 +02:00
Tomas Dvorak 19e7ed6ea1 feat(ui): enhance file browser drag-and-drop and move functionality
Docker Images / Build and push (push) Failing after 15s
Refactor the file browser to improve the user experience and reliability of
folder management and item movement.

- Implement a dedicated drag handle wrapper to improve drag-and-drop
  precision and visual feedback.
- Improve drag-and-drop event handling to prevent accidental triggers
  and ensure correct visual states during drag operations.
- Refactor the move modal logic and styling for better clarity and
  usability.
- Fix folder menu closing logic to correctly handle outside clicks.
- Update CI workflow to ensure Docker is installed before building
  images.
2026-05-10 10:02:02 +02:00
Tomas Dvorak 8336c76705 fix(editor): correct templates, library import, and custom tools
- Move custom tools (checkbox, correct/incorrect, star) to floating
  canvas toolbar above native Excalidraw tools
- Fix correct/incorrect toggle: square text element cycling empty/
  check/cross with debounce to prevent double-firing
- Fix star rating: updates displayed stars on click
- Remove broken infinity-arrow feature entirely
- Fix autosave false "unsaved" on load by skipping first change
  after initial data normalization
- Fix presentation mode: remove opaque overlay blocking canvas,
  add pointer-events to only toolbar/thumbnails
- Fix to-do list add: full text area clickable via customData,
  correct spacing when inserting new tasks
- Add customData to all template "Add..." text elements so every
  template add button + text pair is fully clickable
- Expand generic add handler with role-specific creation for
  brainstorm, retro, swot, storymap, wireframe, timeline, api,
  sitemap, and persona templates
- Fix library import from libraries.excalidraw.com via #addLibrary
  hash: extract into reusable callback, listen to hashchange events,
  and offset imported elements to viewport center

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-10 10:01:06 +02:00
Tomas Dvorak 910546230d refactor(editor): improve type safety and optimize build chunks
Docker Images / Build and push (push) Failing after 21s
Refactor the Editor component to replace `any` types with explicit interfaces for Excalidraw props, library items, and elements, improving type safety and developer experience.

Additionally, update the Vite configuration to implement manual chunking for large dependencies like Excalidraw, React, and Zustand to optimize bundle loading and improve build performance.
2026-05-09 19:27:36 +02:00
Tomas Dvorak 190be65e4f feat(ui): implement folder management and enhance editor functionality
Implements full folder CRUD operations in the file browser, including
renaming, deleting, and drag-and-drop reordering. Enhances the editor
with improved autosave logic and new template role toggling.

- Add folder management (create, update, delete, reorder) to API and UI
- Implement drag-and-drop functionality for folders in FileBrowser
- Add folder context menus and improved styling for Editor and FileBrowser
- Optimize editor autosave to only trigger on actual data changes
- Add support for 'correct-incorrect' template roles in the editor
2026-05-09 18:54:57 +02:00
Tomas Dvorak 71dda9d45d feat(editor): implement autosave functionality and enhance UI
Docker Images / Build and push (push) Failing after 17s
Implements an autosave mechanism to prevent data loss by periodically
sending snapshots of the drawing to the backend. This includes new
API endpoints on the server and updated frontend services.

Additionally, improves the editor experience with:
- Enhanced CSRF protection and origin validation in the backend.
- Fix for React "Maximum update depth exceeded" error during scene
  mutations using a mutation guard.
- New presentation slide thumbnails and navigation UI.
- Expanded template library with various brainstorming layouts.
- Refined dashboard statistics and layout styling.
- Improved sidebar logo using SVG for better scaling.
2026-05-02 15:15:37 +02:00
Tomas Dvorak b79c214ad2 style(ui): refactor component styling and remove hand-drawn aesthetic
Refactor the frontend styling to use consistent design tokens and remove the hand-drawn/rotated aesthetic in favor of a cleaner, more standard UI.

- Replace hardcoded colors and border radii with CSS variables (e.g., `--default-border-color`, `--border-radius-lg`).
- Remove `transform: rotate(...)` and manual `box-shadow` offsets from various components (Sidebar, Dashboard, TemplatePicker, etc.).
- Update `Dashboard` to use a standard `ProgressBar` instead of a hand-drawn SVG chart.
- Standardize font families to use `--ui-font`.
- Clean up `TemplatePicker` logic to properly handle element grouping.
- Remove stale test result files and update `.last-run.json`.
2026-05-02 12:50:56 +02:00
28 changed files with 2706 additions and 1505 deletions
+9
View File
@@ -28,6 +28,15 @@ jobs:
id: image
run: echo "repository=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Set up Docker
run: |
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
rm get-docker.sh
fi
docker --version
- name: Use GitHub token for GHCR
uses: docker/login-action@v3
with:
+5 -7
View File
@@ -10,15 +10,14 @@
/* Excalidraw Context Menu Styling Overrides */
:global(.excalidraw .context-menu) {
background: var(--island-bg-color) !important;
border: 2px solid var(--color-gray-85) !important;
border-radius: 2px !important;
box-shadow: 4px 4px 0 var(--color-gray-85) !important;
transform: rotate(-0.2deg) !important;
border: 1px solid var(--default-border-color) !important;
border-radius: var(--border-radius-lg) !important;
box-shadow: var(--shadow-island-stronger) !important;
padding: 2px !important;
}
:global(.excalidraw .context-menu-item) {
border-radius: 2px !important;
border-radius: var(--border-radius-md) !important;
color: var(--color-gray-85) !important;
font-weight: 500 !important;
padding: 6px 12px !important;
@@ -27,11 +26,10 @@
:global(.excalidraw .context-menu-item:hover) {
background: var(--color-primary-light) !important;
color: var(--color-primary-darkest) !important;
transform: translateX(1px) !important;
}
:global(.excalidraw .context-menu-item-separator) {
border-top: 2px solid var(--color-gray-30) !important;
border-top: 1px solid var(--default-border-color) !important;
margin: 2px 4px !important;
}
@@ -8,7 +8,7 @@
.sidebar {
width: var(--sidebar-width);
background: var(--island-bg-color);
border-right: 2px solid var(--color-gray-85);
border-right: 1px solid var(--default-border-color);
display: flex;
flex-direction: column;
padding: var(--space-4);
@@ -18,15 +18,6 @@
bottom: 0;
z-index: 100;
transition: transform var(--duration-normal) var(--ease-out);
box-shadow: 3px 0 0 var(--color-gray-85);
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 23px,
var(--color-gray-20) 23px,
var(--color-gray-20) 24px
);
@media (max-width: 768px) {
transform: translateX(-100%);
@@ -81,17 +72,17 @@
}
.logoImg {
width: 28px;
width: auto;
height: 28px;
flex-shrink: 0;
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.1));
color: #fff;
}
.logoMark {
width: 32px;
height: 32px;
border: 2px solid var(--color-gray-85);
border-radius: 9px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
color: var(--color-gray-85);
background: var(--color-primary-light);
display: inline-flex;
@@ -99,7 +90,6 @@
justify-content: center;
font-size: var(--text-lg);
font-weight: 800;
transform: rotate(-4deg);
flex-shrink: 0;
}
@@ -141,25 +131,22 @@
padding: var(--space-3) var(--space-4);
color: var(--color-gray-70);
text-decoration: none;
border: 2px solid transparent;
border-radius: 2px;
border: 1px solid transparent;
border-radius: var(--border-radius-lg);
transition: all var(--duration-fast) var(--ease-out);
font-weight: 500;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-color: var(--color-gray-30);
transform: rotate(-0.5deg);
border-color: var(--default-border-color);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 600;
border-color: var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-0.3deg);
border-color: var(--color-primary);
}
}
@@ -236,8 +223,7 @@
.header {
height: var(--header-height);
background: var(--island-bg-color);
border-bottom: 2px solid var(--color-gray-85);
box-shadow: 0 3px 0 var(--color-gray-85);
border-bottom: 1px solid var(--default-border-color);
display: flex;
align-items: center;
justify-content: space-between;
@@ -297,11 +283,11 @@
.iconButton {
position: relative;
background: none;
border: 2px solid transparent;
border: 1px solid transparent;
color: var(--color-gray-60);
cursor: pointer;
padding: var(--space-2);
border-radius: 2px;
border-radius: var(--border-radius-lg);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -310,9 +296,7 @@
&:hover {
color: var(--color-on-surface);
background: var(--color-surface-low);
border-color: var(--color-gray-30);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-1deg);
border-color: var(--default-border-color);
}
}
@@ -396,8 +380,8 @@
.nameModal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--modal-shadow);
padding: var(--space-5);
width: 360px;
@@ -483,14 +467,13 @@
top: calc(100% + var(--space-2));
right: 100px;
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 5px 5px 0 var(--color-gray-85);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island-stronger);
width: 320px;
max-height: 400px;
overflow-y: auto;
z-index: 100;
transform: rotate(-0.2deg);
}
.notifHeader {
@@ -498,14 +481,14 @@
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 2px solid var(--color-gray-85);
border-bottom: 1px solid var(--default-border-color);
}
.notifTitle {
font-weight: 600;
font-size: var(--text-sm);
color: var(--color-gray-85);
font-family: 'Georgia', serif;
font-family: var(--ui-font);
}
.notifMarkAll {
+4 -8
View File
@@ -37,14 +37,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ open, onClose }) => {
>
<div className={styles.sidebarHeader}>
<div className={styles.logo}>
<img
src="https://plus.excalidraw.com/images/logo.svg"
alt="Excalidraw"
className={styles.logoImg}
width={28}
height={28}
/>
<span className={styles.logoText}>Excalidraw</span>
<svg viewBox="0 0 120 28" className={styles.logoImg} aria-label="Excalidraw+">
<text x="0" y="22" fontFamily="Virgil, Segoe UI Emoji, sans-serif" fontSize="20" fontWeight="700" fill="#ffffff">Excalidraw</text>
<text x="96" y="22" fontFamily="Virgil, Segoe UI Emoji, sans-serif" fontSize="20" fontWeight="700" fill="#ffffff" opacity="0.7">+</text>
</svg>
</div>
{onClose && (
<button
@@ -13,15 +13,14 @@
.modal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 8px 8px 0 var(--color-gray-85);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island-stronger);
width: 100%;
max-width: 720px;
max-height: 80vh;
overflow-y: auto;
padding: var(--space-6);
transform: rotate(-0.1deg);
}
.header {
@@ -42,17 +41,16 @@
.closeBtn {
background: none;
border: 2px solid transparent;
border: 1px solid transparent;
cursor: pointer;
color: var(--color-gray-60);
padding: var(--space-2);
border-radius: 2px;
border-radius: var(--border-radius-lg);
&:hover {
border-color: var(--color-gray-85);
border-color: var(--default-border-color);
color: var(--color-gray-90);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-1deg);
background: var(--color-surface-low);
}
}
@@ -69,21 +67,20 @@
text-align: center;
padding: var(--space-6) var(--space-4);
cursor: pointer;
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
background: var(--island-bg-color);
box-shadow: 2px 2px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
transition: all var(--duration-fast);
&:hover {
border-color: var(--color-primary);
transform: translateY(-2px) rotate(-0.3deg);
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: translateY(-2px);
box-shadow: var(--shadow-island-stronger);
}
&:active {
transform: translateY(0) rotate(0);
box-shadow: 1px 1px 0 var(--color-gray-85);
transform: translateY(0);
}
}
@@ -1,9 +1,9 @@
import React from 'react';
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool, KanbanSquare, MessageSquare, PanelsTopLeft, GitFork, Lightbulb, RotateCcw, Shield, Map, Timer, Layers } from 'lucide-react';
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool, KanbanSquare, MessageSquare, PanelsTopLeft, GitFork, Lightbulb, RotateCcw, Shield, Map, Timer, Layers, Database, Code, Globe, UserCircle } from 'lucide-react';
import { Card } from '@/components';
import styles from './TemplatePicker.module.scss';
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow' | 'kanban' | 'meeting' | 'wireframe' | 'mindmap' | 'brainstorm' | 'retrospective' | 'swot' | 'storymap' | 'timeline' | 'architecture';
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow' | 'kanban' | 'meeting' | 'wireframe' | 'mindmap' | 'brainstorm' | 'brainstorm-star' | 'brainstorm-matrix' | 'brainstorm-freeform' | 'brainstorm-fishbone' | 'brainstorm-venn' | 'brainstorm-tree' | 'brainstorm-converge' | 'retrospective' | 'swot' | 'storymap' | 'timeline' | 'architecture' | 'er-diagram' | 'api-design' | 'sitemap' | 'user-persona';
interface TemplatePickerProps {
isOpen: boolean;
@@ -21,7 +21,7 @@ interface TemplateOption {
elements: RawElement[];
}
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string, groupId?: string) {
function makeHandDrawnRect(x: number, y: number, w: number, h: number, groupId?: string) {
return {
id: `el-${Math.random().toString(36).slice(2)}`,
type: 'rectangle',
@@ -41,15 +41,15 @@ function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: st
version: 2,
versionNonce: Math.floor(Math.random() * 100000),
isDeleted: false,
boundElements: text ? [{ id: `txt-${Math.random().toString(36).slice(2)}`, type: 'text' }] : [],
boundElements: [],
updated: Date.now(),
link: null,
locked: false,
};
}
function makeText(x: number, y: number, text: string, fontSize = 20) {
return {
function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: string, customData?: Record<string, unknown>) {
const el: RawElement = {
id: `txt-${Math.random().toString(36).slice(2)}`,
type: 'text',
x, y, width: text.length * (fontSize * 0.55), height: fontSize * 1.4,
@@ -61,7 +61,7 @@ function makeText(x: number, y: number, text: string, fontSize = 20) {
strokeStyle: 'solid',
roughness: 1,
opacity: 100,
groupIds: [],
groupIds: groupId ? [groupId] : [],
frameId: null,
roundness: null,
seed: Math.floor(Math.random() * 10000),
@@ -77,11 +77,15 @@ function makeText(x: number, y: number, text: string, fontSize = 20) {
fontFamily: 1,
textAlign: 'left',
verticalAlign: 'top',
baseline: 18,
baseline: Math.round(fontSize * 0.7),
containerId: null,
originalText: text,
lineHeight: 1.25,
};
if (customData) {
el.customData = customData;
}
return el;
}
function makeCheckbox(x: number, y: number, checked = false) {
@@ -158,7 +162,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeCheckbox(60, 210, false),
makeText(90, 210, 'Third task'),
makeAddButton(60, 250, '+', 'todo-add'),
makeText(92, 250, 'Add task...', 16),
makeText(92, 250, 'Add task...', 16, undefined, { templateRole: 'todo-add', action: 'add' }),
makeHandDrawnRect(50, 290, 500, 2),
makeText(60, 310, 'Notes:', 18),
],
@@ -172,7 +176,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeCheckbox(60, 210, false),
makeText(90, 210, 'Another task', 18),
makeAddButton(60, 250, '+', 'checklist-add'),
makeText(92, 250, 'Add item...', 16),
makeText(92, 250, 'Add item...', 16, undefined, { templateRole: 'checklist-add', action: 'add' }),
],
list: [
makeHandDrawnRect(50, 50, 500, 50),
@@ -182,7 +186,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(60, 210, '• Third bullet point'),
makeText(60, 250, '• Fourth item with details'),
makeAddButton(60, 290, '+', 'list-add'),
makeText(92, 290, 'Add bullet...', 16),
makeText(92, 290, 'Add bullet...', 16, undefined, { templateRole: 'list-add', action: 'add' }),
],
flow: [
makeHandDrawnRect(200, 50, 200, 60),
@@ -197,7 +201,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(200, 350, 200, 60),
makeText(230, 370, 'End', 20),
makeAddButton(420, 180, '+', 'flow-add'),
makeText(452, 180, 'Add step', 14),
makeText(452, 180, 'Add step', 14, undefined, { templateRole: 'flow-add', action: 'add' }),
],
kanban: [
makeText(50, 40, 'Kanban Board', 30),
@@ -208,14 +212,14 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(285, 120, 'Doing', 20),
makeText(495, 120, 'Done', 20),
// Card 1 - grouped
makeHandDrawnRect(70, 170, 140, 70, undefined, 'card1'),
makeText(85, 190, 'User research', 16),
makeHandDrawnRect(70, 170, 140, 70, 'card1'),
makeText(85, 190, 'User research', 16, 'card1'),
// Card 2 - grouped
makeHandDrawnRect(280, 170, 140, 70, undefined, 'card2'),
makeText(295, 190, 'Sketch flow', 16),
makeHandDrawnRect(280, 170, 140, 70, 'card2'),
makeText(295, 190, 'Sketch flow', 16, 'card2'),
// Card 3 - grouped
makeHandDrawnRect(490, 170, 140, 70, undefined, 'card3'),
makeText(505, 190, 'Project brief', 16),
makeHandDrawnRect(490, 170, 140, 70, 'card3'),
makeText(505, 190, 'Project brief', 16, 'card3'),
// Add card buttons per column
makeAddButton(110, 380, '+', 'kanban-add-backlog'),
makeAddButton(320, 380, '+', 'kanban-add-doing'),
@@ -234,7 +238,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeCheckbox(70, 390, false),
makeText(105, 390, 'Owner and next step', 18),
makeAddButton(70, 430, '+', 'meeting-add-action'),
makeText(102, 430, 'Add action...', 14),
makeText(102, 430, 'Add action...', 14, undefined, { templateRole: 'meeting-add-action', action: 'add' }),
],
wireframe: [
makeText(50, 35, 'Page Wireframe', 30),
@@ -248,7 +252,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(265, 380, 190, 110),
makeHandDrawnRect(480, 380, 190, 110),
makeAddButton(480, 500, '+', 'wireframe-add-section'),
makeText(512, 500, 'Add section', 14),
makeText(512, 500, 'Add section', 14, undefined, { templateRole: 'wireframe-add-section', action: 'add' }),
],
mindmap: [
makeHandDrawnRect(240, 200, 200, 70),
@@ -285,12 +289,173 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(520, 196, 'Idea 3', 18),
makeArrow(460, 110, 580, 180),
makeAddButton(50, 240, '+', 'brainstorm-add'),
makeText(82, 240, 'Add idea...', 16),
makeText(82, 240, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
// Notes area
makeHandDrawnRect(50, 280, 610, 100),
makeText(70, 300, 'Notes & connections:', 18),
makeText(70, 330, '- Write insights here', 16),
],
'brainstorm-star': [
makeText(50, 30, 'Brainstorm — Star', 30),
// Central hub
makeHandDrawnRect(260, 200, 180, 60),
makeText(290, 220, 'Core Idea', 22),
// 6 radial branches (top, top-right, bottom-right, bottom, bottom-left, top-left)
makeHandDrawnRect(280, 60, 140, 50),
makeText(300, 76, 'Branch 1', 18),
makeArrow(350, 200, 350, 110),
makeHandDrawnRect(480, 140, 140, 50),
makeText(500, 156, 'Branch 2', 18),
makeArrow(440, 220, 480, 165),
makeHandDrawnRect(480, 280, 140, 50),
makeText(500, 296, 'Branch 3', 18),
makeArrow(440, 240, 480, 305),
makeHandDrawnRect(280, 320, 140, 50),
makeText(300, 336, 'Branch 4', 18),
makeArrow(350, 260, 350, 320),
makeHandDrawnRect(60, 280, 140, 50),
makeText(80, 296, 'Branch 5', 18),
makeArrow(260, 240, 200, 305),
makeHandDrawnRect(60, 140, 140, 50),
makeText(80, 156, 'Branch 6', 18),
makeArrow(260, 220, 200, 165),
makeAddButton(50, 400, '+', 'brainstorm-add'),
makeText(82, 400, 'Add branch...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-matrix': [
makeText(50, 30, 'Brainstorm — Matrix', 30),
// 2x2 grid
makeHandDrawnRect(50, 90, 300, 160),
makeText(150, 130, 'Quadrant A', 20),
makeText(80, 170, '- Idea 1', 16),
makeText(80, 200, '- Idea 2', 16),
makeHandDrawnRect(370, 90, 300, 160),
makeText(470, 130, 'Quadrant B', 20),
makeText(400, 170, '- Idea 1', 16),
makeText(400, 200, '- Idea 2', 16),
makeHandDrawnRect(50, 270, 300, 160),
makeText(150, 310, 'Quadrant C', 20),
makeText(80, 350, '- Idea 1', 16),
makeText(80, 380, '- Idea 2', 16),
makeHandDrawnRect(370, 270, 300, 160),
makeText(470, 310, 'Quadrant D', 20),
makeText(400, 350, '- Idea 1', 16),
makeText(400, 380, '- Idea 2', 16),
makeAddButton(50, 450, '+', 'brainstorm-add'),
makeText(82, 450, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-freeform': [
makeText(50, 30, 'Brainstorm — Freeform', 30),
makeText(50, 70, 'Drag sticky notes anywhere!', 16),
// Scattered sticky notes
makeHandDrawnRect(60, 110, 160, 80),
makeText(80, 140, '💡 Idea 1', 18),
makeHandDrawnRect(260, 130, 160, 80),
makeText(280, 160, '🚀 Idea 2', 18),
makeHandDrawnRect(460, 110, 160, 80),
makeText(480, 140, '🎯 Idea 3', 18),
makeHandDrawnRect(120, 230, 160, 80),
makeText(140, 260, '❓ Idea 4', 18),
makeHandDrawnRect(340, 250, 160, 80),
makeText(360, 280, '✨ Idea 5', 18),
makeAddButton(50, 360, '+', 'brainstorm-add'),
makeText(82, 360, 'Add note...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-fishbone': [
makeText(50, 30, 'Brainstorm — Fishbone', 30),
// Spine
makeArrow(100, 250, 600, 250),
// Problem head
makeHandDrawnRect(600, 220, 140, 60),
makeText(620, 240, 'Problem', 18),
// Top branches
makeArrow(220, 250, 180, 160),
makeHandDrawnRect(120, 110, 160, 50),
makeText(140, 128, 'People', 16),
makeArrow(380, 250, 340, 160),
makeHandDrawnRect(280, 110, 160, 50),
makeText(300, 128, 'Process', 16),
makeArrow(540, 250, 500, 160),
makeHandDrawnRect(440, 110, 160, 50),
makeText(460, 128, 'Policy', 16),
// Bottom branches
makeArrow(280, 250, 240, 340),
makeHandDrawnRect(180, 340, 160, 50),
makeText(200, 358, 'Place', 16),
makeArrow(460, 250, 420, 340),
makeHandDrawnRect(360, 340, 160, 50),
makeText(380, 358, 'Product', 16),
makeAddButton(50, 420, '+', 'brainstorm-add'),
makeText(82, 420, 'Add cause...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-venn': [
makeText(50, 30, 'Brainstorm — Venn', 30),
// Three overlapping circles
makeHandDrawnRect(120, 120, 160, 160),
makeText(170, 130, 'A', 20),
makeText(140, 190, 'Set A traits', 14),
makeHandDrawnRect(280, 120, 160, 160),
makeText(330, 130, 'B', 20),
makeText(300, 190, 'Set B traits', 14),
makeHandDrawnRect(200, 220, 160, 160),
makeText(250, 230, 'C', 20),
makeText(220, 290, 'Set C traits', 14),
// Center overlap note
makeText(245, 190, 'Overlap', 12),
makeAddButton(50, 400, '+', 'brainstorm-add'),
makeText(82, 400, 'Add set...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-tree': [
makeText(50, 30, 'Brainstorm — Tree', 30),
// Root
makeHandDrawnRect(280, 90, 160, 50),
makeText(310, 110, 'Root Topic', 18),
// Branches
makeArrow(360, 140, 200, 200),
makeHandDrawnRect(120, 190, 160, 50),
makeText(145, 210, 'Branch 1', 16),
makeArrow(360, 140, 360, 200),
makeHandDrawnRect(280, 190, 160, 50),
makeText(305, 210, 'Branch 2', 16),
makeArrow(360, 140, 520, 200),
makeHandDrawnRect(440, 190, 160, 50),
makeText(465, 210, 'Branch 3', 16),
// Leaves
makeArrow(200, 240, 140, 300),
makeHandDrawnRect(60, 290, 140, 40),
makeText(85, 305, 'Leaf 1a', 14),
makeArrow(200, 240, 260, 300),
makeHandDrawnRect(200, 290, 140, 40),
makeText(225, 305, 'Leaf 1b', 14),
makeArrow(520, 240, 460, 300),
makeHandDrawnRect(440, 290, 140, 40),
makeText(465, 305, 'Leaf 3a', 14),
makeAddButton(50, 360, '+', 'brainstorm-add'),
makeText(82, 360, 'Add branch...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-converge': [
makeText(50, 30, 'Brainstorm — Converge', 30),
// Diverging ideas (top)
makeHandDrawnRect(80, 90, 140, 50),
makeText(105, 110, 'Idea A', 16),
makeHandDrawnRect(280, 90, 140, 50),
makeText(305, 110, 'Idea B', 16),
makeHandDrawnRect(480, 90, 140, 50),
makeText(505, 110, 'Idea C', 16),
// Converging arrows
makeArrow(150, 140, 290, 220),
makeArrow(350, 140, 330, 220),
makeArrow(550, 140, 370, 220),
// Converged outcome
makeHandDrawnRect(220, 220, 260, 70),
makeText(260, 245, 'Combined Solution', 20),
// Next steps
makeArrow(350, 290, 350, 350),
makeHandDrawnRect(240, 350, 220, 50),
makeText(265, 370, 'Action Plan', 16),
makeAddButton(50, 430, '+', 'brainstorm-add'),
makeText(82, 430, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
retrospective: [
makeText(50, 30, 'Retrospective', 30),
// Went Well
@@ -368,19 +533,19 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(50, 300, 600, 2),
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
makeAddButton(50, 350, '+', 'storymap-add-row'),
makeText(82, 350, 'Add row...', 14),
makeText(82, 350, 'Add row...', 14, undefined, { templateRole: 'storymap-add-row', action: 'add' }),
],
timeline: [
makeText(50, 30, 'Project Timeline', 30),
makeHandDrawnRect(50, 90, 600, 4),
// Milestones
makeHandDrawnRect(80, 70, 20, 44, undefined, 'milestone-1'),
makeHandDrawnRect(80, 70, 20, 44, 'milestone-1'),
makeText(60, 125, 'Q1 Kickoff', 14),
makeHandDrawnRect(220, 70, 20, 44, undefined, 'milestone-2'),
makeHandDrawnRect(220, 70, 20, 44, 'milestone-2'),
makeText(200, 125, 'Design', 14),
makeHandDrawnRect(360, 70, 20, 44, undefined, 'milestone-3'),
makeHandDrawnRect(360, 70, 20, 44, 'milestone-3'),
makeText(340, 125, 'Build', 14),
makeHandDrawnRect(500, 70, 20, 44, undefined, 'milestone-4'),
makeHandDrawnRect(500, 70, 20, 44, 'milestone-4'),
makeText(480, 125, 'Launch', 14),
// Tasks below timeline
makeHandDrawnRect(50, 170, 130, 50),
@@ -392,7 +557,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(500, 170, 130, 50),
makeText(515, 185, 'Deploy', 14),
makeAddButton(80, 240, '+', 'timeline-add'),
makeText(112, 240, 'Add phase...', 14),
makeText(112, 240, 'Add phase...', 14, undefined, { templateRole: 'timeline-add', action: 'add' }),
],
architecture: [
makeText(50, 30, 'System Architecture', 30),
@@ -419,7 +584,85 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(50, 200, 160, 70),
makeText(90, 220, 'CDN', 18),
makeAddButton(300, 290, '+', 'architecture-add'),
makeText(332, 290, 'Add component...', 14),
makeText(332, 290, 'Add component...', 14, undefined, { templateRole: 'architecture-add', action: 'add' }),
],
'er-diagram': [
makeText(50, 30, 'ER Diagram', 30),
// User entity
makeHandDrawnRect(50, 90, 160, 120),
makeText(70, 110, 'User', 18),
makeText(70, 140, 'id: PK', 14),
makeText(70, 164, 'email: string', 14),
makeText(70, 188, 'name: string', 14),
// Relationship line
makeArrow(210, 150, 270, 150),
makeText(220, 130, '1:N', 12),
// Order entity
makeHandDrawnRect(270, 90, 160, 120),
makeText(290, 110, 'Order', 18),
makeText(290, 140, 'id: PK', 14),
makeText(290, 164, 'user_id: FK', 14),
makeText(290, 188, 'total: number', 14),
],
'api-design': [
makeText(50, 30, 'API Design', 30),
makeHandDrawnRect(50, 90, 600, 50),
makeText(70, 110, 'GET /users → List users', 16),
makeHandDrawnRect(50, 150, 600, 50),
makeText(70, 170, 'POST /users → Create user', 16),
makeHandDrawnRect(50, 210, 600, 50),
makeText(70, 230, 'GET /users/:id → Get user', 16),
makeHandDrawnRect(50, 270, 600, 50),
makeText(70, 290, 'PATCH /users/:id → Update user', 16),
makeHandDrawnRect(50, 330, 600, 50),
makeText(70, 350, 'DELETE /users/:id → Delete user', 16),
makeAddButton(50, 400, '+', 'api-add'),
makeText(82, 400, 'Add endpoint...', 14, undefined, { templateRole: 'api-add', action: 'add' }),
],
'sitemap': [
makeText(50, 30, 'Site Map', 30),
// Home
makeHandDrawnRect(280, 90, 140, 50),
makeText(320, 112, 'Home', 18),
// Pages below
makeHandDrawnRect(50, 180, 140, 50),
makeText(75, 202, 'Products', 16),
makeHandDrawnRect(220, 180, 140, 50),
makeText(250, 202, 'Pricing', 16),
makeHandDrawnRect(390, 180, 140, 50),
makeText(420, 202, 'About', 16),
makeHandDrawnRect(560, 180, 140, 50),
makeText(585, 202, 'Contact', 16),
// Connections
makeArrow(350, 140, 120, 180),
makeArrow(350, 140, 290, 180),
makeArrow(350, 140, 460, 180),
makeArrow(350, 140, 630, 180),
makeAddButton(50, 260, '+', 'sitemap-add'),
makeText(82, 260, 'Add page...', 14, undefined, { templateRole: 'sitemap-add', action: 'add' }),
],
'user-persona': [
makeText(50, 30, 'User Persona', 30),
// Name & role
makeHandDrawnRect(50, 90, 300, 70),
makeText(70, 112, 'Name: Alex, 32, Designer', 18),
// Goals
makeHandDrawnRect(50, 180, 300, 130),
makeText(70, 200, 'Goals 🎯', 18),
makeText(70, 230, '- Save time on workflows', 14),
makeText(70, 254, '- Collaborate easily', 14),
// Frustrations
makeHandDrawnRect(370, 90, 300, 220),
makeText(390, 112, 'Frustrations 😤', 18),
makeText(390, 142, '- Too many tools', 14),
makeText(390, 166, '- Slow feedback loops', 14),
makeText(390, 190, '- Hard to share ideas', 14),
makeText(390, 214, '- No single source of truth', 14),
// Behaviors
makeHandDrawnRect(50, 330, 620, 70),
makeText(70, 352, 'Behaviors: Uses Figma, Slack, Notion. Prefers visual tools.', 14),
makeAddButton(50, 420, '+', 'persona-add'),
makeText(82, 420, 'Add trait...', 14, undefined, { templateRole: 'persona-add', action: 'add' }),
],
};
@@ -434,11 +677,22 @@ const OPTIONS: TemplateOption[] = [
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: PanelsTopLeft, elements: [] },
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: GitFork, elements: [] },
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a central topic', icon: Lightbulb, elements: [] },
{ id: 'brainstorm-star', label: 'Star Brainstorm', description: 'Radial branches from core idea', icon: Lightbulb, elements: [] },
{ id: 'brainstorm-matrix', label: 'Matrix Brainstorm', description: '2×2 grid for categorizing ideas', icon: Lightbulb, elements: [] },
{ id: 'brainstorm-freeform', label: 'Freeform Notes', description: 'Scattered sticky notes layout', icon: Lightbulb, elements: [] },
{ id: 'brainstorm-fishbone', label: 'Fishbone Diagram', description: 'Root-cause analysis with causes', icon: Lightbulb, elements: [] },
{ id: 'brainstorm-venn', label: 'Venn Diagram', description: 'Compare overlapping sets', icon: Lightbulb, elements: [] },
{ id: 'brainstorm-tree', label: 'Tree Diagram', description: 'Hierarchical branching topics', icon: Lightbulb, elements: [] },
{ id: 'brainstorm-converge', label: 'Converge Map', description: 'Ideas merging into a solution', icon: Lightbulb, elements: [] },
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: RotateCcw, elements: [] },
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opportunities, threats', icon: Shield, elements: [] },
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: Map, elements: [] },
{ id: 'timeline', label: 'Timeline', description: 'Project phases and milestones', icon: Timer, elements: [] },
{ id: 'architecture', label: 'Architecture', description: 'System components and connections', icon: Layers, elements: [] },
{ id: 'er-diagram', label: 'ER Diagram', description: 'Entity relationship with tables', icon: Database, elements: [] },
{ id: 'api-design', label: 'API Design', description: 'REST endpoints and methods', icon: Code, elements: [] },
{ id: 'sitemap', label: 'Site Map', description: 'Website page hierarchy', icon: Globe, elements: [] },
{ id: 'user-persona', label: 'User Persona', description: 'Goals, frustrations, behaviors', icon: UserCircle, elements: [] },
];
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
@@ -13,17 +13,15 @@
gap: var(--space-6);
padding: var(--space-5) var(--space-6);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(-0.3deg);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island-stronger);
h1 {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
font-family: 'Georgia', serif;
font-family: var(--ui-font);
letter-spacing: -0.02em;
}
}
@@ -84,21 +82,16 @@
}
.statCardWrapper {
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(0.15deg);
transition: transform 0.15s ease, box-shadow 0.15s ease;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
transition: all 0.2s var(--ease-out);
background: var(--island-bg-color);
&:hover {
transform: rotate(0) translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--color-gray-85);
box-shadow: var(--shadow-island-stronger);
transform: translateY(-2px);
}
&:nth-child(2) { transform: rotate(-0.1deg); }
&:nth-child(3) { transform: rotate(0.25deg); }
&:nth-child(4) { transform: rotate(-0.2deg); }
&:nth-child(5) { transform: rotate(0.05deg); }
}
.statCard {
@@ -107,7 +100,9 @@
align-items: flex-start;
text-align: left;
padding: var(--space-5);
min-height: 150px;
min-height: 140px;
position: relative;
overflow: hidden;
}
.statTop {
@@ -115,69 +110,70 @@
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: var(--space-3);
margin-bottom: var(--space-2);
}
.statIcon {
width: 40px;
height: 40px;
border-radius: 50%;
width: 36px;
height: 36px;
border-radius: var(--border-radius-lg);
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-primary-light);
border: 2px solid var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-2deg);
}
.handChart {
width: 80px;
height: 40px;
flex-shrink: 0;
transform: rotate(1deg);
}
.sparkline {
width: 100%;
height: 28px;
margin-top: var(--space-2);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
opacity: 0.9;
}
.statValue {
font-size: var(--text-3xl);
font-size: var(--text-2xl);
font-weight: 700;
line-height: 1;
font-family: 'Georgia', serif;
font-family: var(--ui-font);
margin-top: var(--space-2);
}
.statLabel {
font-size: var(--text-sm);
color: var(--color-muted);
margin-top: var(--space-1);
margin-bottom: var(--space-2);
}
.chartBarWrap {
.statBarTrack {
width: 100%;
height: 6px;
background: var(--color-gray-20);
border-radius: var(--border-radius-full);
margin-top: auto;
overflow: hidden;
}
.statBarFill {
height: 100%;
border-radius: var(--border-radius-full);
transition: width 0.6s var(--ease-out);
}
.progressBarWrap {
position: relative;
width: 100%;
height: 6px;
margin-top: var(--space-3);
border-radius: var(--border-radius-full);
overflow: hidden;
}
.chartBarBg {
.progressBarBg {
position: absolute;
inset: 0;
background: var(--color-gray-20);
border-radius: var(--border-radius-full);
}
.chartBar {
.progressBarFill {
position: absolute;
inset: 0;
border-radius: var(--border-radius-full);
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-darkest));
transition: width 0.4s var(--ease-out);
}
@@ -230,22 +226,21 @@
gap: var(--space-3);
padding: var(--space-3) var(--space-2);
margin-bottom: var(--space-2);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
cursor: pointer;
transition: all 0.15s ease;
box-shadow: 2px 2px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
background: var(--island-bg-color);
&:hover {
border-color: var(--color-primary);
background: var(--color-surface-low);
transform: translateX(2px) rotate(-0.3deg);
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: translateX(2px);
box-shadow: var(--shadow-island);
}
&:last-child {
border-bottom: 2px solid var(--color-gray-30);
margin-bottom: 0;
}
}
@@ -253,12 +248,12 @@
.drawingThumb {
width: 48px;
height: 48px;
border-radius: 2px;
border-radius: var(--border-radius-lg);
overflow: hidden;
background: var(--color-surface-low);
flex-shrink: 0;
border: 2px solid var(--color-gray-30);
box-shadow: 2px 2px 0 var(--color-gray-85);
border: 1px solid var(--default-border-color);
box-shadow: var(--shadow-island);
img {
width: 100%;
@@ -346,20 +341,26 @@
.activityItem {
display: flex;
align-items: center;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) 0;
padding: var(--space-3) var(--space-2);
border-bottom: 1px solid var(--color-gray-20);
transition: background 0.15s ease;
border-radius: var(--border-radius-md);
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--color-surface-low);
}
}
.activityAvatar {
width: 32px;
height: 32px;
border-radius: 2px;
width: 34px;
height: 34px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
@@ -368,17 +369,19 @@
font-size: var(--text-xs);
font-weight: 600;
flex-shrink: 0;
border: 2px solid var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
border: 2px solid var(--island-bg-color);
box-shadow: var(--shadow-island);
}
.activityInfo {
flex: 1;
min-width: 0;
}
.activityText {
font-size: var(--text-sm);
color: var(--color-gray-80);
line-height: 1.4;
}
.activityTime {
@@ -399,12 +402,11 @@
.modal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 5px 5px 0 var(--color-gray-85);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island-stronger);
width: 420px;
max-width: 90vw;
transform: rotate(-0.3deg);
}
.modalHeader {
@@ -412,12 +414,12 @@
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 2px solid var(--color-gray-85);
border-bottom: 1px solid var(--default-border-color);
h3 {
margin: 0;
font-size: var(--text-lg);
color: var(--color-gray-85);
font-family: 'Georgia', serif;
font-family: var(--ui-font);
}
}
@@ -444,15 +446,15 @@
.modalInput {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
background: var(--input-bg-color);
color: var(--color-on-surface);
font-size: var(--text-sm);
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 3px 3px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
}
}
@@ -465,25 +467,25 @@
.modalBtnSecondary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-30);
border-radius: var(--border-radius-lg);
border: 1px solid var(--default-border-color);
background: transparent;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover { background: var(--color-surface-low); transform: rotate(-0.5deg); }
box-shadow: var(--shadow-island);
&:hover { background: var(--color-surface-low); }
}
.modalBtnPrimary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-85);
border-radius: var(--border-radius-lg);
border: 1px solid var(--default-border-color);
background: var(--color-primary);
color: white;
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover { background: var(--color-primary-darker); transform: rotate(-0.5deg); }
box-shadow: var(--shadow-island);
&:hover { background: var(--color-primary-darker); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
+10 -127
View File
@@ -9,86 +9,15 @@ import styles from './Dashboard.module.scss';
const ACTIVITY_LIMIT = 5;
const HandDrawnChart: React.FC<{ value: number; max: number; color?: string }> = ({ value, max, color = '#6965db' }) => {
const StatBar: React.FC<{ value: number; max: number; color: string }> = ({ value, max, color }) => {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const w = 120;
const h = 60;
const pad = 6;
const barW = ((w - pad * 2) * pct) / 100;
const roughness = 1.2;
const r = () => (Math.random() - 0.5) * roughness;
return (
<svg className={styles.handChart} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
<path
d={`M${pad + r()},${pad + r()} L${w - pad + r()},${pad + r()} L${w - pad + r()},${h - pad + r()} L${pad + r()},${h - pad + r()} Z`}
fill="none"
stroke="var(--color-gray-40)"
strokeWidth="1"
strokeLinecap="round"
<div className={styles.statBarTrack} aria-hidden="true">
<div
className={styles.statBarFill}
style={{ width: `${pct}%`, backgroundColor: color }}
/>
{pct > 0 && (
<path
d={`M${pad + r()},${h - pad + r()} L${pad + r()},${pad + r()} L${pad + barW + r()},${pad + r()} L${pad + barW + r()},${h - pad + r()} Z`}
fill={color}
stroke={color}
strokeWidth="1"
opacity="0.35"
strokeLinecap="round"
/>
)}
<path
d={`M${pad + r()},${h - pad + r()} L${pad + barW + r()},${h - pad + r()}`}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d={`M${pad + r()},${pad + r()} L${pad + barW + r()},${pad + r()}`}
fill="none"
stroke={color}
strokeWidth="1"
strokeLinecap="round"
opacity="0.5"
/>
</svg>
);
};
const MiniSparkline: React.FC<{ data: number[]; color?: string }> = ({ data, color = '#6965db' }) => {
if (!data.length) return null;
const w = 140;
const h = 40;
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
const stepX = w / (data.length - 1 || 1);
const points = data.map((v, i) => {
const x = i * stepX;
const y = h - ((v - min) / range) * (h - 4) - 2;
return `${x + (Math.random() - 0.5) * 0.8},${y + (Math.random() - 0.5) * 0.8}`;
}).join(' ');
return (
<svg className={styles.sparkline} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
<polyline
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
opacity="0.7"
/>
{data.map((v, i) => {
const x = i * stepX;
const y = h - ((v - min) / range) * (h - 4) - 2;
return <circle key={i} cx={x} cy={y} r="2" fill={color} opacity="0.5" />;
})}
</svg>
</div>
);
};
@@ -98,8 +27,6 @@ export const Dashboard: React.FC = () => {
const { recentDrawings, setRecentDrawings, activity, setActivity } = useDrawingStore();
const { user } = useAuthStore();
const [isCreating, setIsCreating] = useState(false);
const [showNameModal, setShowNameModal] = useState(false);
const [newDrawingName, setNewDrawingName] = useState('');
const [statsData, setStatsData] = useState({
teams: 0,
members: 0,
@@ -130,18 +57,11 @@ export const Dashboard: React.FC = () => {
loadData();
}, [setRecentDrawings, setActivity]);
const handleCreateDrawing = () => {
setNewDrawingName('');
setShowNameModal(true);
};
const confirmCreateDrawing = async () => {
const title = newDrawingName.trim() || 'Untitled Drawing';
const handleCreateDrawing = async () => {
setIsCreating(true);
setShowNameModal(false);
try {
const newDrawing = await api.drawings.create({
title,
title: 'Untitled Drawing',
visibility: 'team',
});
setRecentDrawings([newDrawing, ...recentDrawings]);
@@ -165,13 +85,6 @@ export const Dashboard: React.FC = () => {
const storageMax = Math.max(Number(statsData.storage_bytes), 1024 * 1024);
const statColors = ['#6965db', '#339af0', '#40c057', '#fcc419', '#ff6b6b'];
const sparkData = [
[2, 4, 3, 8, 5, 9, statsData.drawings],
[1, 2, 3, 3, 4, 5, statsData.projects + statsData.folders],
[1, 1, 1, 1, 2, 2, statsData.teams],
[5, 8, 12, 15, 20, 25, statsData.revisions],
[1024, 2048, 4096, 8192, 16384, 32768, Number(statsData.storage_bytes)],
];
const stats = [
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, chartValue: statsData.drawings, max: maxStat, icon: FileText, color: statColors[0] },
@@ -224,18 +137,17 @@ export const Dashboard: React.FC = () => {
</div>
<div className={styles.statsGrid}>
{stats.map((stat, idx) => (
{stats.map((stat) => (
<Card key={stat.label} className={styles.statCardWrapper}>
<CardContent className={styles.statCard}>
<div className={styles.statTop}>
<div className={styles.statIcon} style={{ color: stat.color, borderColor: stat.color }}>
<stat.icon size={22} />
</div>
<HandDrawnChart value={stat.chartValue} max={stat.max} color={stat.color} />
</div>
<div className={styles.statValue} style={{ color: stat.color }}>{stat.value}</div>
<div className={styles.statLabel}>{stat.label}</div>
<MiniSparkline data={sparkData[idx]} color={stat.color} />
<StatBar value={stat.chartValue} max={stat.max} color={stat.color} />
</CardContent>
</Card>
))}
@@ -342,35 +254,6 @@ export const Dashboard: React.FC = () => {
</div>
</div>
{showNameModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="new-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setShowNameModal(false); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="new-drawing-title">New Drawing</h3>
<button className={styles.modalClose} onClick={() => setShowNameModal(false)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<label htmlFor="drawing-name">Name</label>
<input
id="drawing-name"
type="text"
autoFocus
placeholder="Untitled Drawing"
value={newDrawingName}
onChange={(e) => setNewDrawingName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmCreateDrawing(); if (e.key === 'Escape') setShowNameModal(false); }}
className={styles.modalInput}
/>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setShowNameModal(false)}>Cancel</button>
<button className={styles.modalBtnPrimary} onClick={confirmCreateDrawing} disabled={isCreating}>
{isCreating ? <Loader2 size={16} className={styles.spinner} /> : 'Create'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
+183 -12
View File
@@ -38,6 +38,13 @@
gap: var(--space-2);
}
.toolbarDivider {
width: 1px;
height: 24px;
background: var(--default-border-color);
margin: 0 var(--space-2);
}
.title {
font-weight: 500;
color: var(--color-gray-85);
@@ -73,11 +80,46 @@
flex: 1;
position: relative;
overflow: hidden;
min-height: 0;
:global(.excalidraw) {
width: 100%;
height: 100%;
}
:global(.excalidrew-wrapper) {
width: 100%;
height: 100%;
}
// Ensure Excalidraw's internal layout fills the container
:global(.excalidraw .excalidraw-canvas-container) {
width: 100% !important;
height: 100% !important;
}
}
.canvasTools {
position: absolute;
top: 8px;
left: 8px;
z-index: 10;
display: flex;
gap: 4px;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
padding: 4px;
box-shadow: var(--shadow-island);
button {
padding: 4px;
min-width: 32px;
min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
}
.loadingCanvas {
@@ -390,11 +432,18 @@
.presentationOverlay {
position: fixed;
top: 12px;
right: 12px;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
pointer-events: auto;
pointer-events: none;
animation: presentationFadeIn 0.3s var(--ease-out);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: var(--space-4);
}
@keyframes presentationFadeIn {
@@ -406,19 +455,85 @@
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
padding: var(--space-2) var(--space-4);
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(-0.3deg);
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-xl);
padding: var(--space-3) var(--space-5);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
margin-bottom: var(--space-4);
pointer-events: auto;
}
.presentationLabel {
font-size: var(--text-sm);
color: var(--color-gray-70);
font-weight: 500;
font-family: 'Georgia', serif;
font-family: var(--ui-font);
}
.presentationSlides {
display: flex;
gap: var(--space-2);
margin-top: var(--space-3);
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
padding: var(--space-2);
box-shadow: var(--shadow-island);
max-width: 400px;
overflow-x: auto;
pointer-events: auto;
}
.presentationSlideThumb {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 56px;
padding: var(--space-2);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
background: transparent;
cursor: pointer;
transition: all 0.15s ease;
color: var(--color-gray-70);
&:hover {
background: var(--color-gray-10);
border-color: var(--color-gray-30);
}
}
.presentationSlideActive {
border-color: var(--color-primary);
background: var(--color-primary-light);
color: var(--color-primary);
}
.presentationSlideNumber {
font-size: var(--text-xs);
font-weight: 600;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--border-radius-full);
background: var(--color-gray-20);
}
.presentationSlideActive .presentationSlideNumber {
background: var(--color-primary);
color: white;
}
.presentationSlideName {
font-size: 10px;
max-width: 60px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modalOverlay {
@@ -433,8 +548,8 @@
.modal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--modal-shadow);
width: 420px;
max-width: 90vw;
@@ -555,3 +670,59 @@
z-index: 80;
}
}
// Excalidraw context menu styling
:global(.context-menu),
:global(.excalidraw-context-menu) {
background: var(--island-bg-color) !important;
border: 1px solid var(--default-border-color) !important;
border-radius: var(--border-radius-xl) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
padding: var(--space-1) !important;
.context-menu-item,
.menu-item {
border-radius: var(--border-radius-lg) !important;
padding: var(--space-2) var(--space-3) !important;
margin: 2px 0 !important;
font-size: var(--text-sm) !important;
&:hover {
background: var(--color-surface-low) !important;
}
&:first-child {
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0 !important;
}
&:last-child {
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg) !important;
}
}
.context-menu-separator,
.menu-item-separator {
background: var(--default-border-color) !important;
margin: var(--space-1) var(--space-2) !important;
}
}
// Excalidraw dropdown menus
:global(.dropdown-menu),
:global(.excalidraw-dropdown) {
background: var(--island-bg-color) !important;
border: 1px solid var(--default-border-color) !important;
border-radius: var(--border-radius-xl) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
padding: var(--space-1) !important;
.dropdown-menu-item,
.menu-item {
border-radius: var(--border-radius-lg) !important;
padding: var(--space-2) var(--space-3) !important;
&:hover {
background: var(--color-surface-low) !important;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -18,10 +18,9 @@
flex-wrap: wrap;
padding: var(--space-5);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(0.2deg);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island-stronger);
@media (max-width: 640px) {
flex-direction: column;
@@ -115,9 +114,9 @@
width: 240px;
flex-shrink: 0;
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
padding: var(--space-3);
align-self: flex-start;
@@ -133,6 +132,10 @@
list-style: none;
padding: 0;
margin: 0;
li {
position: relative;
}
}
.folderItem {
@@ -140,30 +143,32 @@
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: 2px;
border-radius: var(--border-radius-lg);
color: var(--color-gray-70);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
background: none;
border: 2px solid transparent;
border: 1px solid transparent;
width: 100%;
text-align: left;
font-size: var(--text-sm);
position: relative;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-color: var(--color-gray-30);
transform: rotate(-0.3deg);
border-color: var(--default-border-color);
}
&.folderActive {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 600;
border-color: var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-0.2deg);
border-color: var(--color-primary);
}
&.dragging {
opacity: 0.5;
}
svg {
@@ -172,6 +177,97 @@
}
}
.dragHandleWrapper {
cursor: grab;
display: flex;
align-items: center;
padding: 2px;
border-radius: var(--border-radius-sm);
&:hover {
background: var(--color-surface-low);
}
&:active {
cursor: grabbing;
}
}
.dragHandle {
color: var(--color-muted);
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out);
flex-shrink: 0;
.folderItem:hover & {
opacity: 1;
}
}
.dragOver {
background: var(--color-surface-primary-container);
border: 2px dashed var(--color-primary);
border-radius: var(--border-radius-lg);
}
.folderMenuBtn {
margin-left: auto;
background: none;
border: none;
padding: var(--space-1);
cursor: pointer;
color: var(--color-muted);
border-radius: var(--border-radius-md);
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.folderMenu {
position: absolute;
right: var(--space-2);
top: 100%;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
min-width: 140px;
z-index: 20;
padding: var(--space-1);
}
.folderMenuItem {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
background: none;
border: none;
padding: var(--space-2) var(--space-2);
cursor: pointer;
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
font-size: var(--text-sm);
text-align: left;
&:hover {
background: var(--color-surface-low);
}
}
.folderMenuDanger {
color: var(--color-danger-text);
&:hover {
background: rgba(224, 49, 49, 0.08);
}
}
.grid {
flex: 1;
display: grid;
@@ -228,15 +324,13 @@
.drawingCard {
position: relative;
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(0.1deg);
transition: transform 0.15s ease, box-shadow 0.15s ease;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
transition: box-shadow 0.15s ease;
&:hover {
transform: rotate(0) translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--color-gray-85);
box-shadow: var(--shadow-island-stronger);
}
}
@@ -312,9 +406,9 @@
top: calc(100% + var(--space-1));
right: 0;
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
min-width: 160px;
z-index: 10;
display: flex;
@@ -328,7 +422,7 @@
text-align: left;
padding: var(--space-2) var(--space-3);
cursor: pointer;
border-radius: var(--border-radius-sm);
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
font-size: var(--text-sm);
@@ -351,17 +445,88 @@
margin: var(--space-1) 0;
}
.dropdownSubmenu {
.batchBar {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
background: var(--color-surface-primary-container);
border: 1px solid var(--color-primary);
border-radius: var(--border-radius-lg);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.dropdownSubheader {
.batchCount {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-primary-darkest);
}
.batchActions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.batchBtn {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
color: var(--color-on-surface);
cursor: pointer;
&:hover {
background: var(--color-surface-low);
}
}
.batchDanger {
color: var(--color-danger-text);
&:hover {
background: rgba(224, 49, 49, 0.08);
}
}
.selectBox {
position: absolute;
top: var(--space-2);
left: var(--space-2);
z-index: 5;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-1);
cursor: pointer;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out);
.drawingCard:hover &,
&:focus {
opacity: 1;
}
}
.drawingSelected {
border: 2px solid var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
.selectBox {
opacity: 1;
color: var(--color-primary);
border-color: var(--color-primary);
}
}
.newProjectForm {
@@ -371,58 +536,55 @@
flex-wrap: wrap;
padding: var(--space-3);
background: var(--color-surface-low);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
box-shadow: 2px 2px 0 var(--color-gray-85);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
}
.newProjectInput {
flex: 1;
min-width: 120px;
background: var(--input-bg-color);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-3);
color: var(--text-primary-color);
font-size: var(--text-sm);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 3px 3px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
}
}
.newProjectBtn {
background: var(--color-primary);
color: #fff;
border: 2px solid var(--color-gray-85);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
font-weight: 500;
box-shadow: 2px 2px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
&:hover {
background: var(--color-primary-darkest);
transform: rotate(-0.5deg);
}
}
.newProjectBtnCancel {
background: none;
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-on-surface);
box-shadow: 2px 2px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
&:hover {
background: var(--color-surface-low);
transform: rotate(-0.5deg);
}
}
@@ -466,12 +628,11 @@
.modal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 5px 5px 0 var(--color-gray-85);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island-stronger);
width: 420px;
max-width: 90vw;
transform: rotate(-0.3deg);
}
.modalHeader {
@@ -479,13 +640,13 @@
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 2px solid var(--color-gray-85);
border-bottom: 1px solid var(--default-border-color);
h3 {
margin: 0;
font-size: var(--text-lg);
color: var(--color-gray-85);
font-family: 'Georgia', serif;
font-family: var(--ui-font);
}
}
@@ -514,8 +675,8 @@
.modalInput {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
background: var(--input-bg-color);
color: var(--color-on-surface);
font-size: var(--text-sm);
@@ -523,7 +684,7 @@
&:focus {
border-color: var(--color-primary);
box-shadow: 3px 3px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
}
}
@@ -536,27 +697,82 @@
.modalBtnSecondary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-30);
border-radius: var(--border-radius-lg);
border: 1px solid var(--default-border-color);
background: transparent;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
&:hover { background: var(--color-surface-low); transform: rotate(-0.5deg); }
&:hover { background: var(--color-surface-low); }
}
.modalBtnPrimary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-85);
border-radius: var(--border-radius-lg);
border: 1px solid var(--default-border-color);
background: var(--color-primary);
color: white;
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
&:hover { background: var(--color-primary-darker); transform: rotate(-0.5deg); }
&:hover { background: var(--color-primary-darker); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
.moveHint {
font-size: var(--text-sm);
color: var(--color-muted);
margin: 0 0 var(--space-3);
}
.moveList {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 320px;
overflow-y: auto;
}
.moveItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: none;
border: 1px solid transparent;
border-radius: var(--border-radius-lg);
cursor: pointer;
text-align: left;
color: var(--color-on-surface);
font-size: var(--text-sm);
transition: all var(--duration-fast) var(--ease-out);
svg {
color: var(--color-primary);
flex-shrink: 0;
}
&:hover {
background: var(--color-surface-low);
border-color: var(--default-border-color);
}
&.moveItemActive {
background: var(--color-surface-primary-container);
border-color: var(--color-primary);
color: var(--color-primary-darkest);
font-weight: 500;
}
}
.moveCurrent {
margin-left: auto;
font-size: var(--text-xs);
color: var(--color-muted);
background: var(--color-surface-low);
padding: var(--space-1) var(--space-2);
border-radius: var(--border-radius-md);
}
+504 -98
View File
@@ -1,11 +1,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle } from 'lucide-react';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle, Pencil, Trash2, GripVertical, Square, SquareCheck, Move } from 'lucide-react';
import { Card, Button, Modal } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Drawing } from '@/types';
import type { Drawing, Folder as FolderType } from '@/types';
import styles from './FileBrowser.module.scss';
export const FileBrowser: React.FC = () => {
@@ -35,7 +35,22 @@ export const FileBrowser: React.FC = () => {
const [renameValue, setRenameValue] = useState('');
// Move state
const [movingId, setMovingId] = useState<string | null>(null);
const [moveModalDrawing, setMoveModalDrawing] = useState<Drawing | null>(null);
// Folder menu state
const [folderMenuId, setFolderMenuId] = useState<string | null>(null);
const folderMenuRef = useRef<HTMLDivElement | null>(null);
// Drag-drop state for folders
const [draggedFolderId, setDraggedFolderId] = useState<string | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
// Drag-drop state for drawings
const [draggedDrawingId, setDraggedDrawingId] = useState<string | null>(null);
// Multi-select state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchMoveOpen, setBatchMoveOpen] = useState(false);
// New drawing name modal state
const [showNameModal, setShowNameModal] = useState(false);
@@ -173,6 +188,11 @@ export const FileBrowser: React.FC = () => {
try {
await api.drawings.delete(drawing.id);
removeDrawing(drawing.id);
setSelectedIds(prev => {
const next = new Set(prev);
next.delete(drawing.id);
return next;
});
setActiveMenu(null);
setModal(m => ({ ...m, open: false }));
} catch (err) {
@@ -183,6 +203,55 @@ export const FileBrowser: React.FC = () => {
});
};
const handleDeleteSelected = () => {
const count = selectedIds.size;
if (count === 0) return;
const selectedDrawings = visibleDrawings.filter(d => selectedIds.has(d.id));
showModal('confirm', 'Delete Drawings', `Delete ${count} drawing(s)? This cannot be undone.`, async () => {
try {
await Promise.all(selectedDrawings.map(d => api.drawings.delete(d.id)));
selectedDrawings.forEach(d => removeDrawing(d.id));
setSelectedIds(new Set());
setModal(m => ({ ...m, open: false }));
} catch (err) {
console.error('Failed to delete drawings:', err);
setModal(m => ({ ...m, open: false }));
setTimeout(() => showModal('alert', 'Error', 'Failed to delete drawings.'), 100);
}
});
};
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
setSelectedIds(new Set(visibleDrawings.map(d => d.id)));
};
const clearSelection = () => {
setSelectedIds(new Set());
};
const handleBatchMove = async (folderId: string | null) => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
try {
await Promise.all(ids.map(id => api.drawings.update(id, { folder_id: folderId })));
setDrawings(drawings.map(d => selectedIds.has(d.id) ? { ...d, folder_id: folderId } : d));
setSelectedIds(new Set());
setBatchMoveOpen(false);
} catch (err) {
console.error('Failed to move drawings:', err);
showModal('alert', 'Error', 'Failed to move drawings. Please try again.');
}
};
const handleDuplicateDrawing = async (drawing: Drawing) => {
try {
const newDrawing = await api.drawings.create({
@@ -219,20 +288,165 @@ export const FileBrowser: React.FC = () => {
try {
await api.drawings.update(drawing.id, { folder_id: folderId });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
setMovingId(null);
setActiveMenu(null);
setMoveModalDrawing(null);
} catch (err) {
console.error('Failed to move drawing:', err);
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
}
};
const handleRenameFolder = async (folder: FolderType) => {
const name = renameValue.trim();
if (!name || name === folder.name) {
setRenamingId(null);
return;
}
try {
const updated = await api.folders.update(folder.id, { name });
setFolders(folders.map(f => f.id === folder.id ? updated : f));
setRenamingId(null);
setFolderMenuId(null);
} catch (err) {
console.error('Failed to rename folder:', err);
showModal('alert', 'Error', 'Failed to rename folder. Please try again.');
}
};
const handleDeleteFolder = (folder: FolderType) => {
const drawingsInFolder = drawings.filter(d => d.folder_id === folder.id);
const message = drawingsInFolder.length > 0
? `Delete "${folder.name}" and move its ${drawingsInFolder.length} drawing(s) to root? This cannot be undone.`
: `Delete "${folder.name}"? This cannot be undone.`;
showModal('confirm', 'Delete Folder', message, async () => {
try {
// Move drawings to root first
for (const drawing of drawingsInFolder) {
await api.drawings.update(drawing.id, { folder_id: null });
}
setDrawings(drawings.map(d =>
d.folder_id === folder.id ? { ...d, folder_id: null } : d
));
await api.folders.delete(folder.id);
setFolders(folders.filter(f => f.id !== folder.id));
setFolderMenuId(null);
setModal(m => ({ ...m, open: false }));
if (activeFolderId === folder.id) {
navigate('/files');
}
} catch (err) {
console.error('Failed to delete folder:', err);
setModal(m => ({ ...m, open: false }));
setTimeout(() => showModal('alert', 'Error', 'Failed to delete folder.'), 100);
}
});
};
// Drag and drop handlers for folders
const handleDragStart = (e: React.DragEvent, folderId: string) => {
const target = e.target as HTMLElement;
const isHandle = target.closest(`.${styles.dragHandleWrapper}`) !== null;
if (!isHandle) {
e.preventDefault();
return;
}
setDraggedFolderId(folderId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragLeave = (e: React.DragEvent) => {
const related = e.relatedTarget as HTMLElement;
const current = e.currentTarget as HTMLElement;
if (related && current.contains(related)) {
return;
}
setDragOverFolderId(null);
};
const handleDrop = async (e: React.DragEvent, targetFolderId: string | null) => {
e.preventDefault();
e.stopPropagation();
// Handle drawing drop onto folder
if (draggedDrawingId) {
const drawing = drawings.find(d => d.id === draggedDrawingId);
if (drawing && drawing.folder_id !== targetFolderId) {
try {
await api.drawings.update(drawing.id, { folder_id: targetFolderId });
setDrawings(drawings.map(d => d.id === draggedDrawingId ? { ...d, folder_id: targetFolderId } : d));
} catch (err) {
console.error('Failed to move drawing to folder:', err);
}
}
setDraggedDrawingId(null);
setDragOverFolderId(null);
return;
}
// Handle folder reorder drop
if (!draggedFolderId || draggedFolderId === targetFolderId) {
setDraggedFolderId(null);
setDragOverFolderId(null);
return;
}
// Reorder: move dragged folder to target position
const currentFolders = [...folders];
const draggedIndex = currentFolders.findIndex(f => f.id === draggedFolderId);
const targetIndex = currentFolders.findIndex(f => f.id === targetFolderId);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedFolderId(null);
setDragOverFolderId(null);
return;
}
const [draggedFolder] = currentFolders.splice(draggedIndex, 1);
currentFolders.splice(targetIndex, 0, draggedFolder);
const newOrder = currentFolders.map(f => f.id);
try {
const reordered = await api.folders.reorder(newOrder);
setFolders(reordered);
} catch (err) {
console.error('Failed to reorder folders:', err);
}
setDraggedFolderId(null);
setDragOverFolderId(null);
};
const handleDragEnd = () => {
setDraggedFolderId(null);
setDragOverFolderId(null);
setDraggedDrawingId(null);
};
// Drawing drag handlers
const handleDrawingDragStart = (e: React.DragEvent, drawingId: string) => {
setDraggedDrawingId(drawingId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDrawingDragEnd = () => {
setDraggedDrawingId(null);
setDragOverFolderId(null);
};
// Close menu on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
const target = e.target as HTMLElement;
if (menuRef.current && !menuRef.current.contains(target)) {
setActiveMenu(null);
}
if (folderMenuRef.current && !folderMenuRef.current.contains(target)) {
const isMenuBtn = target.closest(`.${styles.folderMenuBtn}`) !== null;
if (!isMenuBtn) {
setFolderMenuId(null);
}
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
@@ -347,6 +561,28 @@ export const FileBrowser: React.FC = () => {
</div>
</div>
{selectedIds.size > 0 && (
<div className={styles.batchBar}>
<span className={styles.batchCount}>{selectedIds.size} selected</span>
<div className={styles.batchActions}>
<button className={styles.batchBtn} onClick={selectAll}>
Select All
</button>
<button className={styles.batchBtn} onClick={() => setBatchMoveOpen(true)}>
<Move size={14} />
Move to...
</button>
<button className={`${styles.batchBtn} ${styles.batchDanger}`} onClick={handleDeleteSelected}>
<Trash2 size={14} />
Delete
</button>
<button className={styles.batchBtn} onClick={clearSelection}>
Clear
</button>
</div>
</div>
)}
<div className={styles.content}>
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
{showNewProject && (
@@ -374,7 +610,20 @@ export const FileBrowser: React.FC = () => {
</div>
)}
<ul className={styles.folderTree} role="tree">
<li>
<li
onDragOver={(e) => {
e.preventDefault();
if (draggedDrawingId) setDragOverFolderId('__root__');
}}
onDragLeave={(e) => {
const related = e.relatedTarget as HTMLElement;
const current = e.currentTarget as HTMLElement;
if (related && current.contains(related)) return;
setDragOverFolderId(null);
}}
onDrop={(e) => handleDrop(e, null)}
className={dragOverFolderId === '__root__' ? styles.dragOver : ''}
>
<button
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(null)}
@@ -386,16 +635,93 @@ export const FileBrowser: React.FC = () => {
</button>
</li>
{folders.map((folder) => (
<li key={folder.id}>
<button
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(folder.id)}
aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>{folder.name}</span>
</button>
<li
key={folder.id}
draggable
onDragStart={(e) => handleDragStart(e, folder.id)}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if ((draggedFolderId && draggedFolderId !== folder.id) || (draggedDrawingId && draggedDrawingId !== folder.id)) {
setDragOverFolderId(folder.id);
}
}}
onDragLeave={(e) => handleDragLeave(e)}
onDrop={(e) => handleDrop(e, folder.id)}
onDragEnd={handleDragEnd}
className={dragOverFolderId === folder.id ? styles.dragOver : ''}
>
{renamingId === folder.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameFolder(folder);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameFolder(folder)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<button
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''} ${draggedFolderId === folder.id ? styles.dragging : ''}`}
onClick={() => handleFolderClick(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
setFolderMenuId(folder.id);
}}
aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem"
>
<span
className={styles.dragHandleWrapper}
onClick={(e) => e.stopPropagation()}
aria-hidden="true"
>
<GripVertical size={14} className={styles.dragHandle} />
</span>
<Folder size={18} aria-hidden="true" />
<span>{folder.name}</span>
<button
className={styles.folderMenuBtn}
onClick={(e) => {
e.stopPropagation();
setFolderMenuId(folderMenuId === folder.id ? null : folder.id);
}}
aria-label="Folder options"
>
<MoreVertical size={14} />
</button>
</button>
)}
{folderMenuId === folder.id && (
<div className={styles.folderMenu} ref={folderMenuRef}>
<button
className={styles.folderMenuItem}
onClick={(e) => {
e.stopPropagation();
setRenamingId(folder.id);
setRenameValue(folder.name);
setFolderMenuId(null);
}}
>
<Pencil size={14} />
Rename
</button>
<button
className={`${styles.folderMenuItem} ${styles.folderMenuDanger}`}
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(folder);
}}
>
<Trash2 size={14} />
Delete
</button>
</div>
)}
</li>
))}
</ul>
@@ -410,93 +736,99 @@ export const FileBrowser: React.FC = () => {
</p>
</div>
) : (
visibleDrawings.map((drawing) => (
<Card
key={drawing.id}
className={styles.drawingCard}
hover
role="listitem"
tabIndex={0}
onClick={() => handleDrawingClick(drawing)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDrawingClick(drawing);
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.thumbnail}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.info}>
{renamingId === drawing.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameDrawing(drawing);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameDrawing(drawing)}
/>
) : (
<>
<h4 className={styles.title}>{drawing.title}</h4>
<p className={styles.meta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</>
)}
</div>
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
visibleDrawings.map((drawing) => {
const isSelected = selectedIds.has(drawing.id);
return (
<Card
key={drawing.id}
className={`${styles.drawingCard} ${isSelected ? styles.drawingSelected : ''}`}
hover
role="listitem"
tabIndex={0}
draggable
onDragStart={(e) => handleDrawingDragStart(e as unknown as React.DragEvent, drawing.id)}
onDragEnd={handleDrawingDragEnd}
onClick={() => handleDrawingClick(drawing)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDrawingClick(drawing);
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<button
className={styles.more}
className={styles.selectBox}
onClick={(e) => {
e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
setRenamingId(null);
toggleSelect(drawing.id);
}}
aria-label={`More options for ${drawing.title}`}
aria-expanded={activeMenu === drawing.id}
aria-label={isSelected ? 'Deselect' : 'Select'}
aria-pressed={isSelected}
>
<MoreVertical size={16} />
{isSelected ? <SquareCheck size={16} /> : <Square size={16} />}
</button>
{activeMenu === drawing.id && (
<div className={styles.dropdown}>
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
{movingId === drawing.id ? (
<div className={styles.dropdownSubmenu}>
<button className={styles.dropdownSubheader}>Move to:</button>
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
{folders.map(f => (
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
))}
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
</div>
) : (
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
)}
<div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div>
)}
</div>
</Card>
))
<div className={styles.thumbnail}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.info}>
{renamingId === drawing.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameDrawing(drawing);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameDrawing(drawing)}
/>
) : (
<>
<h4 className={styles.title}>{drawing.title}</h4>
<p className={styles.meta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</>
)}
</div>
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
<button
className={styles.more}
onClick={(e) => {
e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
setRenamingId(null);
}}
aria-label={`More options for ${drawing.title}`}
aria-expanded={activeMenu === drawing.id}
>
<MoreVertical size={16} />
</button>
{activeMenu === drawing.id && (
<div className={styles.dropdown}>
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
<button onClick={(e) => { e.stopPropagation(); setMoveModalDrawing(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Move to...</button>
<div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div>
)}
</div>
</Card>
);
})
)}
</main>
</div>
@@ -530,6 +862,80 @@ export const FileBrowser: React.FC = () => {
</div>
</div>
)}
{moveModalDrawing && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="move-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setMoveModalDrawing(null); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="move-drawing-title">Move "{moveModalDrawing.title}"</h3>
<button className={styles.modalClose} onClick={() => setMoveModalDrawing(null)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<p className={styles.moveHint}>Select a destination:</p>
<div className={styles.moveList}>
<button
className={`${styles.moveItem} ${moveModalDrawing.folder_id === null ? styles.moveItemActive : ''}`}
onClick={() => handleMoveDrawing(moveModalDrawing, null)}
>
<Folder size={18} />
<span>All Projects</span>
{moveModalDrawing.folder_id === null && <span className={styles.moveCurrent}>Current</span>}
</button>
{folders.map((f) => (
<button
key={f.id}
className={`${styles.moveItem} ${moveModalDrawing.folder_id === f.id ? styles.moveItemActive : ''}`}
onClick={() => handleMoveDrawing(moveModalDrawing, f.id)}
>
<Folder size={18} />
<span>{f.name}</span>
{moveModalDrawing.folder_id === f.id && <span className={styles.moveCurrent}>Current</span>}
</button>
))}
</div>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setMoveModalDrawing(null)}>Cancel</button>
</div>
</div>
</div>
)}
{batchMoveOpen && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="batch-move-title" onClick={(e) => { if (e.target === e.currentTarget) setBatchMoveOpen(false); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="batch-move-title">Move {selectedIds.size} drawing(s)</h3>
<button className={styles.modalClose} onClick={() => setBatchMoveOpen(false)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<p className={styles.moveHint}>Select a destination:</p>
<div className={styles.moveList}>
<button
className={styles.moveItem}
onClick={() => handleBatchMove(null)}
>
<Folder size={18} />
<span>All Projects</span>
</button>
{folders.map((f) => (
<button
key={f.id}
className={styles.moveItem}
onClick={() => handleBatchMove(f.id)}
>
<Folder size={18} />
<span>{f.name}</span>
</button>
))}
</div>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setBatchMoveOpen(false)}>Cancel</button>
</div>
</div>
</div>
)}
</div>
</>
);
@@ -9,17 +9,16 @@
margin-bottom: var(--space-8);
padding: var(--space-5);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(0.1deg);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island-stronger);
h1 {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
font-family: 'Georgia', serif;
font-family: var(--ui-font);
}
}
@@ -45,10 +44,10 @@
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: 2px;
border-radius: var(--border-radius-lg);
color: var(--color-gray-70);
background: none;
border: 2px solid transparent;
border: 1px solid transparent;
cursor: pointer;
font-size: var(--text-sm);
transition: all var(--duration-fast) var(--ease-out);
@@ -57,17 +56,14 @@
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-color: var(--color-gray-30);
transform: rotate(-0.2deg);
border-color: var(--default-border-color);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 600;
border-color: var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-0.1deg);
border-color: var(--color-primary);
}
}
@@ -96,8 +92,8 @@
font-size: var(--text-2xl);
font-weight: 700;
overflow: hidden;
border: 2px solid var(--color-gray-85);
box-shadow: 3px 3px 0 var(--color-gray-85);
border: 1px solid var(--default-border-color);
box-shadow: var(--shadow-island);
img {
width: 100%;
@@ -151,26 +147,24 @@
.themeOption {
padding: var(--space-2) var(--space-4);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
background: var(--island-bg-color);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
box-shadow: 2px 2px 0 var(--color-gray-30);
box-shadow: var(--shadow-island);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
transform: translate(-1px, -1px);
box-shadow: 3px 3px 0 var(--color-primary);
box-shadow: var(--shadow-island-stronger);
}
&.active {
background: var(--color-primary);
border-color: var(--color-gray-85);
border-color: var(--color-primary);
color: white;
box-shadow: 2px 2px 0 var(--color-gray-85);
}
}
+18 -22
View File
@@ -9,17 +9,16 @@
margin-bottom: var(--space-8);
padding: var(--space-5);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(-0.2deg);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island-stronger);
h1 {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
font-family: 'Georgia', serif;
font-family: var(--ui-font);
}
}
@@ -68,21 +67,20 @@
gap: var(--space-3);
padding: var(--space-3) var(--space-2);
margin-bottom: var(--space-2);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
transition: all 0.15s ease;
box-shadow: 2px 2px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
background: var(--island-bg-color);
&:hover {
border-color: var(--color-primary);
background: var(--color-surface-low);
transform: translateX(2px) rotate(-0.2deg);
box-shadow: 3px 3px 0 var(--color-gray-85);
box-shadow: var(--shadow-island-stronger);
}
&:last-child {
border-bottom: 2px solid var(--color-gray-30);
border-bottom: 1px solid var(--default-border-color);
margin-bottom: 0;
}
}
@@ -98,8 +96,8 @@
justify-content: center;
font-weight: 700;
flex-shrink: 0;
border: 2px solid var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
border: 1px solid var(--default-border-color);
box-shadow: var(--shadow-island);
}
.memberInfo {
@@ -122,13 +120,12 @@
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--color-surface-low);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-gray-70);
text-transform: capitalize;
box-shadow: 1px 1px 0 var(--color-gray-85);
}
.inviteForm {
@@ -139,15 +136,15 @@
.inviteInput {
padding: var(--space-3);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
font-size: var(--text-sm);
background: var(--input-bg-color);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 3px 3px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
}
}
@@ -187,12 +184,11 @@
.roleSelect {
padding: var(--space-2) var(--space-3);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
font-size: var(--text-sm);
background: var(--input-bg-color);
cursor: pointer;
box-shadow: 1px 1px 0 var(--color-gray-85);
}
.error {
@@ -78,15 +78,13 @@
.templateCard {
overflow: hidden;
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(0.1deg);
transition: transform 0.15s ease, box-shadow 0.15s ease;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
transition: box-shadow 0.15s ease;
&:hover {
transform: rotate(0) translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--color-gray-85);
box-shadow: var(--shadow-island-stronger);
}
}
+9
View File
@@ -15,6 +15,7 @@ async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
credentials: 'include',
headers: {
...headers,
...options?.headers,
@@ -44,6 +45,8 @@ export const api = {
fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string): Promise<{ ok: boolean }> =>
fetchApi(`/drawings/${id}`, { method: 'DELETE' }),
autosave: (id: string, snapshot: object): Promise<{ ok: boolean }> =>
fetchApi(`/drawings/${id}/autosave`, { method: 'PATCH', body: JSON.stringify({ snapshot }) }),
},
revisions: {
list: (drawingId: string): Promise<DrawingRevision[]> =>
@@ -58,6 +61,12 @@ export const api = {
list: (): Promise<Folder[]> => fetchApi('/folders'),
create: (data: object): Promise<Folder> =>
fetchApi('/folders', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: object): Promise<Folder> =>
fetchApi(`/folders/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string): Promise<void> =>
fetchApi(`/folders/${id}`, { method: 'DELETE' }),
reorder: (folderIds: string[]): Promise<Folder[]> =>
fetchApi('/folders/reorder', { method: 'POST', body: JSON.stringify({ folder_ids: folderIds }) }),
},
teams: {
list: (): Promise<Team[]> => fetchApi('/teams'),
+7 -7
View File
@@ -122,17 +122,17 @@ a {
// ============================================
.excalidraw {
--border-radius-md: 2px;
--border-radius-md: var(--border-radius-lg);
.context-menu {
border: 2px solid var(--color-gray-85) !important;
border-radius: 2px !important;
box-shadow: 3px 3px 0 var(--color-gray-85) !important;
border: 1px solid var(--default-border-color) !important;
border-radius: var(--border-radius-lg) !important;
box-shadow: var(--shadow-island) !important;
}
.library-menu-items-container {
border: 2px solid var(--color-gray-85) !important;
border-radius: 2px !important;
box-shadow: 3px 3px 0 var(--color-gray-85) !important;
border: 1px solid var(--default-border-color) !important;
border-radius: var(--border-radius-lg) !important;
box-shadow: var(--shadow-island) !important;
}
}
+1 -8
View File
@@ -1,11 +1,4 @@
{
"status": "failed",
"failedTests": [
"c31ff144dc4fee3acd0a-bec551c658216ec9862a",
"c31ff144dc4fee3acd0a-f87315abf5d197970540",
"c31ff144dc4fee3acd0a-fc5e81ebcffdb7687b8e",
"c31ff144dc4fee3acd0a-989f5dcca4211fe0b2e4",
"c31ff144dc4fee3acd0a-ac5aa3cfe7537125a151",
"c31ff144dc4fee3acd0a-7f990aaafdc09c3794e8"
]
"failedTests": []
}
@@ -1,162 +0,0 @@
# Test info
- Name: dashboard >> shows stats cards
- Location: /home/tdvorak/Desktop/PROG+HTML/Excalidraw/frontend/e2e/app.spec.ts:45:3
# Error details
```
Error: Error reading storage state from playwright/.auth/state.json:
ENOENT: no such file or directory, open 'playwright/.auth/state.json'
```
# Test source
```ts
1 | import { test, expect } from '@playwright/test';
2 |
3 | const BASE = 'http://localhost:3456';
4 |
5 | // Auth: first-run signup, blocked signup, login
6 | test.describe.serial('auth flow', () => {
7 | test.use({ storageState: { cookies: [], origins: [] } });
8 |
9 | test('redirects to signup when no users exist', async ({ page }) => {
10 | await page.goto(BASE + '/');
11 | await expect(page).toHaveURL(/\/signup$/);
12 | await expect(page.getByRole('heading', { name: 'Create account' })).toBeVisible();
13 | });
14 |
15 | test('first user can signup', async ({ page }) => {
16 | await page.goto(BASE + '/signup');
17 | await page.getByLabel('Full Name').fill('E2E User');
18 | await page.getByLabel('Email').fill('e2e@test.com');
19 | await page.getByLabel('Password').fill('e2e-password-123');
20 | await page.getByRole('button', { name: 'Create Account' }).click();
21 | await expect(page).toHaveURL(BASE + '/');
22 | await expect(page.getByText(/Welcome back/)).toBeVisible();
23 | await page.context().storageState({ path: 'playwright/.auth/state.json' });
24 | });
25 |
26 | test('blocks second signup when users exist', async ({ page }) => {
27 | await page.goto(BASE + '/signup');
28 | await expect(page).toHaveURL(/\/login$/);
29 | });
30 |
31 | test('existing user can login', async ({ page }) => {
32 | await page.goto(BASE + '/login');
33 | await page.getByLabel('Email').fill('e2e@test.com');
34 | await page.getByLabel('Password').fill('e2e-password-123');
35 | await page.getByRole('button', { name: 'Sign In' }).click();
36 | await expect(page).toHaveURL(BASE + '/');
37 | await expect(page.getByText(/Welcome back/)).toBeVisible();
38 | });
39 | });
40 |
41 | // Dashboard: quick actions and stats
42 | test.describe.serial('dashboard', () => {
43 | test.use({ storageState: 'playwright/.auth/state.json' });
44 |
> 45 | test('shows stats cards', async ({ page }) => {
| ^ Error: Error reading storage state from playwright/.auth/state.json:
46 | await page.goto(BASE + '/');
47 | await expect(page.getByText('Drawings')).toBeVisible();
48 | await expect(page.getByText('Projects')).toBeVisible();
49 | await expect(page.getByText('Teams')).toBeVisible();
50 | });
51 |
52 | test('quick action: New Project navigates to files', async ({ page }) => {
53 | await page.goto(BASE + '/');
54 | await page.getByRole('button', { name: 'New Project' }).click();
55 | await expect(page).toHaveURL(/\/files/);
56 | await expect(page.getByRole('navigation', { name: 'Project tree' })).toBeVisible();
57 | await expect(page.getByText('All Projects')).toBeVisible();
58 | });
59 |
60 | test('quick action: Invite navigates to team', async ({ page }) => {
61 | await page.goto(BASE + '/');
62 | await page.getByRole('button', { name: 'Invite' }).click();
63 | await expect(page).toHaveURL(/\/team/);
64 | await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
65 | });
66 |
67 | test('quick action: Library navigates to marketplace', async ({ page }) => {
68 | await page.goto(BASE + '/');
69 | await page.getByRole('button', { name: 'Library' }).click();
70 | await expect(page).toHaveURL(/\/library/);
71 | await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
72 | });
73 |
74 | test('New Drawing opens template picker', async ({ page }) => {
75 | await page.goto(BASE + '/');
76 | await page.getByRole('button', { name: 'New Drawing' }).click();
77 | await expect(page.getByRole('dialog')).toBeVisible();
78 | await expect(page.getByRole('heading', { name: 'Choose a Template' })).toBeVisible();
79 | await expect(page.getByRole('button', { name: 'Blank Canvas' })).toBeVisible();
80 | await expect(page.getByRole('button', { name: 'To-Do List' })).toBeVisible();
81 | await expect(page.getByRole('button', { name: 'Checklist' })).toBeVisible();
82 | await expect(page.getByRole('button', { name: 'Bullet List' })).toBeVisible();
83 | await expect(page.getByRole('button', { name: 'Flow Chart' })).toBeVisible();
84 | });
85 | });
86 |
87 | // Projects / FileBrowser
88 | test.describe.serial('projects', () => {
89 | test.use({ storageState: 'playwright/.auth/state.json' });
90 |
91 | test('shows Projects label in sidebar and breadcrumb', async ({ page }) => {
92 | await page.goto(BASE + '/files');
93 | await expect(page.getByRole('navigation', { name: 'Main navigation' }).getByText('Projects')).toBeVisible();
94 | await expect(page.getByText('All Projects')).toBeVisible();
95 | });
96 |
97 | test('can create a drawing from file browser', async ({ page }) => {
98 | await page.goto(BASE + '/files');
99 | await page.getByRole('button', { name: 'Create new drawing' }).click();
100 | await expect(page.getByRole('dialog')).toBeVisible();
101 | await page.getByRole('button', { name: 'Blank Canvas' }).click();
102 | await expect(page).toHaveURL(/\/drawing\//);
103 | await expect(page.getByText('Loading Excalidraw')).toBeVisible();
104 | });
105 | });
106 |
107 | // Editor / Canvas
108 | test.describe.serial('editor', () => {
109 | test.use({ storageState: 'playwright/.auth/state.json' });
110 |
111 | test('creates drawing with To-Do template', async ({ page }) => {
112 | await page.goto(BASE + '/');
113 | await page.getByRole('button', { name: 'New Drawing' }).click();
114 | await page.getByRole('button', { name: 'To-Do List' }).click();
115 | await expect(page).toHaveURL(/\/drawing\//);
116 | await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
117 | });
118 |
119 | test('editor shows save controls and back button', async ({ page }) => {
120 | await page.goto(BASE + '/');
121 | await page.getByRole('button', { name: 'New Drawing' }).click();
122 | await page.getByRole('button', { name: 'Blank Canvas' }).click();
123 | await expect(page).toHaveURL(/\/drawing\//);
124 | await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
125 | await expect(page.getByRole('button', { name: /Back/i })).toBeVisible();
126 | });
127 | });
128 |
129 | // Library Marketplace
130 | test.describe.serial('library', () => {
131 | test.use({ storageState: 'playwright/.auth/state.json' });
132 |
133 | test('loads marketplace with search and categories', async ({ page }) => {
134 | await page.goto(BASE + '/library');
135 | await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
136 | await expect(page.getByPlaceholder('Search libraries...')).toBeVisible();
137 | await expect(page.getByRole('button', { name: 'All' }).first()).toBeVisible();
138 | await expect(page.getByRole('button', { name: 'Open External' })).toBeVisible();
139 | });
140 |
141 | test('search filters libraries', async ({ page }) => {
142 | await page.goto(BASE + '/library');
143 | await page.getByPlaceholder('Search libraries...').fill('zzzznonexistent');
144 | await expect(page.getByText('No libraries found')).toBeVisible();
145 | });
```
@@ -1,177 +0,0 @@
# Test info
- Name: editor >> creates drawing with To-Do template
- Location: /home/tdvorak/Desktop/PROG+HTML/Excalidraw/frontend/e2e/app.spec.ts:111:3
# Error details
```
Error: Error reading storage state from playwright/.auth/state.json:
ENOENT: no such file or directory, open 'playwright/.auth/state.json'
```
# Test source
```ts
11 | await expect(page).toHaveURL(/\/signup$/);
12 | await expect(page.getByRole('heading', { name: 'Create account' })).toBeVisible();
13 | });
14 |
15 | test('first user can signup', async ({ page }) => {
16 | await page.goto(BASE + '/signup');
17 | await page.getByLabel('Full Name').fill('E2E User');
18 | await page.getByLabel('Email').fill('e2e@test.com');
19 | await page.getByLabel('Password').fill('e2e-password-123');
20 | await page.getByRole('button', { name: 'Create Account' }).click();
21 | await expect(page).toHaveURL(BASE + '/');
22 | await expect(page.getByText(/Welcome back/)).toBeVisible();
23 | await page.context().storageState({ path: 'playwright/.auth/state.json' });
24 | });
25 |
26 | test('blocks second signup when users exist', async ({ page }) => {
27 | await page.goto(BASE + '/signup');
28 | await expect(page).toHaveURL(/\/login$/);
29 | });
30 |
31 | test('existing user can login', async ({ page }) => {
32 | await page.goto(BASE + '/login');
33 | await page.getByLabel('Email').fill('e2e@test.com');
34 | await page.getByLabel('Password').fill('e2e-password-123');
35 | await page.getByRole('button', { name: 'Sign In' }).click();
36 | await expect(page).toHaveURL(BASE + '/');
37 | await expect(page.getByText(/Welcome back/)).toBeVisible();
38 | });
39 | });
40 |
41 | // Dashboard: quick actions and stats
42 | test.describe.serial('dashboard', () => {
43 | test.use({ storageState: 'playwright/.auth/state.json' });
44 |
45 | test('shows stats cards', async ({ page }) => {
46 | await page.goto(BASE + '/');
47 | await expect(page.getByText('Drawings')).toBeVisible();
48 | await expect(page.getByText('Projects')).toBeVisible();
49 | await expect(page.getByText('Teams')).toBeVisible();
50 | });
51 |
52 | test('quick action: New Project navigates to files', async ({ page }) => {
53 | await page.goto(BASE + '/');
54 | await page.getByRole('button', { name: 'New Project' }).click();
55 | await expect(page).toHaveURL(/\/files/);
56 | await expect(page.getByRole('navigation', { name: 'Project tree' })).toBeVisible();
57 | await expect(page.getByText('All Projects')).toBeVisible();
58 | });
59 |
60 | test('quick action: Invite navigates to team', async ({ page }) => {
61 | await page.goto(BASE + '/');
62 | await page.getByRole('button', { name: 'Invite' }).click();
63 | await expect(page).toHaveURL(/\/team/);
64 | await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
65 | });
66 |
67 | test('quick action: Library navigates to marketplace', async ({ page }) => {
68 | await page.goto(BASE + '/');
69 | await page.getByRole('button', { name: 'Library' }).click();
70 | await expect(page).toHaveURL(/\/library/);
71 | await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
72 | });
73 |
74 | test('New Drawing opens template picker', async ({ page }) => {
75 | await page.goto(BASE + '/');
76 | await page.getByRole('button', { name: 'New Drawing' }).click();
77 | await expect(page.getByRole('dialog')).toBeVisible();
78 | await expect(page.getByRole('heading', { name: 'Choose a Template' })).toBeVisible();
79 | await expect(page.getByRole('button', { name: 'Blank Canvas' })).toBeVisible();
80 | await expect(page.getByRole('button', { name: 'To-Do List' })).toBeVisible();
81 | await expect(page.getByRole('button', { name: 'Checklist' })).toBeVisible();
82 | await expect(page.getByRole('button', { name: 'Bullet List' })).toBeVisible();
83 | await expect(page.getByRole('button', { name: 'Flow Chart' })).toBeVisible();
84 | });
85 | });
86 |
87 | // Projects / FileBrowser
88 | test.describe.serial('projects', () => {
89 | test.use({ storageState: 'playwright/.auth/state.json' });
90 |
91 | test('shows Projects label in sidebar and breadcrumb', async ({ page }) => {
92 | await page.goto(BASE + '/files');
93 | await expect(page.getByRole('navigation', { name: 'Main navigation' }).getByText('Projects')).toBeVisible();
94 | await expect(page.getByText('All Projects')).toBeVisible();
95 | });
96 |
97 | test('can create a drawing from file browser', async ({ page }) => {
98 | await page.goto(BASE + '/files');
99 | await page.getByRole('button', { name: 'Create new drawing' }).click();
100 | await expect(page.getByRole('dialog')).toBeVisible();
101 | await page.getByRole('button', { name: 'Blank Canvas' }).click();
102 | await expect(page).toHaveURL(/\/drawing\//);
103 | await expect(page.getByText('Loading Excalidraw')).toBeVisible();
104 | });
105 | });
106 |
107 | // Editor / Canvas
108 | test.describe.serial('editor', () => {
109 | test.use({ storageState: 'playwright/.auth/state.json' });
110 |
> 111 | test('creates drawing with To-Do template', async ({ page }) => {
| ^ Error: Error reading storage state from playwright/.auth/state.json:
112 | await page.goto(BASE + '/');
113 | await page.getByRole('button', { name: 'New Drawing' }).click();
114 | await page.getByRole('button', { name: 'To-Do List' }).click();
115 | await expect(page).toHaveURL(/\/drawing\//);
116 | await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
117 | });
118 |
119 | test('editor shows save controls and back button', async ({ page }) => {
120 | await page.goto(BASE + '/');
121 | await page.getByRole('button', { name: 'New Drawing' }).click();
122 | await page.getByRole('button', { name: 'Blank Canvas' }).click();
123 | await expect(page).toHaveURL(/\/drawing\//);
124 | await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
125 | await expect(page.getByRole('button', { name: /Back/i })).toBeVisible();
126 | });
127 | });
128 |
129 | // Library Marketplace
130 | test.describe.serial('library', () => {
131 | test.use({ storageState: 'playwright/.auth/state.json' });
132 |
133 | test('loads marketplace with search and categories', async ({ page }) => {
134 | await page.goto(BASE + '/library');
135 | await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
136 | await expect(page.getByPlaceholder('Search libraries...')).toBeVisible();
137 | await expect(page.getByRole('button', { name: 'All' }).first()).toBeVisible();
138 | await expect(page.getByRole('button', { name: 'Open External' })).toBeVisible();
139 | });
140 |
141 | test('search filters libraries', async ({ page }) => {
142 | await page.goto(BASE + '/library');
143 | await page.getByPlaceholder('Search libraries...').fill('zzzznonexistent');
144 | await expect(page.getByText('No libraries found')).toBeVisible();
145 | });
146 | });
147 |
148 | // Team / Invites
149 | test.describe.serial('team', () => {
150 | test.use({ storageState: 'playwright/.auth/state.json' });
151 |
152 | test('shows owner in members list', async ({ page }) => {
153 | await page.goto(BASE + '/team');
154 | await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
155 | await expect(page.getByText('E2E User')).toBeVisible();
156 | await expect(page.getByText('owner')).toBeVisible();
157 | });
158 |
159 | test('can send team invite', async ({ page }) => {
160 | await page.goto(BASE + '/team');
161 | await page.getByLabel('Email address').fill('invited@test.com');
162 | await page.locator('select').selectOption('editor');
163 | await page.getByRole('button', { name: 'Send Invite' }).click();
164 | await expect(page.getByText('Invite sent!')).toBeVisible();
165 | await expect(page.getByText('Pending Invites')).toBeVisible();
166 | await expect(page.getByText('invited@test.com')).toBeVisible();
167 | await expect(page.getByText('editor').first()).toBeVisible();
168 | });
169 | });
170 |
```
@@ -1,155 +0,0 @@
# Test info
- Name: library >> loads marketplace with search and categories
- Location: /home/tdvorak/Desktop/PROG+HTML/Excalidraw/frontend/e2e/app.spec.ts:133:3
# Error details
```
Error: Error reading storage state from playwright/.auth/state.json:
ENOENT: no such file or directory, open 'playwright/.auth/state.json'
```
# Test source
```ts
33 | await page.getByLabel('Email').fill('e2e@test.com');
34 | await page.getByLabel('Password').fill('e2e-password-123');
35 | await page.getByRole('button', { name: 'Sign In' }).click();
36 | await expect(page).toHaveURL(BASE + '/');
37 | await expect(page.getByText(/Welcome back/)).toBeVisible();
38 | });
39 | });
40 |
41 | // Dashboard: quick actions and stats
42 | test.describe.serial('dashboard', () => {
43 | test.use({ storageState: 'playwright/.auth/state.json' });
44 |
45 | test('shows stats cards', async ({ page }) => {
46 | await page.goto(BASE + '/');
47 | await expect(page.getByText('Drawings')).toBeVisible();
48 | await expect(page.getByText('Projects')).toBeVisible();
49 | await expect(page.getByText('Teams')).toBeVisible();
50 | });
51 |
52 | test('quick action: New Project navigates to files', async ({ page }) => {
53 | await page.goto(BASE + '/');
54 | await page.getByRole('button', { name: 'New Project' }).click();
55 | await expect(page).toHaveURL(/\/files/);
56 | await expect(page.getByRole('navigation', { name: 'Project tree' })).toBeVisible();
57 | await expect(page.getByText('All Projects')).toBeVisible();
58 | });
59 |
60 | test('quick action: Invite navigates to team', async ({ page }) => {
61 | await page.goto(BASE + '/');
62 | await page.getByRole('button', { name: 'Invite' }).click();
63 | await expect(page).toHaveURL(/\/team/);
64 | await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
65 | });
66 |
67 | test('quick action: Library navigates to marketplace', async ({ page }) => {
68 | await page.goto(BASE + '/');
69 | await page.getByRole('button', { name: 'Library' }).click();
70 | await expect(page).toHaveURL(/\/library/);
71 | await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
72 | });
73 |
74 | test('New Drawing opens template picker', async ({ page }) => {
75 | await page.goto(BASE + '/');
76 | await page.getByRole('button', { name: 'New Drawing' }).click();
77 | await expect(page.getByRole('dialog')).toBeVisible();
78 | await expect(page.getByRole('heading', { name: 'Choose a Template' })).toBeVisible();
79 | await expect(page.getByRole('button', { name: 'Blank Canvas' })).toBeVisible();
80 | await expect(page.getByRole('button', { name: 'To-Do List' })).toBeVisible();
81 | await expect(page.getByRole('button', { name: 'Checklist' })).toBeVisible();
82 | await expect(page.getByRole('button', { name: 'Bullet List' })).toBeVisible();
83 | await expect(page.getByRole('button', { name: 'Flow Chart' })).toBeVisible();
84 | });
85 | });
86 |
87 | // Projects / FileBrowser
88 | test.describe.serial('projects', () => {
89 | test.use({ storageState: 'playwright/.auth/state.json' });
90 |
91 | test('shows Projects label in sidebar and breadcrumb', async ({ page }) => {
92 | await page.goto(BASE + '/files');
93 | await expect(page.getByRole('navigation', { name: 'Main navigation' }).getByText('Projects')).toBeVisible();
94 | await expect(page.getByText('All Projects')).toBeVisible();
95 | });
96 |
97 | test('can create a drawing from file browser', async ({ page }) => {
98 | await page.goto(BASE + '/files');
99 | await page.getByRole('button', { name: 'Create new drawing' }).click();
100 | await expect(page.getByRole('dialog')).toBeVisible();
101 | await page.getByRole('button', { name: 'Blank Canvas' }).click();
102 | await expect(page).toHaveURL(/\/drawing\//);
103 | await expect(page.getByText('Loading Excalidraw')).toBeVisible();
104 | });
105 | });
106 |
107 | // Editor / Canvas
108 | test.describe.serial('editor', () => {
109 | test.use({ storageState: 'playwright/.auth/state.json' });
110 |
111 | test('creates drawing with To-Do template', async ({ page }) => {
112 | await page.goto(BASE + '/');
113 | await page.getByRole('button', { name: 'New Drawing' }).click();
114 | await page.getByRole('button', { name: 'To-Do List' }).click();
115 | await expect(page).toHaveURL(/\/drawing\//);
116 | await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
117 | });
118 |
119 | test('editor shows save controls and back button', async ({ page }) => {
120 | await page.goto(BASE + '/');
121 | await page.getByRole('button', { name: 'New Drawing' }).click();
122 | await page.getByRole('button', { name: 'Blank Canvas' }).click();
123 | await expect(page).toHaveURL(/\/drawing\//);
124 | await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
125 | await expect(page.getByRole('button', { name: /Back/i })).toBeVisible();
126 | });
127 | });
128 |
129 | // Library Marketplace
130 | test.describe.serial('library', () => {
131 | test.use({ storageState: 'playwright/.auth/state.json' });
132 |
> 133 | test('loads marketplace with search and categories', async ({ page }) => {
| ^ Error: Error reading storage state from playwright/.auth/state.json:
134 | await page.goto(BASE + '/library');
135 | await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
136 | await expect(page.getByPlaceholder('Search libraries...')).toBeVisible();
137 | await expect(page.getByRole('button', { name: 'All' }).first()).toBeVisible();
138 | await expect(page.getByRole('button', { name: 'Open External' })).toBeVisible();
139 | });
140 |
141 | test('search filters libraries', async ({ page }) => {
142 | await page.goto(BASE + '/library');
143 | await page.getByPlaceholder('Search libraries...').fill('zzzznonexistent');
144 | await expect(page.getByText('No libraries found')).toBeVisible();
145 | });
146 | });
147 |
148 | // Team / Invites
149 | test.describe.serial('team', () => {
150 | test.use({ storageState: 'playwright/.auth/state.json' });
151 |
152 | test('shows owner in members list', async ({ page }) => {
153 | await page.goto(BASE + '/team');
154 | await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
155 | await expect(page.getByText('E2E User')).toBeVisible();
156 | await expect(page.getByText('owner')).toBeVisible();
157 | });
158 |
159 | test('can send team invite', async ({ page }) => {
160 | await page.goto(BASE + '/team');
161 | await page.getByLabel('Email address').fill('invited@test.com');
162 | await page.locator('select').selectOption('editor');
163 | await page.getByRole('button', { name: 'Send Invite' }).click();
164 | await expect(page.getByText('Invite sent!')).toBeVisible();
165 | await expect(page.getByText('Pending Invites')).toBeVisible();
166 | await expect(page.getByText('invited@test.com')).toBeVisible();
167 | await expect(page.getByText('editor').first()).toBeVisible();
168 | });
169 | });
170 |
```
@@ -1,187 +0,0 @@
# Test info
- Name: projects >> shows Projects label in sidebar and breadcrumb
- Location: /home/tdvorak/Desktop/PROG+HTML/Excalidraw/frontend/e2e/app.spec.ts:91:3
# Error details
```
Error: Error reading storage state from playwright/.auth/state.json:
ENOENT: no such file or directory, open 'playwright/.auth/state.json'
```
# Test source
```ts
1 | import { test, expect } from '@playwright/test';
2 |
3 | const BASE = 'http://localhost:3456';
4 |
5 | // Auth: first-run signup, blocked signup, login
6 | test.describe.serial('auth flow', () => {
7 | test.use({ storageState: { cookies: [], origins: [] } });
8 |
9 | test('redirects to signup when no users exist', async ({ page }) => {
10 | await page.goto(BASE + '/');
11 | await expect(page).toHaveURL(/\/signup$/);
12 | await expect(page.getByRole('heading', { name: 'Create account' })).toBeVisible();
13 | });
14 |
15 | test('first user can signup', async ({ page }) => {
16 | await page.goto(BASE + '/signup');
17 | await page.getByLabel('Full Name').fill('E2E User');
18 | await page.getByLabel('Email').fill('e2e@test.com');
19 | await page.getByLabel('Password').fill('e2e-password-123');
20 | await page.getByRole('button', { name: 'Create Account' }).click();
21 | await expect(page).toHaveURL(BASE + '/');
22 | await expect(page.getByText(/Welcome back/)).toBeVisible();
23 | await page.context().storageState({ path: 'playwright/.auth/state.json' });
24 | });
25 |
26 | test('blocks second signup when users exist', async ({ page }) => {
27 | await page.goto(BASE + '/signup');
28 | await expect(page).toHaveURL(/\/login$/);
29 | });
30 |
31 | test('existing user can login', async ({ page }) => {
32 | await page.goto(BASE + '/login');
33 | await page.getByLabel('Email').fill('e2e@test.com');
34 | await page.getByLabel('Password').fill('e2e-password-123');
35 | await page.getByRole('button', { name: 'Sign In' }).click();
36 | await expect(page).toHaveURL(BASE + '/');
37 | await expect(page.getByText(/Welcome back/)).toBeVisible();
38 | });
39 | });
40 |
41 | // Dashboard: quick actions and stats
42 | test.describe.serial('dashboard', () => {
43 | test.use({ storageState: 'playwright/.auth/state.json' });
44 |
45 | test('shows stats cards', async ({ page }) => {
46 | await page.goto(BASE + '/');
47 | await expect(page.getByText('Drawings')).toBeVisible();
48 | await expect(page.getByText('Projects')).toBeVisible();
49 | await expect(page.getByText('Teams')).toBeVisible();
50 | });
51 |
52 | test('quick action: New Project navigates to files', async ({ page }) => {
53 | await page.goto(BASE + '/');
54 | await page.getByRole('button', { name: 'New Project' }).click();
55 | await expect(page).toHaveURL(/\/files/);
56 | await expect(page.getByRole('navigation', { name: 'Project tree' })).toBeVisible();
57 | await expect(page.getByText('All Projects')).toBeVisible();
58 | });
59 |
60 | test('quick action: Invite navigates to team', async ({ page }) => {
61 | await page.goto(BASE + '/');
62 | await page.getByRole('button', { name: 'Invite' }).click();
63 | await expect(page).toHaveURL(/\/team/);
64 | await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
65 | });
66 |
67 | test('quick action: Library navigates to marketplace', async ({ page }) => {
68 | await page.goto(BASE + '/');
69 | await page.getByRole('button', { name: 'Library' }).click();
70 | await expect(page).toHaveURL(/\/library/);
71 | await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
72 | });
73 |
74 | test('New Drawing opens template picker', async ({ page }) => {
75 | await page.goto(BASE + '/');
76 | await page.getByRole('button', { name: 'New Drawing' }).click();
77 | await expect(page.getByRole('dialog')).toBeVisible();
78 | await expect(page.getByRole('heading', { name: 'Choose a Template' })).toBeVisible();
79 | await expect(page.getByRole('button', { name: 'Blank Canvas' })).toBeVisible();
80 | await expect(page.getByRole('button', { name: 'To-Do List' })).toBeVisible();
81 | await expect(page.getByRole('button', { name: 'Checklist' })).toBeVisible();
82 | await expect(page.getByRole('button', { name: 'Bullet List' })).toBeVisible();
83 | await expect(page.getByRole('button', { name: 'Flow Chart' })).toBeVisible();
84 | });
85 | });
86 |
87 | // Projects / FileBrowser
88 | test.describe.serial('projects', () => {
89 | test.use({ storageState: 'playwright/.auth/state.json' });
90 |
> 91 | test('shows Projects label in sidebar and breadcrumb', async ({ page }) => {
| ^ Error: Error reading storage state from playwright/.auth/state.json:
92 | await page.goto(BASE + '/files');
93 | await expect(page.getByRole('navigation', { name: 'Main navigation' }).getByText('Projects')).toBeVisible();
94 | await expect(page.getByText('All Projects')).toBeVisible();
95 | });
96 |
97 | test('can create a drawing from file browser', async ({ page }) => {
98 | await page.goto(BASE + '/files');
99 | await page.getByRole('button', { name: 'Create new drawing' }).click();
100 | await expect(page.getByRole('dialog')).toBeVisible();
101 | await page.getByRole('button', { name: 'Blank Canvas' }).click();
102 | await expect(page).toHaveURL(/\/drawing\//);
103 | await expect(page.getByText('Loading Excalidraw')).toBeVisible();
104 | });
105 | });
106 |
107 | // Editor / Canvas
108 | test.describe.serial('editor', () => {
109 | test.use({ storageState: 'playwright/.auth/state.json' });
110 |
111 | test('creates drawing with To-Do template', async ({ page }) => {
112 | await page.goto(BASE + '/');
113 | await page.getByRole('button', { name: 'New Drawing' }).click();
114 | await page.getByRole('button', { name: 'To-Do List' }).click();
115 | await expect(page).toHaveURL(/\/drawing\//);
116 | await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
117 | });
118 |
119 | test('editor shows save controls and back button', async ({ page }) => {
120 | await page.goto(BASE + '/');
121 | await page.getByRole('button', { name: 'New Drawing' }).click();
122 | await page.getByRole('button', { name: 'Blank Canvas' }).click();
123 | await expect(page).toHaveURL(/\/drawing\//);
124 | await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
125 | await expect(page.getByRole('button', { name: /Back/i })).toBeVisible();
126 | });
127 | });
128 |
129 | // Library Marketplace
130 | test.describe.serial('library', () => {
131 | test.use({ storageState: 'playwright/.auth/state.json' });
132 |
133 | test('loads marketplace with search and categories', async ({ page }) => {
134 | await page.goto(BASE + '/library');
135 | await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
136 | await expect(page.getByPlaceholder('Search libraries...')).toBeVisible();
137 | await expect(page.getByRole('button', { name: 'All' }).first()).toBeVisible();
138 | await expect(page.getByRole('button', { name: 'Open External' })).toBeVisible();
139 | });
140 |
141 | test('search filters libraries', async ({ page }) => {
142 | await page.goto(BASE + '/library');
143 | await page.getByPlaceholder('Search libraries...').fill('zzzznonexistent');
144 | await expect(page.getByText('No libraries found')).toBeVisible();
145 | });
146 | });
147 |
148 | // Team / Invites
149 | test.describe.serial('team', () => {
150 | test.use({ storageState: 'playwright/.auth/state.json' });
151 |
152 | test('shows owner in members list', async ({ page }) => {
153 | await page.goto(BASE + '/team');
154 | await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
155 | await expect(page.getByText('E2E User')).toBeVisible();
156 | await expect(page.getByText('owner')).toBeVisible();
157 | });
158 |
159 | test('can send team invite', async ({ page }) => {
160 | await page.goto(BASE + '/team');
161 | await page.getByLabel('Email address').fill('invited@test.com');
162 | await page.locator('select').selectOption('editor');
163 | await page.getByRole('button', { name: 'Send Invite' }).click();
164 | await expect(page.getByText('Invite sent!')).toBeVisible();
165 | await expect(page.getByText('Pending Invites')).toBeVisible();
166 | await expect(page.getByText('invited@test.com')).toBeVisible();
167 | await expect(page.getByText('editor').first()).toBeVisible();
168 | });
169 | });
170 |
```
@@ -1,136 +0,0 @@
# Test info
- Name: team >> shows owner in members list
- Location: /home/tdvorak/Desktop/PROG+HTML/Excalidraw/frontend/e2e/app.spec.ts:152:3
# Error details
```
Error: Error reading storage state from playwright/.auth/state.json:
ENOENT: no such file or directory, open 'playwright/.auth/state.json'
```
# Test source
```ts
52 | test('quick action: New Project navigates to files', async ({ page }) => {
53 | await page.goto(BASE + '/');
54 | await page.getByRole('button', { name: 'New Project' }).click();
55 | await expect(page).toHaveURL(/\/files/);
56 | await expect(page.getByRole('navigation', { name: 'Project tree' })).toBeVisible();
57 | await expect(page.getByText('All Projects')).toBeVisible();
58 | });
59 |
60 | test('quick action: Invite navigates to team', async ({ page }) => {
61 | await page.goto(BASE + '/');
62 | await page.getByRole('button', { name: 'Invite' }).click();
63 | await expect(page).toHaveURL(/\/team/);
64 | await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
65 | });
66 |
67 | test('quick action: Library navigates to marketplace', async ({ page }) => {
68 | await page.goto(BASE + '/');
69 | await page.getByRole('button', { name: 'Library' }).click();
70 | await expect(page).toHaveURL(/\/library/);
71 | await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
72 | });
73 |
74 | test('New Drawing opens template picker', async ({ page }) => {
75 | await page.goto(BASE + '/');
76 | await page.getByRole('button', { name: 'New Drawing' }).click();
77 | await expect(page.getByRole('dialog')).toBeVisible();
78 | await expect(page.getByRole('heading', { name: 'Choose a Template' })).toBeVisible();
79 | await expect(page.getByRole('button', { name: 'Blank Canvas' })).toBeVisible();
80 | await expect(page.getByRole('button', { name: 'To-Do List' })).toBeVisible();
81 | await expect(page.getByRole('button', { name: 'Checklist' })).toBeVisible();
82 | await expect(page.getByRole('button', { name: 'Bullet List' })).toBeVisible();
83 | await expect(page.getByRole('button', { name: 'Flow Chart' })).toBeVisible();
84 | });
85 | });
86 |
87 | // Projects / FileBrowser
88 | test.describe.serial('projects', () => {
89 | test.use({ storageState: 'playwright/.auth/state.json' });
90 |
91 | test('shows Projects label in sidebar and breadcrumb', async ({ page }) => {
92 | await page.goto(BASE + '/files');
93 | await expect(page.getByRole('navigation', { name: 'Main navigation' }).getByText('Projects')).toBeVisible();
94 | await expect(page.getByText('All Projects')).toBeVisible();
95 | });
96 |
97 | test('can create a drawing from file browser', async ({ page }) => {
98 | await page.goto(BASE + '/files');
99 | await page.getByRole('button', { name: 'Create new drawing' }).click();
100 | await expect(page.getByRole('dialog')).toBeVisible();
101 | await page.getByRole('button', { name: 'Blank Canvas' }).click();
102 | await expect(page).toHaveURL(/\/drawing\//);
103 | await expect(page.getByText('Loading Excalidraw')).toBeVisible();
104 | });
105 | });
106 |
107 | // Editor / Canvas
108 | test.describe.serial('editor', () => {
109 | test.use({ storageState: 'playwright/.auth/state.json' });
110 |
111 | test('creates drawing with To-Do template', async ({ page }) => {
112 | await page.goto(BASE + '/');
113 | await page.getByRole('button', { name: 'New Drawing' }).click();
114 | await page.getByRole('button', { name: 'To-Do List' }).click();
115 | await expect(page).toHaveURL(/\/drawing\//);
116 | await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
117 | });
118 |
119 | test('editor shows save controls and back button', async ({ page }) => {
120 | await page.goto(BASE + '/');
121 | await page.getByRole('button', { name: 'New Drawing' }).click();
122 | await page.getByRole('button', { name: 'Blank Canvas' }).click();
123 | await expect(page).toHaveURL(/\/drawing\//);
124 | await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
125 | await expect(page.getByRole('button', { name: /Back/i })).toBeVisible();
126 | });
127 | });
128 |
129 | // Library Marketplace
130 | test.describe.serial('library', () => {
131 | test.use({ storageState: 'playwright/.auth/state.json' });
132 |
133 | test('loads marketplace with search and categories', async ({ page }) => {
134 | await page.goto(BASE + '/library');
135 | await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
136 | await expect(page.getByPlaceholder('Search libraries...')).toBeVisible();
137 | await expect(page.getByRole('button', { name: 'All' }).first()).toBeVisible();
138 | await expect(page.getByRole('button', { name: 'Open External' })).toBeVisible();
139 | });
140 |
141 | test('search filters libraries', async ({ page }) => {
142 | await page.goto(BASE + '/library');
143 | await page.getByPlaceholder('Search libraries...').fill('zzzznonexistent');
144 | await expect(page.getByText('No libraries found')).toBeVisible();
145 | });
146 | });
147 |
148 | // Team / Invites
149 | test.describe.serial('team', () => {
150 | test.use({ storageState: 'playwright/.auth/state.json' });
151 |
> 152 | test('shows owner in members list', async ({ page }) => {
| ^ Error: Error reading storage state from playwright/.auth/state.json:
153 | await page.goto(BASE + '/team');
154 | await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
155 | await expect(page.getByText('E2E User')).toBeVisible();
156 | await expect(page.getByText('owner')).toBeVisible();
157 | });
158 |
159 | test('can send team invite', async ({ page }) => {
160 | await page.goto(BASE + '/team');
161 | await page.getByLabel('Email address').fill('invited@test.com');
162 | await page.locator('select').selectOption('editor');
163 | await page.getByRole('button', { name: 'Send Invite' }).click();
164 | await expect(page.getByText('Invite sent!')).toBeVisible();
165 | await expect(page.getByText('Pending Invites')).toBeVisible();
166 | await expect(page.getByText('invited@test.com')).toBeVisible();
167 | await expect(page.getByText('editor').first()).toBeVisible();
168 | });
169 | });
170 |
```
+12
View File
@@ -27,5 +27,17 @@ export default defineConfig({
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
'excalidraw': ['@excalidraw/excalidraw'],
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-vendor': ['lucide-react', 'clsx'],
'i18n': ['i18next', 'react-i18next', 'i18next-browser-languagedetector'],
'state': ['zustand'],
},
},
},
chunkSizeWarningLimit: 1000,
},
})
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE workspace_folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
CREATE INDEX idx_workspace_folders_sort_order ON workspace_folders(team_id, sort_order);
-- +goose Down
DROP INDEX IF EXISTS idx_workspace_folders_sort_order;
ALTER TABLE workspace_folders DROP COLUMN IF EXISTS sort_order;
+98 -9
View File
@@ -56,6 +56,7 @@ func (a *API) Routes() chi.Router {
r.Post("/drawings", a.handleCreateDrawing)
r.Get("/drawings/{drawingID}", a.handleGetDrawing)
r.Patch("/drawings/{drawingID}", a.handleUpdateDrawing)
r.Patch("/drawings/{drawingID}/autosave", a.handleAutosaveDrawing)
r.Delete("/drawings/{drawingID}", a.handleArchiveDrawing)
r.Get("/drawings/{drawingID}/revisions", a.handleListRevisions)
r.Post("/drawings/{drawingID}/revisions", a.handleCreateRevision)
@@ -78,6 +79,9 @@ func (a *API) Routes() chi.Router {
r.Get("/stats", a.handleStats)
r.Get("/folders", a.handleListFolders)
r.Post("/folders", a.handleCreateFolder)
r.Patch("/folders/{folderID}", a.handleUpdateFolder)
r.Delete("/folders/{folderID}", a.handleDeleteFolder)
r.Post("/folders/reorder", a.handleReorderFolders)
r.Get("/projects", a.handleListProjects)
r.Post("/projects", a.handleCreateProject)
r.Get("/notifications", a.handleListNotifications)
@@ -134,6 +138,14 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
// If the request carries a valid session cookie, it has already been
// authenticated by requireSession middleware. The SameSite=Lax cookie
// attribute provides sufficient CSRF protection for same-site requests,
// so we trust authenticated mutations without a strict Origin check.
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
next.ServeHTTP(w, r)
return
}
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
@@ -150,17 +162,34 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
proto = "https"
}
expected := proto + "://" + host
if origin != expected {
// also allow without port in case proxy strips it
expectedNoPort := proto + "://" + strings.SplitN(host, ":", 2)[0]
originNoPort := strings.SplitN(origin, "://", 2)[1]
originNoPort = strings.SplitN(originNoPort, ":", 2)[0]
if originNoPort != expectedNoPort {
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
if origin == expected {
next.ServeHTTP(w, r)
return
}
// allow without port in case proxy strips it
expectedHost := strings.SplitN(host, ":", 2)[0]
originHost := ""
if parts := strings.SplitN(origin, "://", 2); len(parts) == 2 {
originHost = strings.SplitN(parts[1], ":", 2)[0]
}
if originHost != "" && originHost == expectedHost {
next.ServeHTTP(w, r)
return
}
// fallback: check Referer hostname matches
referer := r.Header.Get("Referer")
if referer != "" {
refHost := ""
if parts := strings.SplitN(referer, "://", 2); len(parts) == 2 {
refHost = strings.SplitN(parts[1], "/", 2)[0]
refHost = strings.SplitN(refHost, ":", 2)[0]
}
if refHost != "" && refHost == expectedHost {
next.ServeHTTP(w, r)
return
}
}
next.ServeHTTP(w, r)
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
})
}
@@ -356,6 +385,21 @@ func (a *API) handleUpdateDrawing(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, drawing)
}
func (a *API) handleAutosaveDrawing(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req struct {
Snapshot json.RawMessage `json:"snapshot"`
}
if !decodeJSON(w, r, &req, 10<<20) {
return
}
if err := a.store.AutosaveDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req.Snapshot); err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *API) handleArchiveDrawing(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
if err := a.store.ArchiveDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID")); err != nil {
@@ -584,6 +628,51 @@ func (a *API) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, folder)
}
func (a *API) handleUpdateFolder(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
folderID := chi.URLParam(r, "folderID")
var req UpdateFolderRequest
if !decodeJSON(w, r, &req, 128<<10) {
return
}
folder, err := a.store.UpdateFolder(r.Context(), user.ID, folderID, req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, folder)
}
func (a *API) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
folderID := chi.URLParam(r, "folderID")
if err := a.store.DeleteFolder(r.Context(), user.ID, folderID); err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *API) handleReorderFolders(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req struct {
FolderIDs []string `json:"folder_ids"`
}
if !decodeJSON(w, r, &req, 128<<10) {
return
}
if err := a.store.ReorderFolders(r.Context(), user.ID, req.FolderIDs); err != nil {
writeLookupError(w, err)
return
}
folders, err := a.store.ListFolders(r.Context(), user.ID, "")
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, folders)
}
func (a *API) handleListProjects(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
@@ -639,7 +728,7 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any, limit int64) bo
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, limit)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
// Allow unknown fields so frontend can send extra data without breaking
if err := decoder.Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return false
+184 -13
View File
@@ -40,12 +40,33 @@ type CreateDrawingRequest struct {
Snapshot json.RawMessage `json:"snapshot"`
}
// NullString distinguishes JSON null (Valid=true, Value=nil) from absent field (Valid=false).
type NullString struct {
Value *string
Valid bool
}
func (n *NullString) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
n.Value = nil
n.Valid = true
return nil
}
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
n.Value = &s
n.Valid = true
return nil
}
type UpdateDrawingRequest struct {
FolderID *string `json:"folder_id"`
ProjectID *string `json:"project_id"`
Title *string `json:"title"`
Description *string `json:"description"`
Visibility *string `json:"visibility"`
FolderID NullString `json:"folder_id"`
ProjectID NullString `json:"project_id"`
Title *string `json:"title"`
Description *string `json:"description"`
Visibility *string `json:"visibility"`
}
type CreateRevisionRequest struct {
@@ -61,6 +82,11 @@ type CreateFolderRequest struct {
Visibility string `json:"visibility"`
}
type UpdateFolderRequest struct {
Name *string `json:"name"`
Visibility *string `json:"visibility"`
}
type CreateProjectRequest struct {
TeamID string `json:"team_id"`
Name string `json:"name"`
@@ -616,11 +642,11 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
}
current.Visibility = *req.Visibility
}
if req.FolderID != nil {
current.FolderID = req.FolderID
if req.FolderID.Valid {
current.FolderID = req.FolderID.Value
}
if req.ProjectID != nil {
current.ProjectID = req.ProjectID
if req.ProjectID.Valid {
current.ProjectID = req.ProjectID.Value
}
now := time.Now().UTC()
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings
@@ -635,6 +661,47 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
return s.GetDrawing(ctx, userID, drawingID)
}
func (s *Store) AutosaveDrawing(ctx context.Context, userID, drawingID string, snapshot json.RawMessage) error {
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
return err
}
if len(snapshot) == 0 || !json.Valid(snapshot) {
return fmt.Errorf("snapshot must be valid JSON")
}
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `UPDATE workspace_drawings SET updated_at = ? WHERE id = ?`, now, drawingID)
if err != nil {
return err
}
// Upsert the latest revision snapshot directly without creating a new revision entry
var existingRevID string
var revNumber int
err = s.db.QueryRowContext(ctx, `SELECT id, revision_number FROM workspace_drawing_revisions WHERE drawing_id = ? ORDER BY revision_number DESC LIMIT 1`, drawingID).Scan(&existingRevID, &revNumber)
if errors.Is(err, sql.ErrNoRows) {
// Create initial revision if none exists
revID := newID()
_, err = s.db.ExecContext(ctx, `INSERT INTO workspace_drawing_revisions
(id, drawing_id, revision_number, snapshot_path, snapshot_size, content_hash, snapshot_json, created_by, created_at, change_summary)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
revID, drawingID, 1, fmt.Sprintf("teams/drawings/%s/revisions/1.json", drawingID), int64(len(snapshot)),
func() string { sum := sha256.Sum256(snapshot); return hex.EncodeToString(sum[:]) }(),
[]byte(snapshot), userID, now, "Auto-save",
)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings SET latest_revision_id = ?, updated_at = ? WHERE id = ?`, revID, now, drawingID)
return err
}
if err != nil {
return err
}
// Update existing latest revision snapshot
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawing_revisions SET snapshot_json = ?, snapshot_size = ?, content_hash = ?, updated_at = ? WHERE id = ?`,
[]byte(snapshot), int64(len(snapshot)), func() string { sum := sha256.Sum256(snapshot); return hex.EncodeToString(sum[:]) }(), now, existingRevID)
return err
}
func (s *Store) ArchiveDrawing(ctx context.Context, userID, drawingID string) error {
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
return err
@@ -881,7 +948,7 @@ func (s *Store) ListFolders(ctx context.Context, userID, teamID string) ([]Folde
return nil, ErrForbidden
}
rows, err := s.db.QueryContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at
FROM workspace_folders WHERE team_id = ? ORDER BY path_cache ASC`, teamID)
FROM workspace_folders WHERE team_id = ? ORDER BY sort_order ASC, created_at ASC`, teamID)
if err != nil {
return nil, err
}
@@ -931,10 +998,12 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
CreatedAt: now,
UpdatedAt: now,
}
var maxOrder int
s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) + 1 FROM workspace_folders WHERE team_id = ?`, teamID).Scan(&maxOrder)
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_folders
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt,
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt, maxOrder,
)
if err != nil {
return nil, err
@@ -942,6 +1011,108 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
return folder, nil
}
func (s *Store) UpdateFolder(ctx context.Context, userID, folderID string, req UpdateFolderRequest) (*Folder, error) {
var teamID string
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return nil, ErrForbidden
}
var updates []string
var args []any
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" || len(name) > 120 {
return nil, fmt.Errorf("folder name must be between 1 and 120 characters")
}
updates = append(updates, "name = ?")
args = append(args, name)
updates = append(updates, "slug = ?")
args = append(args, slugify(name))
updates = append(updates, "path_cache = ?")
args = append(args, slugify(name))
}
if req.Visibility != nil {
updates = append(updates, "visibility = ?")
args = append(args, *req.Visibility)
}
if len(updates) == 0 {
return s.GetFolder(ctx, folderID)
}
updates = append(updates, "updated_at = ?")
args = append(args, time.Now().UTC())
args = append(args, folderID)
query := "UPDATE workspace_folders SET " + strings.Join(updates, ", ") + " WHERE id = ?"
_, err = s.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, err
}
return s.GetFolder(ctx, folderID)
}
func (s *Store) GetFolder(ctx context.Context, folderID string) (*Folder, error) {
var folder Folder
err := s.db.QueryRowContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at FROM workspace_folders WHERE id = ?`, folderID).Scan(
&folder.ID, &folder.TeamID, &folder.ProjectID, &folder.ParentFolderID, &folder.Name, &folder.Slug, &folder.PathCache, &folder.Visibility, &folder.CreatedBy, &folder.CreatedAt, &folder.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &folder, nil
}
func (s *Store) DeleteFolder(ctx context.Context, userID, folderID string) error {
var teamID string
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return ErrForbidden
}
_, err = s.db.ExecContext(ctx, `DELETE FROM workspace_folders WHERE id = ?`, folderID)
return err
}
func (s *Store) ReorderFolders(ctx context.Context, userID string, folderIDs []string) error {
if len(folderIDs) == 0 {
return nil
}
var teamID string
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderIDs[0]).Scan(&teamID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return ErrForbidden
}
for i, id := range folderIDs {
_, err := s.db.ExecContext(ctx, `UPDATE workspace_folders SET sort_order = ? WHERE id = ? AND team_id = ?`, i, id, teamID)
if err != nil {
return err
}
}
return nil
}
func (s *Store) ListProjects(ctx context.Context, userID, teamID string) ([]Project, error) {
if teamID == "" {
teamID, _ = s.defaultTeamID(ctx, userID)