Compare commits

..

2 Commits

Author SHA1 Message Date
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
23 changed files with 1022 additions and 1286 deletions
+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,14 +41,14 @@ 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) {
function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: string) {
return {
id: `txt-${Math.random().toString(36).slice(2)}`,
type: 'text',
@@ -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,7 +77,7 @@ 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,
@@ -208,14 +208,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'),
@@ -291,6 +291,167 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
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),
],
'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),
],
'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),
],
'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),
],
'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),
],
'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),
],
'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),
],
retrospective: [
makeText(50, 30, 'Retrospective', 30),
// Went Well
@@ -374,13 +535,13 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
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),
@@ -421,6 +582,84 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeAddButton(300, 290, '+', 'architecture-add'),
makeText(332, 290, 'Add component...', 14),
],
'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),
],
'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),
],
'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),
],
};
const OPTIONS: TemplateOption[] = [
@@ -434,11 +673,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>
);
};
+82 -7
View File
@@ -73,11 +73,23 @@
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;
}
}
.loadingCanvas {
@@ -407,18 +419,81 @@
align-items: center;
gap: var(--space-3);
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);
padding: var(--space-2) var(--space-4);
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(-0.3deg);
box-shadow: var(--shadow-island);
}
.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;
}
.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 +508,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;
+352 -39
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, StickyNote, LayoutTemplate, MonitorPlay, X, Plus } from 'lucide-react';
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, ChevronLeft, StickyNote, LayoutTemplate, MonitorPlay, X, Plus, Frame } from 'lucide-react';
import { Button } from '@/components';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import { useThemeStore } from '@/stores';
@@ -34,8 +34,15 @@ interface EditorState {
function prepareElementsForImport(sourceElements: LooseElement[], offsetX: number, offsetY: number): LooseElement[] {
if (!sourceElements || !sourceElements.length) return [];
const idMap = new Map<string, string>();
const groupIdMap = new Map<string, string>();
sourceElements.forEach((el) => {
idMap.set(el.id as string, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
const gids = ((el as { groupIds?: string[] }).groupIds) || [];
gids.forEach((gid) => {
if (!groupIdMap.has(gid)) {
groupIdMap.set(gid, `group-${Math.random().toString(36).slice(2, 9)}`);
}
});
});
return sourceElements.map((el) => {
const newEl: LooseElement = { ...el };
@@ -55,6 +62,10 @@ function prepareElementsForImport(sourceElements: LooseElement[], offsetX: numbe
if (newEl.containerId && idMap.has(newEl.containerId as string)) {
newEl.containerId = idMap.get(newEl.containerId as string);
}
const gids = (newEl as { groupIds?: string[] }).groupIds;
if (gids && gids.length) {
(newEl as { groupIds?: string[] }).groupIds = gids.map((gid) => groupIdMap.get(gid) || gid);
}
return newEl;
});
}
@@ -88,6 +99,9 @@ export const Editor: React.FC = () => {
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedDataRef = useRef<string>('');
const lastToggledCheckboxRef = useRef<string | null>(null);
const lastProcessedAddRef = useRef<string | null>(null);
const saveDrawingRef = useRef<() => Promise<void>>(async () => {});
const isMutatingSceneRef = useRef(false);
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
const [showTemplates, setShowTemplates] = useState(false);
@@ -96,6 +110,8 @@ export const Editor: React.FC = () => {
const [templateName, setTemplateName] = useState('');
const [templateDesc, setTemplateDesc] = useState('');
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
const [slideIndex, setSlideIndex] = useState(0);
const [slides, setSlides] = useState<ExcalidrawElement[]>([]);
// Load drawing data
useEffect(() => {
@@ -176,6 +192,22 @@ export const Editor: React.FC = () => {
// Handle changes from Excalidraw
const handleExcalidrawChange = useCallback((elements: readonly ExcalidrawElement[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
// Skip mutation processing if we are in the middle of applying a scene mutation
// to prevent React error #185 (Maximum update depth exceeded)
if (isMutatingSceneRef.current) {
currentStateRef.current = {
elements: elements as unknown as ExcalidrawElement[],
appState: appStateWithoutGrid(appState),
files,
};
setSaveStatus('unsaved');
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => {
saveDrawingRef.current();
}, 2000);
return;
}
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
const selectedEl = selectedIds.length === 1
? elements.find((el) => el.id === selectedIds[0] && !el.isDeleted)
@@ -202,11 +234,19 @@ export const Editor: React.FC = () => {
}
: el
));
excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] });
const nextEls = nextElements;
const nextAppState = appStateWithoutGrid(appState);
const nextFiles = files;
isMutatingSceneRef.current = true;
// Defer updateScene to prevent synchronous re-trigger of onChange (React error #185)
setTimeout(() => {
excalidrawAPI.updateScene({ elements: nextEls as ExcalidrawElement[] });
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
}, 0);
currentStateRef.current = {
elements: nextElements,
appState: appStateWithoutGrid(appState),
files,
elements: nextEls,
appState: nextAppState,
files: nextFiles,
};
setSaveStatus('unsaved');
return;
@@ -217,6 +257,10 @@ export const Editor: React.FC = () => {
// Handle "+" add button click
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.action === 'add' && excalidrawAPI) {
if (lastProcessedAddRef.current === selectedEl.id) {
return;
}
lastProcessedAddRef.current = selectedEl.id;
const customData = (selectedEl.customData as Record<string, unknown>) || {};
const role = customData.templateRole as string;
const btnX = (selectedEl.x as number) || 0;
@@ -253,7 +297,12 @@ export const Editor: React.FC = () => {
? { ...el, y: newY + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
const merged = [...updated, ...newElements];
isMutatingSceneRef.current = true;
setTimeout(() => {
excalidrawAPI.updateScene({ elements: merged as ExcalidrawElement[] });
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
}, 0);
setSaveStatus('unsaved');
return;
}
@@ -288,7 +337,12 @@ export const Editor: React.FC = () => {
? { ...el, y: newY + cardH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
const kanbanMerged = [...updated, ...newElements];
isMutatingSceneRef.current = true;
setTimeout(() => {
excalidrawAPI.updateScene({ elements: kanbanMerged as ExcalidrawElement[] });
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
}, 0);
setSaveStatus('unsaved');
return;
}
@@ -337,7 +391,12 @@ export const Editor: React.FC = () => {
? { ...el, y: newY + nodeH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
const mindmapMerged = [...updated, ...newElements];
isMutatingSceneRef.current = true;
setTimeout(() => {
excalidrawAPI.updateScene({ elements: mindmapMerged as ExcalidrawElement[] });
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
}, 0);
setSaveStatus('unsaved');
return;
}
@@ -364,10 +423,17 @@ export const Editor: React.FC = () => {
? { ...el, y: newY + 30, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
const genericMerged = [...updated, ...newElements];
isMutatingSceneRef.current = true;
setTimeout(() => {
excalidrawAPI.updateScene({ elements: genericMerged as ExcalidrawElement[] });
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
}, 0);
setSaveStatus('unsaved');
return;
}
} else {
lastProcessedAddRef.current = null;
}
currentStateRef.current = {
@@ -380,45 +446,30 @@ export const Editor: React.FC = () => {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
saveDrawing();
saveDrawingRef.current();
}, 2000);
}, [excalidrawAPI]);
// Auto-save functionality
// Auto-save: updates drawing snapshot directly without creating a revision
const saveDrawing = useCallback(async () => {
if (!id || !currentStateRef.current || isSaving) return;
const { elements, appState, files } = currentStateRef.current;
if (!id || !currentStateRef.current) return;
const snapshot = {
type: 'excalidraw',
version: 2,
source: window.location.hostname,
elements,
appState: {
viewBackgroundColor: appState.viewBackgroundColor,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
theme: appState.theme,
zenModeEnabled: appState.zenModeEnabled,
viewModeEnabled: appState.viewModeEnabled,
editingGroup: appState.editingGroup,
selectedElementIds: appState.selectedElementIds,
},
files,
elements: currentStateRef.current.elements,
appState: currentStateRef.current.appState,
files: currentStateRef.current.files,
};
const snapshotJson = JSON.stringify(snapshot);
if (snapshotJson === lastSavedDataRef.current) {
setSaveStatus('saved');
return;
}
try {
setIsSaving(true);
setSaveStatus('saving');
await api.revisions.create(id, snapshot, 'Auto-save');
await api.drawings.autosave(id, snapshot);
lastSavedDataRef.current = snapshotJson;
setSaveStatus('saved');
} catch (err) {
@@ -427,10 +478,16 @@ export const Editor: React.FC = () => {
} finally {
setIsSaving(false);
}
}, [id, isSaving]);
}, [id]);
// Keep ref in sync with latest saveDrawing closure
useEffect(() => {
saveDrawingRef.current = saveDrawing;
}, [saveDrawing]);
// Remove unused revisions warning by displaying count in UI
const revisionCount = revisions.length;
const meaningfulRevisions = revisions.filter((r) => r.change_summary !== 'Auto-save');
const revisionCount = meaningfulRevisions.length;
// Restore a specific revision
const handleRestoreRevision = (revision: DrawingRevision) => {
@@ -450,12 +507,40 @@ export const Editor: React.FC = () => {
}
};
// Manual save
// Manual save: creates a named revision
const handleManualSave = async () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
if (!id || !currentStateRef.current) return;
const snapshot = {
type: 'excalidraw',
version: 2,
source: window.location.hostname,
elements: currentStateRef.current.elements,
appState: currentStateRef.current.appState,
files: currentStateRef.current.files,
};
const snapshotJson = JSON.stringify(snapshot);
try {
setIsSaving(true);
setSaveStatus('saving');
// Create a named revision for manual save
await api.revisions.create(id, snapshot, 'Manual save');
lastSavedDataRef.current = snapshotJson;
setSaveStatus('saved');
// Refresh revisions list
try {
const revData = await api.revisions.list(id);
setRevisions(revData);
} catch (_) { /* ignore */ }
} catch (err) {
console.error('Failed to save:', err);
setSaveStatus('unsaved');
} finally {
setIsSaving(false);
}
await saveDrawing();
};
// Ctrl+S keyboard shortcut
@@ -512,9 +597,20 @@ export const Editor: React.FC = () => {
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: null },
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: null },
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a topic', icon: null },
{ id: 'brainstorm-star', label: 'Star Brainstorm', description: 'Radial branches from core', icon: null },
{ id: 'brainstorm-matrix', label: 'Matrix Brainstorm', description: '2×2 grid for ideas', icon: null },
{ id: 'brainstorm-freeform', label: 'Freeform Notes', description: 'Scattered sticky notes', icon: null },
{ id: 'brainstorm-fishbone', label: 'Fishbone Diagram', description: 'Root-cause analysis', icon: null },
{ id: 'brainstorm-venn', label: 'Venn Diagram', description: 'Compare overlapping sets', icon: null },
{ id: 'brainstorm-tree', label: 'Tree Diagram', description: 'Hierarchical branching', icon: null },
{ id: 'brainstorm-converge', label: 'Converge Map', description: 'Ideas into solution', icon: null },
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: null },
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opps, threats', icon: null },
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: null },
{ id: 'er-diagram', label: 'ER Diagram', description: 'Entity relationship tables', icon: null },
{ id: 'api-design', label: 'API Design', description: 'REST endpoints and methods', icon: null },
{ id: 'sitemap', label: 'Site Map', description: 'Website page hierarchy', icon: null },
{ id: 'user-persona', label: 'User Persona', description: 'Goals, frustrations, behaviors', icon: null },
];
useEffect(() => {
@@ -529,6 +625,122 @@ export const Editor: React.FC = () => {
});
}, [excalidrawAPI]);
// Library import from URL hash (#addLibrary=...)
useEffect(() => {
if (!excalidrawAPI) return;
const hash = window.location.hash;
const match = hash.match(/addLibrary=([^&]+)/);
if (match) {
const libraryUrl = decodeURIComponent(match[1]);
fetch(libraryUrl)
.then((r) => r.json())
.then((data) => {
// Excalidraw library items come in various formats
let libraryItems = data.libraryItems || data.library || data;
// Normalize to Excalidraw's expected library item format: { id, elements, status }
if (Array.isArray(libraryItems)) {
libraryItems = libraryItems.map((item: any) => {
if (item.libraryItem) {
return { id: item.id || item.libraryItem.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.libraryItem.elements || [], status: 'published' };
}
if (item.data) {
return { id: item.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.data.elements || item.elements || [], status: 'published' };
}
if (item.elements) {
return { id: item.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.elements, status: 'published' };
}
return item;
});
}
// Use the Excalidraw imperative API to add library items
try {
const api = excalidrawAPI as any;
if (api.updateLibraryItems) {
api.updateLibraryItems(libraryItems, 'merge');
} else if (api.updateScene) {
// Fallback: add elements directly to the canvas at center
const currentElements = api.getSceneElements?.() || [];
const newElements = libraryItems.flatMap((item: any) => item.elements || []);
if (newElements.length > 0) {
api.updateScene({
elements: [...currentElements, ...newElements] as ExcalidrawElement[],
});
}
}
} catch (e) {
console.warn('Library import failed:', e);
}
window.history.replaceState(null, '', window.location.pathname + window.location.search);
})
.catch((err) => console.error('Failed to load library:', err));
}
}, [excalidrawAPI]);
// Build slides: first slide is whole canvas, then each frame is a slide
useEffect(() => {
if (!presentationMode || !excalidrawAPI) return;
const currentElements = (excalidrawAPI.getSceneElements?.() || []) as ExcalidrawElement[];
const frameElements = currentElements
.filter((el: any) => el.type === 'frame')
.sort((a: any, b: any) => (a.y - b.y) || (a.x - b.x));
const allSlides: ExcalidrawElement[] = [];
// Slide 0: whole canvas (represented by a virtual placeholder)
if (currentElements.length > 0) {
allSlides.push({ id: '__whole_canvas__', type: 'frame', x: 0, y: 0, width: 1, height: 1, name: 'Canvas', isDeleted: false } as any);
}
// Subsequent slides: frames
frameElements.forEach((f: any) => allSlides.push(f));
setSlides(allSlides);
setSlideIndex(0);
window.setTimeout(() => {
const api = excalidrawAPI as any;
if (allSlides.length > 0 && api.scrollToContent) {
if (allSlides[0].id === '__whole_canvas__') {
api.zoomToFit?.();
} else {
api.scrollToContent?.([allSlides[0]], { fitToContent: true, animate: true });
}
}
}, 100);
}, [presentationMode, excalidrawAPI]);
// Presentation keyboard navigation
useEffect(() => {
if (!presentationMode) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') {
e.preventDefault();
setSlideIndex((prev) => Math.min(prev + 1, slides.length - 1));
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
e.preventDefault();
setSlideIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Home') {
e.preventDefault();
setSlideIndex(0);
} else if (e.key === 'End') {
e.preventDefault();
setSlideIndex(slides.length - 1);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [presentationMode, slides.length]);
// Scroll to current slide when slideIndex changes
useEffect(() => {
if (!presentationMode || !excalidrawAPI || slides.length === 0) return;
const currentSlide = slides[slideIndex];
if (!currentSlide) return;
const api = excalidrawAPI as any;
window.setTimeout(() => {
if (currentSlide.id === '__whole_canvas__') {
api.zoomToFit?.();
} else if (api.scrollToContent) {
api.scrollToContent?.([currentSlide], { fitToContent: true, animate: true });
}
}, 50);
}, [slideIndex, slides, presentationMode, excalidrawAPI]);
if (isLoading) {
return (
<div className={styles.container}>
@@ -555,7 +767,12 @@ export const Editor: React.FC = () => {
<div className={styles.container}>
<div className={`${styles.toolbar} ${presentationMode ? styles.toolbarHidden : ''}`}>
<div className={styles.left}>
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<Button variant="ghost" size="sm" onClick={async () => {
if (saveStatus === 'unsaved') {
await saveDrawingRef.current();
}
navigate(drawing?.folder_id ? `/folder/${drawing.folder_id}` : '/files');
}}>
<ArrowLeft size={18} />
{t('editor.back')}
</Button>
@@ -607,6 +824,68 @@ export const Editor: React.FC = () => {
>
<LayoutTemplate size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (!excalidrawAPI) return;
const appState = excalidrawAPI.getAppState?.() || {};
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
const elements = excalidrawAPI.getSceneElements?.() || [];
const selectedEls = elements.filter((el) => selectedIds.includes(el.id));
if (selectedEls.length === 0) {
alert('Select elements on canvas to create a slide');
return;
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
selectedEls.forEach((el) => {
minX = Math.min(minX, el.x);
minY = Math.min(minY, el.y);
maxX = Math.max(maxX, el.x + el.width);
maxY = Math.max(maxY, el.y + el.height);
});
const padding = 40;
const frameEl = {
id: `frame-${Math.random().toString(36).slice(2)}`,
type: 'frame',
x: minX - padding,
y: minY - padding,
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2,
angle: 0,
strokeColor: '#1e1e1e',
backgroundColor: 'transparent',
fillStyle: 'hachure' as const,
strokeWidth: 1,
strokeStyle: 'solid' as const,
roughness: 1,
opacity: 100,
groupIds: [],
roundness: null,
seed: Math.floor(Math.random() * 10000),
version: 2,
versionNonce: Math.floor(Math.random() * 100000),
isDeleted: false,
boundElements: [],
updated: Date.now(),
link: null,
locked: false,
customData: { templateRole: 'slide' },
name: `Slide ${elements.filter((e) => e.type === 'frame').length + 1}`,
};
isMutatingSceneRef.current = true;
excalidrawAPI.updateScene({
elements: [...elements, frameEl] as ExcalidrawElement[],
appState: { ...appState, selectedElementIds: { [frameEl.id]: true } },
});
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
setSaveStatus('unsaved');
}}
title="Create slide from selection"
aria-label="Create a presentation slide from selected elements"
>
<Frame size={16} />
</Button>
<Button
variant="ghost"
size="sm"
@@ -662,10 +941,10 @@ export const Editor: React.FC = () => {
</Button>
</div>
<div className={styles.revisionList}>
{revisions.length === 0 ? (
{meaningfulRevisions.length === 0 ? (
<p className={styles.revisionEmpty}>{t('editor.noRevisions')}</p>
) : (
revisions.map((rev) => (
meaningfulRevisions.map((rev) => (
<button
key={rev.id}
className={`${styles.revisionItem} ${selectedRevision === rev.id ? styles.revisionActive : ''}`}
@@ -731,11 +1010,45 @@ export const Editor: React.FC = () => {
{presentationMode && (
<div className={styles.presentationOverlay} role="presentation">
<div className={styles.presentationToolbar}>
<span className={styles.presentationLabel}>Presentation Mode Press Esc to exit</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSlideIndex((prev) => Math.max(prev - 1, 0))}
disabled={slideIndex <= 0}
aria-label="Previous slide"
>
<ChevronLeft size={16} />
</Button>
<span className={styles.presentationLabel}>
Slide {slides.length > 0 ? slideIndex + 1 : 0} / {slides.length}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSlideIndex((prev) => Math.min(prev + 1, slides.length - 1))}
disabled={slideIndex >= slides.length - 1}
aria-label="Next slide"
>
<ChevronRight size={16} />
</Button>
<Button variant="ghost" size="sm" onClick={() => setPresentationMode(false)} aria-label="Exit presentation">
<X size={16} />
</Button>
</div>
<div className={styles.presentationSlides}>
{slides.map((slide, idx) => (
<button
key={slide.id || idx}
className={`${styles.presentationSlideThumb} ${idx === slideIndex ? styles.presentationSlideActive : ''}`}
onClick={() => setSlideIndex(idx)}
aria-label={`Go to slide ${idx + 1}`}
title={idx === 0 ? 'Whole canvas' : (slide as any).name || `Slide ${idx}`}
>
<div className={styles.presentationSlideNumber}>{idx + 1}</div>
<div className={styles.presentationSlideName}>{idx === 0 ? 'Canvas' : ((slide as any).name || `Slide ${idx}`)}</div>
</button>
))}
</div>
</div>
)}
@@ -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;
@@ -140,12 +139,12 @@
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);
@@ -153,17 +152,14 @@
&: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);
}
svg {
@@ -228,15 +224,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 +306,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 +322,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);
@@ -371,58 +365,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 +457,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 +469,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 +504,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 +513,7 @@
&:focus {
border-color: var(--color-primary);
box-shadow: 3px 3px 0 var(--color-gray-85);
box-shadow: var(--shadow-island);
}
}
@@ -536,27 +526,27 @@
.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; }
}
@@ -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);
}
}
+3
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[]> =>
+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 |
```
+50 -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)
@@ -134,6 +135,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 +159,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 +382,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 {
@@ -639,7 +680,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
+41
View File
@@ -635,6 +635,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