mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 13:52:56 +00:00
Compare commits
2 Commits
462a70933d
...
71dda9d45d
| Author | SHA1 | Date | |
|---|---|---|---|
| 71dda9d45d | |||
| b79c214ad2 |
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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">×</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]> =>
|
||||
|
||||
@@ -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,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 | });
|
||||
```
|
||||
-177
@@ -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 |
|
||||
```
|
||||
-155
@@ -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 |
|
||||
```
|
||||
-187
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user