mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +00:00
Compare commits
8 Commits
462a70933d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c8088404 | |||
| cd22ee1ee8 | |||
| 19e7ed6ea1 | |||
| 8336c76705 | |||
| 910546230d | |||
| 190be65e4f | |||
| 71dda9d45d | |||
| b79c214ad2 |
@@ -28,6 +28,15 @@ jobs:
|
||||
id: image
|
||||
run: echo "repository=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker
|
||||
run: |
|
||||
if ! command -v docker &> /dev/null; then
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
rm get-docker.sh
|
||||
fi
|
||||
docker --version
|
||||
|
||||
- name: Use GitHub token for GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
||||
+1
-1
Submodule excalidraw updated: 278cd35772...f6d85bc80f
@@ -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,15 +41,15 @@ function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: st
|
||||
version: 2,
|
||||
versionNonce: Math.floor(Math.random() * 100000),
|
||||
isDeleted: false,
|
||||
boundElements: text ? [{ id: `txt-${Math.random().toString(36).slice(2)}`, type: 'text' }] : [],
|
||||
boundElements: [],
|
||||
updated: Date.now(),
|
||||
link: null,
|
||||
locked: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeText(x: number, y: number, text: string, fontSize = 20) {
|
||||
return {
|
||||
function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: string, customData?: Record<string, unknown>) {
|
||||
const el: RawElement = {
|
||||
id: `txt-${Math.random().toString(36).slice(2)}`,
|
||||
type: 'text',
|
||||
x, y, width: text.length * (fontSize * 0.55), height: fontSize * 1.4,
|
||||
@@ -61,7 +61,7 @@ function makeText(x: number, y: number, text: string, fontSize = 20) {
|
||||
strokeStyle: 'solid',
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
groupIds: [],
|
||||
groupIds: groupId ? [groupId] : [],
|
||||
frameId: null,
|
||||
roundness: null,
|
||||
seed: Math.floor(Math.random() * 10000),
|
||||
@@ -77,11 +77,15 @@ function makeText(x: number, y: number, text: string, fontSize = 20) {
|
||||
fontFamily: 1,
|
||||
textAlign: 'left',
|
||||
verticalAlign: 'top',
|
||||
baseline: 18,
|
||||
baseline: Math.round(fontSize * 0.7),
|
||||
containerId: null,
|
||||
originalText: text,
|
||||
lineHeight: 1.25,
|
||||
};
|
||||
if (customData) {
|
||||
el.customData = customData;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeCheckbox(x: number, y: number, checked = false) {
|
||||
@@ -158,7 +162,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeCheckbox(60, 210, false),
|
||||
makeText(90, 210, 'Third task'),
|
||||
makeAddButton(60, 250, '+', 'todo-add'),
|
||||
makeText(92, 250, 'Add task...', 16),
|
||||
makeText(92, 250, 'Add task...', 16, undefined, { templateRole: 'todo-add', action: 'add' }),
|
||||
makeHandDrawnRect(50, 290, 500, 2),
|
||||
makeText(60, 310, 'Notes:', 18),
|
||||
],
|
||||
@@ -172,7 +176,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeCheckbox(60, 210, false),
|
||||
makeText(90, 210, 'Another task', 18),
|
||||
makeAddButton(60, 250, '+', 'checklist-add'),
|
||||
makeText(92, 250, 'Add item...', 16),
|
||||
makeText(92, 250, 'Add item...', 16, undefined, { templateRole: 'checklist-add', action: 'add' }),
|
||||
],
|
||||
list: [
|
||||
makeHandDrawnRect(50, 50, 500, 50),
|
||||
@@ -182,7 +186,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeText(60, 210, '• Third bullet point'),
|
||||
makeText(60, 250, '• Fourth item with details'),
|
||||
makeAddButton(60, 290, '+', 'list-add'),
|
||||
makeText(92, 290, 'Add bullet...', 16),
|
||||
makeText(92, 290, 'Add bullet...', 16, undefined, { templateRole: 'list-add', action: 'add' }),
|
||||
],
|
||||
flow: [
|
||||
makeHandDrawnRect(200, 50, 200, 60),
|
||||
@@ -197,7 +201,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(200, 350, 200, 60),
|
||||
makeText(230, 370, 'End', 20),
|
||||
makeAddButton(420, 180, '+', 'flow-add'),
|
||||
makeText(452, 180, 'Add step', 14),
|
||||
makeText(452, 180, 'Add step', 14, undefined, { templateRole: 'flow-add', action: 'add' }),
|
||||
],
|
||||
kanban: [
|
||||
makeText(50, 40, 'Kanban Board', 30),
|
||||
@@ -208,14 +212,14 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeText(285, 120, 'Doing', 20),
|
||||
makeText(495, 120, 'Done', 20),
|
||||
// Card 1 - grouped
|
||||
makeHandDrawnRect(70, 170, 140, 70, undefined, 'card1'),
|
||||
makeText(85, 190, 'User research', 16),
|
||||
makeHandDrawnRect(70, 170, 140, 70, 'card1'),
|
||||
makeText(85, 190, 'User research', 16, 'card1'),
|
||||
// Card 2 - grouped
|
||||
makeHandDrawnRect(280, 170, 140, 70, undefined, 'card2'),
|
||||
makeText(295, 190, 'Sketch flow', 16),
|
||||
makeHandDrawnRect(280, 170, 140, 70, 'card2'),
|
||||
makeText(295, 190, 'Sketch flow', 16, 'card2'),
|
||||
// Card 3 - grouped
|
||||
makeHandDrawnRect(490, 170, 140, 70, undefined, 'card3'),
|
||||
makeText(505, 190, 'Project brief', 16),
|
||||
makeHandDrawnRect(490, 170, 140, 70, 'card3'),
|
||||
makeText(505, 190, 'Project brief', 16, 'card3'),
|
||||
// Add card buttons per column
|
||||
makeAddButton(110, 380, '+', 'kanban-add-backlog'),
|
||||
makeAddButton(320, 380, '+', 'kanban-add-doing'),
|
||||
@@ -234,7 +238,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeCheckbox(70, 390, false),
|
||||
makeText(105, 390, 'Owner and next step', 18),
|
||||
makeAddButton(70, 430, '+', 'meeting-add-action'),
|
||||
makeText(102, 430, 'Add action...', 14),
|
||||
makeText(102, 430, 'Add action...', 14, undefined, { templateRole: 'meeting-add-action', action: 'add' }),
|
||||
],
|
||||
wireframe: [
|
||||
makeText(50, 35, 'Page Wireframe', 30),
|
||||
@@ -248,7 +252,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(265, 380, 190, 110),
|
||||
makeHandDrawnRect(480, 380, 190, 110),
|
||||
makeAddButton(480, 500, '+', 'wireframe-add-section'),
|
||||
makeText(512, 500, 'Add section', 14),
|
||||
makeText(512, 500, 'Add section', 14, undefined, { templateRole: 'wireframe-add-section', action: 'add' }),
|
||||
],
|
||||
mindmap: [
|
||||
makeHandDrawnRect(240, 200, 200, 70),
|
||||
@@ -285,12 +289,173 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeText(520, 196, 'Idea 3', 18),
|
||||
makeArrow(460, 110, 580, 180),
|
||||
makeAddButton(50, 240, '+', 'brainstorm-add'),
|
||||
makeText(82, 240, 'Add idea...', 16),
|
||||
makeText(82, 240, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
// Notes area
|
||||
makeHandDrawnRect(50, 280, 610, 100),
|
||||
makeText(70, 300, 'Notes & connections:', 18),
|
||||
makeText(70, 330, '- Write insights here', 16),
|
||||
],
|
||||
'brainstorm-star': [
|
||||
makeText(50, 30, 'Brainstorm — Star', 30),
|
||||
// Central hub
|
||||
makeHandDrawnRect(260, 200, 180, 60),
|
||||
makeText(290, 220, 'Core Idea', 22),
|
||||
// 6 radial branches (top, top-right, bottom-right, bottom, bottom-left, top-left)
|
||||
makeHandDrawnRect(280, 60, 140, 50),
|
||||
makeText(300, 76, 'Branch 1', 18),
|
||||
makeArrow(350, 200, 350, 110),
|
||||
makeHandDrawnRect(480, 140, 140, 50),
|
||||
makeText(500, 156, 'Branch 2', 18),
|
||||
makeArrow(440, 220, 480, 165),
|
||||
makeHandDrawnRect(480, 280, 140, 50),
|
||||
makeText(500, 296, 'Branch 3', 18),
|
||||
makeArrow(440, 240, 480, 305),
|
||||
makeHandDrawnRect(280, 320, 140, 50),
|
||||
makeText(300, 336, 'Branch 4', 18),
|
||||
makeArrow(350, 260, 350, 320),
|
||||
makeHandDrawnRect(60, 280, 140, 50),
|
||||
makeText(80, 296, 'Branch 5', 18),
|
||||
makeArrow(260, 240, 200, 305),
|
||||
makeHandDrawnRect(60, 140, 140, 50),
|
||||
makeText(80, 156, 'Branch 6', 18),
|
||||
makeArrow(260, 220, 200, 165),
|
||||
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
||||
makeText(82, 400, 'Add branch...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-matrix': [
|
||||
makeText(50, 30, 'Brainstorm — Matrix', 30),
|
||||
// 2x2 grid
|
||||
makeHandDrawnRect(50, 90, 300, 160),
|
||||
makeText(150, 130, 'Quadrant A', 20),
|
||||
makeText(80, 170, '- Idea 1', 16),
|
||||
makeText(80, 200, '- Idea 2', 16),
|
||||
makeHandDrawnRect(370, 90, 300, 160),
|
||||
makeText(470, 130, 'Quadrant B', 20),
|
||||
makeText(400, 170, '- Idea 1', 16),
|
||||
makeText(400, 200, '- Idea 2', 16),
|
||||
makeHandDrawnRect(50, 270, 300, 160),
|
||||
makeText(150, 310, 'Quadrant C', 20),
|
||||
makeText(80, 350, '- Idea 1', 16),
|
||||
makeText(80, 380, '- Idea 2', 16),
|
||||
makeHandDrawnRect(370, 270, 300, 160),
|
||||
makeText(470, 310, 'Quadrant D', 20),
|
||||
makeText(400, 350, '- Idea 1', 16),
|
||||
makeText(400, 380, '- Idea 2', 16),
|
||||
makeAddButton(50, 450, '+', 'brainstorm-add'),
|
||||
makeText(82, 450, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-freeform': [
|
||||
makeText(50, 30, 'Brainstorm — Freeform', 30),
|
||||
makeText(50, 70, 'Drag sticky notes anywhere!', 16),
|
||||
// Scattered sticky notes
|
||||
makeHandDrawnRect(60, 110, 160, 80),
|
||||
makeText(80, 140, '💡 Idea 1', 18),
|
||||
makeHandDrawnRect(260, 130, 160, 80),
|
||||
makeText(280, 160, '🚀 Idea 2', 18),
|
||||
makeHandDrawnRect(460, 110, 160, 80),
|
||||
makeText(480, 140, '🎯 Idea 3', 18),
|
||||
makeHandDrawnRect(120, 230, 160, 80),
|
||||
makeText(140, 260, '❓ Idea 4', 18),
|
||||
makeHandDrawnRect(340, 250, 160, 80),
|
||||
makeText(360, 280, '✨ Idea 5', 18),
|
||||
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
||||
makeText(82, 360, 'Add note...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-fishbone': [
|
||||
makeText(50, 30, 'Brainstorm — Fishbone', 30),
|
||||
// Spine
|
||||
makeArrow(100, 250, 600, 250),
|
||||
// Problem head
|
||||
makeHandDrawnRect(600, 220, 140, 60),
|
||||
makeText(620, 240, 'Problem', 18),
|
||||
// Top branches
|
||||
makeArrow(220, 250, 180, 160),
|
||||
makeHandDrawnRect(120, 110, 160, 50),
|
||||
makeText(140, 128, 'People', 16),
|
||||
makeArrow(380, 250, 340, 160),
|
||||
makeHandDrawnRect(280, 110, 160, 50),
|
||||
makeText(300, 128, 'Process', 16),
|
||||
makeArrow(540, 250, 500, 160),
|
||||
makeHandDrawnRect(440, 110, 160, 50),
|
||||
makeText(460, 128, 'Policy', 16),
|
||||
// Bottom branches
|
||||
makeArrow(280, 250, 240, 340),
|
||||
makeHandDrawnRect(180, 340, 160, 50),
|
||||
makeText(200, 358, 'Place', 16),
|
||||
makeArrow(460, 250, 420, 340),
|
||||
makeHandDrawnRect(360, 340, 160, 50),
|
||||
makeText(380, 358, 'Product', 16),
|
||||
makeAddButton(50, 420, '+', 'brainstorm-add'),
|
||||
makeText(82, 420, 'Add cause...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-venn': [
|
||||
makeText(50, 30, 'Brainstorm — Venn', 30),
|
||||
// Three overlapping circles
|
||||
makeHandDrawnRect(120, 120, 160, 160),
|
||||
makeText(170, 130, 'A', 20),
|
||||
makeText(140, 190, 'Set A traits', 14),
|
||||
makeHandDrawnRect(280, 120, 160, 160),
|
||||
makeText(330, 130, 'B', 20),
|
||||
makeText(300, 190, 'Set B traits', 14),
|
||||
makeHandDrawnRect(200, 220, 160, 160),
|
||||
makeText(250, 230, 'C', 20),
|
||||
makeText(220, 290, 'Set C traits', 14),
|
||||
// Center overlap note
|
||||
makeText(245, 190, 'Overlap', 12),
|
||||
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
||||
makeText(82, 400, 'Add set...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-tree': [
|
||||
makeText(50, 30, 'Brainstorm — Tree', 30),
|
||||
// Root
|
||||
makeHandDrawnRect(280, 90, 160, 50),
|
||||
makeText(310, 110, 'Root Topic', 18),
|
||||
// Branches
|
||||
makeArrow(360, 140, 200, 200),
|
||||
makeHandDrawnRect(120, 190, 160, 50),
|
||||
makeText(145, 210, 'Branch 1', 16),
|
||||
makeArrow(360, 140, 360, 200),
|
||||
makeHandDrawnRect(280, 190, 160, 50),
|
||||
makeText(305, 210, 'Branch 2', 16),
|
||||
makeArrow(360, 140, 520, 200),
|
||||
makeHandDrawnRect(440, 190, 160, 50),
|
||||
makeText(465, 210, 'Branch 3', 16),
|
||||
// Leaves
|
||||
makeArrow(200, 240, 140, 300),
|
||||
makeHandDrawnRect(60, 290, 140, 40),
|
||||
makeText(85, 305, 'Leaf 1a', 14),
|
||||
makeArrow(200, 240, 260, 300),
|
||||
makeHandDrawnRect(200, 290, 140, 40),
|
||||
makeText(225, 305, 'Leaf 1b', 14),
|
||||
makeArrow(520, 240, 460, 300),
|
||||
makeHandDrawnRect(440, 290, 140, 40),
|
||||
makeText(465, 305, 'Leaf 3a', 14),
|
||||
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
||||
makeText(82, 360, 'Add branch...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
'brainstorm-converge': [
|
||||
makeText(50, 30, 'Brainstorm — Converge', 30),
|
||||
// Diverging ideas (top)
|
||||
makeHandDrawnRect(80, 90, 140, 50),
|
||||
makeText(105, 110, 'Idea A', 16),
|
||||
makeHandDrawnRect(280, 90, 140, 50),
|
||||
makeText(305, 110, 'Idea B', 16),
|
||||
makeHandDrawnRect(480, 90, 140, 50),
|
||||
makeText(505, 110, 'Idea C', 16),
|
||||
// Converging arrows
|
||||
makeArrow(150, 140, 290, 220),
|
||||
makeArrow(350, 140, 330, 220),
|
||||
makeArrow(550, 140, 370, 220),
|
||||
// Converged outcome
|
||||
makeHandDrawnRect(220, 220, 260, 70),
|
||||
makeText(260, 245, 'Combined Solution', 20),
|
||||
// Next steps
|
||||
makeArrow(350, 290, 350, 350),
|
||||
makeHandDrawnRect(240, 350, 220, 50),
|
||||
makeText(265, 370, 'Action Plan', 16),
|
||||
makeAddButton(50, 430, '+', 'brainstorm-add'),
|
||||
makeText(82, 430, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||
],
|
||||
retrospective: [
|
||||
makeText(50, 30, 'Retrospective', 30),
|
||||
// Went Well
|
||||
@@ -368,19 +533,19 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(50, 300, 600, 2),
|
||||
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
|
||||
makeAddButton(50, 350, '+', 'storymap-add-row'),
|
||||
makeText(82, 350, 'Add row...', 14),
|
||||
makeText(82, 350, 'Add row...', 14, undefined, { templateRole: 'storymap-add-row', action: 'add' }),
|
||||
],
|
||||
timeline: [
|
||||
makeText(50, 30, 'Project Timeline', 30),
|
||||
makeHandDrawnRect(50, 90, 600, 4),
|
||||
// Milestones
|
||||
makeHandDrawnRect(80, 70, 20, 44, undefined, 'milestone-1'),
|
||||
makeHandDrawnRect(80, 70, 20, 44, 'milestone-1'),
|
||||
makeText(60, 125, 'Q1 Kickoff', 14),
|
||||
makeHandDrawnRect(220, 70, 20, 44, undefined, 'milestone-2'),
|
||||
makeHandDrawnRect(220, 70, 20, 44, 'milestone-2'),
|
||||
makeText(200, 125, 'Design', 14),
|
||||
makeHandDrawnRect(360, 70, 20, 44, undefined, 'milestone-3'),
|
||||
makeHandDrawnRect(360, 70, 20, 44, 'milestone-3'),
|
||||
makeText(340, 125, 'Build', 14),
|
||||
makeHandDrawnRect(500, 70, 20, 44, undefined, 'milestone-4'),
|
||||
makeHandDrawnRect(500, 70, 20, 44, 'milestone-4'),
|
||||
makeText(480, 125, 'Launch', 14),
|
||||
// Tasks below timeline
|
||||
makeHandDrawnRect(50, 170, 130, 50),
|
||||
@@ -392,7 +557,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(500, 170, 130, 50),
|
||||
makeText(515, 185, 'Deploy', 14),
|
||||
makeAddButton(80, 240, '+', 'timeline-add'),
|
||||
makeText(112, 240, 'Add phase...', 14),
|
||||
makeText(112, 240, 'Add phase...', 14, undefined, { templateRole: 'timeline-add', action: 'add' }),
|
||||
],
|
||||
architecture: [
|
||||
makeText(50, 30, 'System Architecture', 30),
|
||||
@@ -419,7 +584,85 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
makeHandDrawnRect(50, 200, 160, 70),
|
||||
makeText(90, 220, 'CDN', 18),
|
||||
makeAddButton(300, 290, '+', 'architecture-add'),
|
||||
makeText(332, 290, 'Add component...', 14),
|
||||
makeText(332, 290, 'Add component...', 14, undefined, { templateRole: 'architecture-add', action: 'add' }),
|
||||
],
|
||||
'er-diagram': [
|
||||
makeText(50, 30, 'ER Diagram', 30),
|
||||
// User entity
|
||||
makeHandDrawnRect(50, 90, 160, 120),
|
||||
makeText(70, 110, 'User', 18),
|
||||
makeText(70, 140, 'id: PK', 14),
|
||||
makeText(70, 164, 'email: string', 14),
|
||||
makeText(70, 188, 'name: string', 14),
|
||||
// Relationship line
|
||||
makeArrow(210, 150, 270, 150),
|
||||
makeText(220, 130, '1:N', 12),
|
||||
// Order entity
|
||||
makeHandDrawnRect(270, 90, 160, 120),
|
||||
makeText(290, 110, 'Order', 18),
|
||||
makeText(290, 140, 'id: PK', 14),
|
||||
makeText(290, 164, 'user_id: FK', 14),
|
||||
makeText(290, 188, 'total: number', 14),
|
||||
],
|
||||
'api-design': [
|
||||
makeText(50, 30, 'API Design', 30),
|
||||
makeHandDrawnRect(50, 90, 600, 50),
|
||||
makeText(70, 110, 'GET /users → List users', 16),
|
||||
makeHandDrawnRect(50, 150, 600, 50),
|
||||
makeText(70, 170, 'POST /users → Create user', 16),
|
||||
makeHandDrawnRect(50, 210, 600, 50),
|
||||
makeText(70, 230, 'GET /users/:id → Get user', 16),
|
||||
makeHandDrawnRect(50, 270, 600, 50),
|
||||
makeText(70, 290, 'PATCH /users/:id → Update user', 16),
|
||||
makeHandDrawnRect(50, 330, 600, 50),
|
||||
makeText(70, 350, 'DELETE /users/:id → Delete user', 16),
|
||||
makeAddButton(50, 400, '+', 'api-add'),
|
||||
makeText(82, 400, 'Add endpoint...', 14, undefined, { templateRole: 'api-add', action: 'add' }),
|
||||
],
|
||||
'sitemap': [
|
||||
makeText(50, 30, 'Site Map', 30),
|
||||
// Home
|
||||
makeHandDrawnRect(280, 90, 140, 50),
|
||||
makeText(320, 112, 'Home', 18),
|
||||
// Pages below
|
||||
makeHandDrawnRect(50, 180, 140, 50),
|
||||
makeText(75, 202, 'Products', 16),
|
||||
makeHandDrawnRect(220, 180, 140, 50),
|
||||
makeText(250, 202, 'Pricing', 16),
|
||||
makeHandDrawnRect(390, 180, 140, 50),
|
||||
makeText(420, 202, 'About', 16),
|
||||
makeHandDrawnRect(560, 180, 140, 50),
|
||||
makeText(585, 202, 'Contact', 16),
|
||||
// Connections
|
||||
makeArrow(350, 140, 120, 180),
|
||||
makeArrow(350, 140, 290, 180),
|
||||
makeArrow(350, 140, 460, 180),
|
||||
makeArrow(350, 140, 630, 180),
|
||||
makeAddButton(50, 260, '+', 'sitemap-add'),
|
||||
makeText(82, 260, 'Add page...', 14, undefined, { templateRole: 'sitemap-add', action: 'add' }),
|
||||
],
|
||||
'user-persona': [
|
||||
makeText(50, 30, 'User Persona', 30),
|
||||
// Name & role
|
||||
makeHandDrawnRect(50, 90, 300, 70),
|
||||
makeText(70, 112, 'Name: Alex, 32, Designer', 18),
|
||||
// Goals
|
||||
makeHandDrawnRect(50, 180, 300, 130),
|
||||
makeText(70, 200, 'Goals 🎯', 18),
|
||||
makeText(70, 230, '- Save time on workflows', 14),
|
||||
makeText(70, 254, '- Collaborate easily', 14),
|
||||
// Frustrations
|
||||
makeHandDrawnRect(370, 90, 300, 220),
|
||||
makeText(390, 112, 'Frustrations 😤', 18),
|
||||
makeText(390, 142, '- Too many tools', 14),
|
||||
makeText(390, 166, '- Slow feedback loops', 14),
|
||||
makeText(390, 190, '- Hard to share ideas', 14),
|
||||
makeText(390, 214, '- No single source of truth', 14),
|
||||
// Behaviors
|
||||
makeHandDrawnRect(50, 330, 620, 70),
|
||||
makeText(70, 352, 'Behaviors: Uses Figma, Slack, Notion. Prefers visual tools.', 14),
|
||||
makeAddButton(50, 420, '+', 'persona-add'),
|
||||
makeText(82, 420, 'Add trait...', 14, undefined, { templateRole: 'persona-add', action: 'add' }),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -434,11 +677,22 @@ const OPTIONS: TemplateOption[] = [
|
||||
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: PanelsTopLeft, elements: [] },
|
||||
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: GitFork, elements: [] },
|
||||
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a central topic', icon: Lightbulb, elements: [] },
|
||||
{ id: 'brainstorm-star', label: 'Star Brainstorm', description: 'Radial branches from core idea', icon: Lightbulb, elements: [] },
|
||||
{ id: 'brainstorm-matrix', label: 'Matrix Brainstorm', description: '2×2 grid for categorizing ideas', icon: Lightbulb, elements: [] },
|
||||
{ id: 'brainstorm-freeform', label: 'Freeform Notes', description: 'Scattered sticky notes layout', icon: Lightbulb, elements: [] },
|
||||
{ id: 'brainstorm-fishbone', label: 'Fishbone Diagram', description: 'Root-cause analysis with causes', icon: Lightbulb, elements: [] },
|
||||
{ id: 'brainstorm-venn', label: 'Venn Diagram', description: 'Compare overlapping sets', icon: Lightbulb, elements: [] },
|
||||
{ id: 'brainstorm-tree', label: 'Tree Diagram', description: 'Hierarchical branching topics', icon: Lightbulb, elements: [] },
|
||||
{ id: 'brainstorm-converge', label: 'Converge Map', description: 'Ideas merging into a solution', icon: Lightbulb, elements: [] },
|
||||
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: RotateCcw, elements: [] },
|
||||
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opportunities, threats', icon: Shield, elements: [] },
|
||||
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: Map, elements: [] },
|
||||
{ id: 'timeline', label: 'Timeline', description: 'Project phases and milestones', icon: Timer, elements: [] },
|
||||
{ id: 'architecture', label: 'Architecture', description: 'System components and connections', icon: Layers, elements: [] },
|
||||
{ id: 'er-diagram', label: 'ER Diagram', description: 'Entity relationship with tables', icon: Database, elements: [] },
|
||||
{ id: 'api-design', label: 'API Design', description: 'REST endpoints and methods', icon: Code, elements: [] },
|
||||
{ id: 'sitemap', label: 'Site Map', description: 'Website page hierarchy', icon: Globe, elements: [] },
|
||||
{ id: 'user-persona', label: 'User Persona', description: 'Goals, frustrations, behaviors', icon: UserCircle, elements: [] },
|
||||
];
|
||||
|
||||
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
|
||||
|
||||
@@ -13,17 +13,15 @@
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-5) var(--space-6);
|
||||
background: var(--island-bg-color);
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
box-shadow: 4px 4px 0 var(--color-gray-85);
|
||||
transform: rotate(-0.3deg);
|
||||
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
h1 {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-gray-85);
|
||||
margin-bottom: var(--space-2);
|
||||
font-family: 'Georgia', serif;
|
||||
font-family: var(--ui-font);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
@@ -84,21 +82,16 @@
|
||||
}
|
||||
|
||||
.statCardWrapper {
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
||||
transform: rotate(0.15deg);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island);
|
||||
transition: all 0.2s var(--ease-out);
|
||||
background: var(--island-bg-color);
|
||||
|
||||
&:hover {
|
||||
transform: rotate(0) translate(-1px, -1px);
|
||||
box-shadow: 5px 5px 0 var(--color-gray-85);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:nth-child(2) { transform: rotate(-0.1deg); }
|
||||
&:nth-child(3) { transform: rotate(0.25deg); }
|
||||
&:nth-child(4) { transform: rotate(-0.2deg); }
|
||||
&:nth-child(5) { transform: rotate(0.05deg); }
|
||||
}
|
||||
|
||||
.statCard {
|
||||
@@ -107,7 +100,9 @@
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
padding: var(--space-5);
|
||||
min-height: 150px;
|
||||
min-height: 140px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statTop {
|
||||
@@ -115,69 +110,70 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--border-radius-lg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary-light);
|
||||
border: 2px solid var(--color-gray-85);
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.handChart {
|
||||
width: 80px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
transform: rotate(1deg);
|
||||
}
|
||||
|
||||
.sparkline {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
margin-top: var(--space-2);
|
||||
background: var(--color-surface-low);
|
||||
border: 1px solid var(--color-gray-20);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: var(--text-3xl);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-family: 'Georgia', serif;
|
||||
font-family: var(--ui-font);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-muted);
|
||||
margin-top: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.chartBarWrap {
|
||||
.statBarTrack {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--color-gray-20);
|
||||
border-radius: var(--border-radius-full);
|
||||
margin-top: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statBarFill {
|
||||
height: 100%;
|
||||
border-radius: var(--border-radius-full);
|
||||
transition: width 0.6s var(--ease-out);
|
||||
}
|
||||
|
||||
.progressBarWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
margin-top: var(--space-3);
|
||||
border-radius: var(--border-radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chartBarBg {
|
||||
.progressBarBg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-gray-20);
|
||||
border-radius: var(--border-radius-full);
|
||||
}
|
||||
|
||||
.chartBar {
|
||||
.progressBarFill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-darkest));
|
||||
transition: width 0.4s var(--ease-out);
|
||||
}
|
||||
|
||||
@@ -230,22 +226,21 @@
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
border: 2px solid var(--color-gray-30);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
box-shadow: var(--shadow-island);
|
||||
background: var(--island-bg-color);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-surface-low);
|
||||
transform: translateX(2px) rotate(-0.3deg);
|
||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
||||
transform: translateX(2px);
|
||||
box-shadow: var(--shadow-island);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 2px solid var(--color-gray-30);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -253,12 +248,12 @@
|
||||
.drawingThumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 2px;
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-low);
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--color-gray-30);
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
border: 1px solid var(--default-border-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@@ -346,20 +341,26 @@
|
||||
|
||||
.activityItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) 0;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border-bottom: 1px solid var(--color-gray-20);
|
||||
transition: background 0.15s ease;
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
}
|
||||
}
|
||||
|
||||
.activityAvatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
@@ -368,17 +369,19 @@
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--color-gray-85);
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
border: 2px solid var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
}
|
||||
|
||||
.activityInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.activityText {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-gray-80);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.activityTime {
|
||||
@@ -399,12 +402,11 @@
|
||||
|
||||
.modal {
|
||||
background: var(--island-bg-color);
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
box-shadow: 5px 5px 0 var(--color-gray-85);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
transform: rotate(-0.3deg);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
@@ -412,12 +414,12 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 2px solid var(--color-gray-85);
|
||||
border-bottom: 1px solid var(--default-border-color);
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gray-85);
|
||||
font-family: 'Georgia', serif;
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,15 +446,15 @@
|
||||
.modalInput {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 2px solid var(--color-gray-30);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: var(--input-bg-color);
|
||||
color: var(--color-on-surface);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
||||
box-shadow: var(--shadow-island);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,25 +467,25 @@
|
||||
|
||||
.modalBtnSecondary {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: 2px;
|
||||
border: 2px solid var(--color-gray-30);
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--default-border-color);
|
||||
background: transparent;
|
||||
color: var(--color-gray-70);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
&:hover { background: var(--color-surface-low); transform: rotate(-0.5deg); }
|
||||
box-shadow: var(--shadow-island);
|
||||
&:hover { background: var(--color-surface-low); }
|
||||
}
|
||||
|
||||
.modalBtnPrimary {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: 2px;
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--default-border-color);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
&:hover { background: var(--color-primary-darker); transform: rotate(-0.5deg); }
|
||||
box-shadow: var(--shadow-island);
|
||||
&:hover { background: var(--color-primary-darker); }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toolbarDivider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--default-border-color);
|
||||
margin: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-85);
|
||||
@@ -73,11 +80,46 @@
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
|
||||
:global(.excalidraw) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:global(.excalidrew-wrapper) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Ensure Excalidraw's internal layout fills the container
|
||||
:global(.excalidraw .excalidraw-canvas-container) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.canvasTools {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-island);
|
||||
|
||||
button {
|
||||
padding: 4px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingCanvas {
|
||||
@@ -390,11 +432,18 @@
|
||||
|
||||
.presentationOverlay {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
pointer-events: auto;
|
||||
pointer-events: none;
|
||||
animation: presentationFadeIn 0.3s var(--ease-out);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
@keyframes presentationFadeIn {
|
||||
@@ -406,19 +455,85 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
background: var(--island-bg-color);
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
||||
transform: rotate(-0.3deg);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-xl);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
margin-bottom: var(--space-4);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.presentationLabel {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-gray-70);
|
||||
font-weight: 500;
|
||||
font-family: 'Georgia', serif;
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
|
||||
.presentationSlides {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--space-2);
|
||||
box-shadow: var(--shadow-island);
|
||||
max-width: 400px;
|
||||
overflow-x: auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.presentationSlideThumb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 56px;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--color-gray-20);
|
||||
border-radius: var(--border-radius-md);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: var(--color-gray-70);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-gray-10);
|
||||
border-color: var(--color-gray-30);
|
||||
}
|
||||
}
|
||||
|
||||
.presentationSlideActive {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.presentationSlideNumber {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-gray-20);
|
||||
}
|
||||
|
||||
.presentationSlideActive .presentationSlideNumber {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.presentationSlideName {
|
||||
font-size: 10px;
|
||||
max-width: 60px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
@@ -433,8 +548,8 @@
|
||||
|
||||
.modal {
|
||||
background: var(--island-bg-color);
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--modal-shadow);
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
@@ -555,3 +670,59 @@
|
||||
z-index: 80;
|
||||
}
|
||||
}
|
||||
|
||||
// Excalidraw context menu styling
|
||||
:global(.context-menu),
|
||||
:global(.excalidraw-context-menu) {
|
||||
background: var(--island-bg-color) !important;
|
||||
border: 1px solid var(--default-border-color) !important;
|
||||
border-radius: var(--border-radius-xl) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
|
||||
padding: var(--space-1) !important;
|
||||
|
||||
.context-menu-item,
|
||||
.menu-item {
|
||||
border-radius: var(--border-radius-lg) !important;
|
||||
padding: var(--space-2) var(--space-3) !important;
|
||||
margin: 2px 0 !important;
|
||||
font-size: var(--text-sm) !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low) !important;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0 !important;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-separator,
|
||||
.menu-item-separator {
|
||||
background: var(--default-border-color) !important;
|
||||
margin: var(--space-1) var(--space-2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Excalidraw dropdown menus
|
||||
:global(.dropdown-menu),
|
||||
:global(.excalidraw-dropdown) {
|
||||
background: var(--island-bg-color) !important;
|
||||
border: 1px solid var(--default-border-color) !important;
|
||||
border-radius: var(--border-radius-xl) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
|
||||
padding: var(--space-1) !important;
|
||||
|
||||
.dropdown-menu-item,
|
||||
.menu-item {
|
||||
border-radius: var(--border-radius-lg) !important;
|
||||
padding: var(--space-2) var(--space-3) !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,9 @@
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-5);
|
||||
background: var(--island-bg-color);
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
box-shadow: 4px 4px 0 var(--color-gray-85);
|
||||
transform: rotate(0.2deg);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
|
||||
@media (max-width: 640px) {
|
||||
flex-direction: column;
|
||||
@@ -115,9 +114,9 @@
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--island-bg-color);
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island);
|
||||
padding: var(--space-3);
|
||||
align-self: flex-start;
|
||||
|
||||
@@ -133,6 +132,10 @@
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.folderItem {
|
||||
@@ -140,30 +143,32 @@
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: 2px;
|
||||
border-radius: var(--border-radius-lg);
|
||||
color: var(--color-gray-70);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
background: none;
|
||||
border: 2px solid transparent;
|
||||
border: 1px solid transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
color: var(--color-on-surface);
|
||||
border-color: var(--color-gray-30);
|
||||
transform: rotate(-0.3deg);
|
||||
border-color: var(--default-border-color);
|
||||
}
|
||||
|
||||
&.folderActive {
|
||||
background: var(--color-surface-primary-container);
|
||||
color: var(--color-primary-darkest);
|
||||
font-weight: 600;
|
||||
border-color: var(--color-gray-85);
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
transform: rotate(-0.2deg);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
svg {
|
||||
@@ -172,6 +177,97 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandleWrapper {
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
color: var(--color-muted);
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-out);
|
||||
flex-shrink: 0;
|
||||
|
||||
.folderItem:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dragOver {
|
||||
background: var(--color-surface-primary-container);
|
||||
border: 2px dashed var(--color-primary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
}
|
||||
|
||||
.folderMenuBtn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--space-1);
|
||||
cursor: pointer;
|
||||
color: var(--color-muted);
|
||||
border-radius: var(--border-radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
}
|
||||
|
||||
.folderMenu {
|
||||
position: absolute;
|
||||
right: var(--space-2);
|
||||
top: 100%;
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island);
|
||||
min-width: 140px;
|
||||
z-index: 20;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.folderMenuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--color-on-surface);
|
||||
font-size: var(--text-sm);
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
}
|
||||
}
|
||||
|
||||
.folderMenuDanger {
|
||||
color: var(--color-danger-text);
|
||||
|
||||
&:hover {
|
||||
background: rgba(224, 49, 49, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
@@ -228,15 +324,13 @@
|
||||
|
||||
.drawingCard {
|
||||
position: relative;
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
||||
transform: rotate(0.1deg);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island);
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: rotate(0) translate(-1px, -1px);
|
||||
box-shadow: 5px 5px 0 var(--color-gray-85);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,9 +406,9 @@
|
||||
top: calc(100% + var(--space-1));
|
||||
right: 0;
|
||||
background: var(--island-bg-color);
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island);
|
||||
min-width: 160px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
@@ -328,7 +422,7 @@
|
||||
text-align: left;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--color-on-surface);
|
||||
font-size: var(--text-sm);
|
||||
|
||||
@@ -351,17 +445,88 @@
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.dropdownSubmenu {
|
||||
.batchBar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--color-surface-primary-container);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dropdownSubheader {
|
||||
.batchCount {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-darkest);
|
||||
}
|
||||
|
||||
.batchActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.batchBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-on-surface);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
}
|
||||
}
|
||||
|
||||
.batchDanger {
|
||||
color: var(--color-danger-text);
|
||||
|
||||
&:hover {
|
||||
background: rgba(224, 49, 49, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.selectBox {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
z-index: 5;
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-1);
|
||||
cursor: pointer;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-out);
|
||||
|
||||
.drawingCard:hover &,
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.drawingSelected {
|
||||
border: 2px solid var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||
|
||||
.selectBox {
|
||||
opacity: 1;
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.newProjectForm {
|
||||
@@ -371,58 +536,55 @@
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface-low);
|
||||
border: 2px solid var(--color-gray-30);
|
||||
border-radius: 2px;
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island);
|
||||
}
|
||||
|
||||
.newProjectInput {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
background: var(--input-bg-color);
|
||||
border: 2px solid var(--color-gray-30);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--text-sm);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
||||
box-shadow: var(--shadow-island);
|
||||
}
|
||||
}
|
||||
|
||||
.newProjectBtn {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
box-shadow: var(--shadow-island);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-darkest);
|
||||
transform: rotate(-0.5deg);
|
||||
}
|
||||
}
|
||||
|
||||
.newProjectBtnCancel {
|
||||
background: none;
|
||||
border: 2px solid var(--color-gray-30);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-on-surface);
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
box-shadow: var(--shadow-island);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
transform: rotate(-0.5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,12 +628,11 @@
|
||||
|
||||
.modal {
|
||||
background: var(--island-bg-color);
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
box-shadow: 5px 5px 0 var(--color-gray-85);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
transform: rotate(-0.3deg);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
@@ -479,13 +640,13 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 2px solid var(--color-gray-85);
|
||||
border-bottom: 1px solid var(--default-border-color);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-gray-85);
|
||||
font-family: 'Georgia', serif;
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,8 +675,8 @@
|
||||
.modalInput {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 2px solid var(--color-gray-30);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: var(--input-bg-color);
|
||||
color: var(--color-on-surface);
|
||||
font-size: var(--text-sm);
|
||||
@@ -523,7 +684,7 @@
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
||||
box-shadow: var(--shadow-island);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,27 +697,82 @@
|
||||
|
||||
.modalBtnSecondary {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: 2px;
|
||||
border: 2px solid var(--color-gray-30);
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--default-border-color);
|
||||
background: transparent;
|
||||
color: var(--color-gray-70);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
box-shadow: var(--shadow-island);
|
||||
|
||||
&:hover { background: var(--color-surface-low); transform: rotate(-0.5deg); }
|
||||
&:hover { background: var(--color-surface-low); }
|
||||
}
|
||||
|
||||
.modalBtnPrimary {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: 2px;
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--default-border-color);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
box-shadow: var(--shadow-island);
|
||||
|
||||
&:hover { background: var(--color-primary-darker); transform: rotate(-0.5deg); }
|
||||
&:hover { background: var(--color-primary-darker); }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.moveHint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-muted);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
.moveList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.moveItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--color-on-surface);
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
|
||||
svg {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
border-color: var(--default-border-color);
|
||||
}
|
||||
|
||||
&.moveItemActive {
|
||||
background: var(--color-surface-primary-container);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-darkest);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.moveCurrent {
|
||||
margin-left: auto;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface-low);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle, Pencil, Trash2, GripVertical, Square, SquareCheck, Move } from 'lucide-react';
|
||||
import { Card, Button, Modal } from '@/components';
|
||||
import { useDrawingStore } from '@/stores';
|
||||
import { api } from '@/services';
|
||||
import type { Drawing } from '@/types';
|
||||
import type { Drawing, Folder as FolderType } from '@/types';
|
||||
import styles from './FileBrowser.module.scss';
|
||||
|
||||
export const FileBrowser: React.FC = () => {
|
||||
@@ -35,7 +35,22 @@ export const FileBrowser: React.FC = () => {
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
// Move state
|
||||
const [movingId, setMovingId] = useState<string | null>(null);
|
||||
const [moveModalDrawing, setMoveModalDrawing] = useState<Drawing | null>(null);
|
||||
|
||||
// Folder menu state
|
||||
const [folderMenuId, setFolderMenuId] = useState<string | null>(null);
|
||||
const folderMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Drag-drop state for folders
|
||||
const [draggedFolderId, setDraggedFolderId] = useState<string | null>(null);
|
||||
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
|
||||
|
||||
// Drag-drop state for drawings
|
||||
const [draggedDrawingId, setDraggedDrawingId] = useState<string | null>(null);
|
||||
|
||||
// Multi-select state
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchMoveOpen, setBatchMoveOpen] = useState(false);
|
||||
|
||||
// New drawing name modal state
|
||||
const [showNameModal, setShowNameModal] = useState(false);
|
||||
@@ -173,6 +188,11 @@ export const FileBrowser: React.FC = () => {
|
||||
try {
|
||||
await api.drawings.delete(drawing.id);
|
||||
removeDrawing(drawing.id);
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(drawing.id);
|
||||
return next;
|
||||
});
|
||||
setActiveMenu(null);
|
||||
setModal(m => ({ ...m, open: false }));
|
||||
} catch (err) {
|
||||
@@ -183,6 +203,55 @@ export const FileBrowser: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const count = selectedIds.size;
|
||||
if (count === 0) return;
|
||||
const selectedDrawings = visibleDrawings.filter(d => selectedIds.has(d.id));
|
||||
showModal('confirm', 'Delete Drawings', `Delete ${count} drawing(s)? This cannot be undone.`, async () => {
|
||||
try {
|
||||
await Promise.all(selectedDrawings.map(d => api.drawings.delete(d.id)));
|
||||
selectedDrawings.forEach(d => removeDrawing(d.id));
|
||||
setSelectedIds(new Set());
|
||||
setModal(m => ({ ...m, open: false }));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete drawings:', err);
|
||||
setModal(m => ({ ...m, open: false }));
|
||||
setTimeout(() => showModal('alert', 'Error', 'Failed to delete drawings.'), 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedIds(new Set(visibleDrawings.map(d => d.id)));
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleBatchMove = async (folderId: string | null) => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await Promise.all(ids.map(id => api.drawings.update(id, { folder_id: folderId })));
|
||||
setDrawings(drawings.map(d => selectedIds.has(d.id) ? { ...d, folder_id: folderId } : d));
|
||||
setSelectedIds(new Set());
|
||||
setBatchMoveOpen(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to move drawings:', err);
|
||||
showModal('alert', 'Error', 'Failed to move drawings. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateDrawing = async (drawing: Drawing) => {
|
||||
try {
|
||||
const newDrawing = await api.drawings.create({
|
||||
@@ -219,20 +288,165 @@ export const FileBrowser: React.FC = () => {
|
||||
try {
|
||||
await api.drawings.update(drawing.id, { folder_id: folderId });
|
||||
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
|
||||
setMovingId(null);
|
||||
setActiveMenu(null);
|
||||
setMoveModalDrawing(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to move drawing:', err);
|
||||
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameFolder = async (folder: FolderType) => {
|
||||
const name = renameValue.trim();
|
||||
if (!name || name === folder.name) {
|
||||
setRenamingId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await api.folders.update(folder.id, { name });
|
||||
setFolders(folders.map(f => f.id === folder.id ? updated : f));
|
||||
setRenamingId(null);
|
||||
setFolderMenuId(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to rename folder:', err);
|
||||
showModal('alert', 'Error', 'Failed to rename folder. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFolder = (folder: FolderType) => {
|
||||
const drawingsInFolder = drawings.filter(d => d.folder_id === folder.id);
|
||||
const message = drawingsInFolder.length > 0
|
||||
? `Delete "${folder.name}" and move its ${drawingsInFolder.length} drawing(s) to root? This cannot be undone.`
|
||||
: `Delete "${folder.name}"? This cannot be undone.`;
|
||||
|
||||
showModal('confirm', 'Delete Folder', message, async () => {
|
||||
try {
|
||||
// Move drawings to root first
|
||||
for (const drawing of drawingsInFolder) {
|
||||
await api.drawings.update(drawing.id, { folder_id: null });
|
||||
}
|
||||
setDrawings(drawings.map(d =>
|
||||
d.folder_id === folder.id ? { ...d, folder_id: null } : d
|
||||
));
|
||||
await api.folders.delete(folder.id);
|
||||
setFolders(folders.filter(f => f.id !== folder.id));
|
||||
setFolderMenuId(null);
|
||||
setModal(m => ({ ...m, open: false }));
|
||||
if (activeFolderId === folder.id) {
|
||||
navigate('/files');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete folder:', err);
|
||||
setModal(m => ({ ...m, open: false }));
|
||||
setTimeout(() => showModal('alert', 'Error', 'Failed to delete folder.'), 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Drag and drop handlers for folders
|
||||
const handleDragStart = (e: React.DragEvent, folderId: string) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isHandle = target.closest(`.${styles.dragHandleWrapper}`) !== null;
|
||||
if (!isHandle) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
setDraggedFolderId(folderId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
const related = e.relatedTarget as HTMLElement;
|
||||
const current = e.currentTarget as HTMLElement;
|
||||
if (related && current.contains(related)) {
|
||||
return;
|
||||
}
|
||||
setDragOverFolderId(null);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetFolderId: string | null) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Handle drawing drop onto folder
|
||||
if (draggedDrawingId) {
|
||||
const drawing = drawings.find(d => d.id === draggedDrawingId);
|
||||
if (drawing && drawing.folder_id !== targetFolderId) {
|
||||
try {
|
||||
await api.drawings.update(drawing.id, { folder_id: targetFolderId });
|
||||
setDrawings(drawings.map(d => d.id === draggedDrawingId ? { ...d, folder_id: targetFolderId } : d));
|
||||
} catch (err) {
|
||||
console.error('Failed to move drawing to folder:', err);
|
||||
}
|
||||
}
|
||||
setDraggedDrawingId(null);
|
||||
setDragOverFolderId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle folder reorder drop
|
||||
if (!draggedFolderId || draggedFolderId === targetFolderId) {
|
||||
setDraggedFolderId(null);
|
||||
setDragOverFolderId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reorder: move dragged folder to target position
|
||||
const currentFolders = [...folders];
|
||||
const draggedIndex = currentFolders.findIndex(f => f.id === draggedFolderId);
|
||||
const targetIndex = currentFolders.findIndex(f => f.id === targetFolderId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) {
|
||||
setDraggedFolderId(null);
|
||||
setDragOverFolderId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const [draggedFolder] = currentFolders.splice(draggedIndex, 1);
|
||||
currentFolders.splice(targetIndex, 0, draggedFolder);
|
||||
|
||||
const newOrder = currentFolders.map(f => f.id);
|
||||
|
||||
try {
|
||||
const reordered = await api.folders.reorder(newOrder);
|
||||
setFolders(reordered);
|
||||
} catch (err) {
|
||||
console.error('Failed to reorder folders:', err);
|
||||
}
|
||||
|
||||
setDraggedFolderId(null);
|
||||
setDragOverFolderId(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedFolderId(null);
|
||||
setDragOverFolderId(null);
|
||||
setDraggedDrawingId(null);
|
||||
};
|
||||
|
||||
// Drawing drag handlers
|
||||
const handleDrawingDragStart = (e: React.DragEvent, drawingId: string) => {
|
||||
setDraggedDrawingId(drawingId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDrawingDragEnd = () => {
|
||||
setDraggedDrawingId(null);
|
||||
setDragOverFolderId(null);
|
||||
};
|
||||
|
||||
// Close menu on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (menuRef.current && !menuRef.current.contains(target)) {
|
||||
setActiveMenu(null);
|
||||
}
|
||||
if (folderMenuRef.current && !folderMenuRef.current.contains(target)) {
|
||||
const isMenuBtn = target.closest(`.${styles.folderMenuBtn}`) !== null;
|
||||
if (!isMenuBtn) {
|
||||
setFolderMenuId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
@@ -347,6 +561,28 @@ export const FileBrowser: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className={styles.batchBar}>
|
||||
<span className={styles.batchCount}>{selectedIds.size} selected</span>
|
||||
<div className={styles.batchActions}>
|
||||
<button className={styles.batchBtn} onClick={selectAll}>
|
||||
Select All
|
||||
</button>
|
||||
<button className={styles.batchBtn} onClick={() => setBatchMoveOpen(true)}>
|
||||
<Move size={14} />
|
||||
Move to...
|
||||
</button>
|
||||
<button className={`${styles.batchBtn} ${styles.batchDanger}`} onClick={handleDeleteSelected}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
<button className={styles.batchBtn} onClick={clearSelection}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.content}>
|
||||
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
|
||||
{showNewProject && (
|
||||
@@ -374,7 +610,20 @@ export const FileBrowser: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<ul className={styles.folderTree} role="tree">
|
||||
<li>
|
||||
<li
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggedDrawingId) setDragOverFolderId('__root__');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
const related = e.relatedTarget as HTMLElement;
|
||||
const current = e.currentTarget as HTMLElement;
|
||||
if (related && current.contains(related)) return;
|
||||
setDragOverFolderId(null);
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, null)}
|
||||
className={dragOverFolderId === '__root__' ? styles.dragOver : ''}
|
||||
>
|
||||
<button
|
||||
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
|
||||
onClick={() => handleFolderClick(null)}
|
||||
@@ -386,16 +635,93 @@ export const FileBrowser: React.FC = () => {
|
||||
</button>
|
||||
</li>
|
||||
{folders.map((folder) => (
|
||||
<li key={folder.id}>
|
||||
<button
|
||||
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
|
||||
onClick={() => handleFolderClick(folder.id)}
|
||||
aria-current={activeFolderId === folder.id ? 'true' : undefined}
|
||||
role="treeitem"
|
||||
>
|
||||
<Folder size={18} aria-hidden="true" />
|
||||
<span>{folder.name}</span>
|
||||
</button>
|
||||
<li
|
||||
key={folder.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, folder.id)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if ((draggedFolderId && draggedFolderId !== folder.id) || (draggedDrawingId && draggedDrawingId !== folder.id)) {
|
||||
setDragOverFolderId(folder.id);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => handleDragLeave(e)}
|
||||
onDrop={(e) => handleDrop(e, folder.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={dragOverFolderId === folder.id ? styles.dragOver : ''}
|
||||
>
|
||||
{renamingId === folder.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
className={styles.renameInput}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameFolder(folder);
|
||||
if (e.key === 'Escape') setRenamingId(null);
|
||||
}}
|
||||
onBlur={() => handleRenameFolder(folder)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''} ${draggedFolderId === folder.id ? styles.dragging : ''}`}
|
||||
onClick={() => handleFolderClick(folder.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setFolderMenuId(folder.id);
|
||||
}}
|
||||
aria-current={activeFolderId === folder.id ? 'true' : undefined}
|
||||
role="treeitem"
|
||||
>
|
||||
<span
|
||||
className={styles.dragHandleWrapper}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<GripVertical size={14} className={styles.dragHandle} />
|
||||
</span>
|
||||
<Folder size={18} aria-hidden="true" />
|
||||
<span>{folder.name}</span>
|
||||
<button
|
||||
className={styles.folderMenuBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFolderMenuId(folderMenuId === folder.id ? null : folder.id);
|
||||
}}
|
||||
aria-label="Folder options"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
</button>
|
||||
)}
|
||||
{folderMenuId === folder.id && (
|
||||
<div className={styles.folderMenu} ref={folderMenuRef}>
|
||||
<button
|
||||
className={styles.folderMenuItem}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenamingId(folder.id);
|
||||
setRenameValue(folder.name);
|
||||
setFolderMenuId(null);
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.folderMenuItem} ${styles.folderMenuDanger}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFolder(folder);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -410,93 +736,99 @@ export const FileBrowser: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleDrawings.map((drawing) => (
|
||||
<Card
|
||||
key={drawing.id}
|
||||
className={styles.drawingCard}
|
||||
hover
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
onClick={() => handleDrawingClick(drawing)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleDrawingClick(drawing);
|
||||
}
|
||||
}}
|
||||
aria-label={`Open drawing ${drawing.title}`}
|
||||
>
|
||||
<div className={styles.thumbnail}>
|
||||
{drawing.thumbnail_url ? (
|
||||
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
|
||||
) : (
|
||||
<img
|
||||
src={`/api/drawings/${drawing.id}/thumbnail`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
{renamingId === drawing.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
className={styles.renameInput}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameDrawing(drawing);
|
||||
if (e.key === 'Escape') setRenamingId(null);
|
||||
}}
|
||||
onBlur={() => handleRenameDrawing(drawing)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h4 className={styles.title}>{drawing.title}</h4>
|
||||
<p className={styles.meta}>
|
||||
Edited {new Date(drawing.updated_at).toLocaleDateString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
|
||||
visibleDrawings.map((drawing) => {
|
||||
const isSelected = selectedIds.has(drawing.id);
|
||||
return (
|
||||
<Card
|
||||
key={drawing.id}
|
||||
className={`${styles.drawingCard} ${isSelected ? styles.drawingSelected : ''}`}
|
||||
hover
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(e) => handleDrawingDragStart(e as unknown as React.DragEvent, drawing.id)}
|
||||
onDragEnd={handleDrawingDragEnd}
|
||||
onClick={() => handleDrawingClick(drawing)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleDrawingClick(drawing);
|
||||
}
|
||||
}}
|
||||
aria-label={`Open drawing ${drawing.title}`}
|
||||
>
|
||||
<button
|
||||
className={styles.more}
|
||||
className={styles.selectBox}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
|
||||
setRenamingId(null);
|
||||
toggleSelect(drawing.id);
|
||||
}}
|
||||
aria-label={`More options for ${drawing.title}`}
|
||||
aria-expanded={activeMenu === drawing.id}
|
||||
aria-label={isSelected ? 'Deselect' : 'Select'}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
{isSelected ? <SquareCheck size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
{activeMenu === drawing.id && (
|
||||
<div className={styles.dropdown}>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
|
||||
{movingId === drawing.id ? (
|
||||
<div className={styles.dropdownSubmenu}>
|
||||
<button className={styles.dropdownSubheader}>Move to:</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
|
||||
{folders.map(f => (
|
||||
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
|
||||
))}
|
||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
|
||||
)}
|
||||
<div className={styles.dropdownDivider} />
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
<div className={styles.thumbnail}>
|
||||
{drawing.thumbnail_url ? (
|
||||
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
|
||||
) : (
|
||||
<img
|
||||
src={`/api/drawings/${drawing.id}/thumbnail`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
{renamingId === drawing.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
className={styles.renameInput}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameDrawing(drawing);
|
||||
if (e.key === 'Escape') setRenamingId(null);
|
||||
}}
|
||||
onBlur={() => handleRenameDrawing(drawing)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h4 className={styles.title}>{drawing.title}</h4>
|
||||
<p className={styles.meta}>
|
||||
Edited {new Date(drawing.updated_at).toLocaleDateString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
|
||||
<button
|
||||
className={styles.more}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
|
||||
setRenamingId(null);
|
||||
}}
|
||||
aria-label={`More options for ${drawing.title}`}
|
||||
aria-expanded={activeMenu === drawing.id}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{activeMenu === drawing.id && (
|
||||
<div className={styles.dropdown}>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); setMoveModalDrawing(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Move to...</button>
|
||||
<div className={styles.dropdownDivider} />
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
@@ -530,6 +862,80 @@ export const FileBrowser: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{moveModalDrawing && (
|
||||
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="move-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setMoveModalDrawing(null); }}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h3 id="move-drawing-title">Move "{moveModalDrawing.title}"</h3>
|
||||
<button className={styles.modalClose} onClick={() => setMoveModalDrawing(null)} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className={styles.modalBody}>
|
||||
<p className={styles.moveHint}>Select a destination:</p>
|
||||
<div className={styles.moveList}>
|
||||
<button
|
||||
className={`${styles.moveItem} ${moveModalDrawing.folder_id === null ? styles.moveItemActive : ''}`}
|
||||
onClick={() => handleMoveDrawing(moveModalDrawing, null)}
|
||||
>
|
||||
<Folder size={18} />
|
||||
<span>All Projects</span>
|
||||
{moveModalDrawing.folder_id === null && <span className={styles.moveCurrent}>Current</span>}
|
||||
</button>
|
||||
{folders.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`${styles.moveItem} ${moveModalDrawing.folder_id === f.id ? styles.moveItemActive : ''}`}
|
||||
onClick={() => handleMoveDrawing(moveModalDrawing, f.id)}
|
||||
>
|
||||
<Folder size={18} />
|
||||
<span>{f.name}</span>
|
||||
{moveModalDrawing.folder_id === f.id && <span className={styles.moveCurrent}>Current</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<button className={styles.modalBtnSecondary} onClick={() => setMoveModalDrawing(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchMoveOpen && (
|
||||
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="batch-move-title" onClick={(e) => { if (e.target === e.currentTarget) setBatchMoveOpen(false); }}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h3 id="batch-move-title">Move {selectedIds.size} drawing(s)</h3>
|
||||
<button className={styles.modalClose} onClick={() => setBatchMoveOpen(false)} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className={styles.modalBody}>
|
||||
<p className={styles.moveHint}>Select a destination:</p>
|
||||
<div className={styles.moveList}>
|
||||
<button
|
||||
className={styles.moveItem}
|
||||
onClick={() => handleBatchMove(null)}
|
||||
>
|
||||
<Folder size={18} />
|
||||
<span>All Projects</span>
|
||||
</button>
|
||||
{folders.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={styles.moveItem}
|
||||
onClick={() => handleBatchMove(f.id)}
|
||||
>
|
||||
<Folder size={18} />
|
||||
<span>{f.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<button className={styles.modalBtnSecondary} onClick={() => setBatchMoveOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,17 +9,16 @@
|
||||
margin-bottom: var(--space-8);
|
||||
padding: var(--space-5);
|
||||
background: var(--island-bg-color);
|
||||
border: 2px solid var(--color-gray-85);
|
||||
border-radius: 2px;
|
||||
box-shadow: 4px 4px 0 var(--color-gray-85);
|
||||
transform: rotate(0.1deg);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-gray-85);
|
||||
margin-bottom: var(--space-2);
|
||||
font-family: 'Georgia', serif;
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +44,10 @@
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: 2px;
|
||||
border-radius: var(--border-radius-lg);
|
||||
color: var(--color-gray-70);
|
||||
background: none;
|
||||
border: 2px solid transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
@@ -57,17 +56,14 @@
|
||||
&:hover {
|
||||
background: var(--color-surface-low);
|
||||
color: var(--color-on-surface);
|
||||
border-color: var(--color-gray-30);
|
||||
transform: rotate(-0.2deg);
|
||||
border-color: var(--default-border-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-surface-primary-container);
|
||||
color: var(--color-primary-darkest);
|
||||
font-weight: 600;
|
||||
border-color: var(--color-gray-85);
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
transform: rotate(-0.1deg);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +92,8 @@
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--color-gray-85);
|
||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
||||
border: 1px solid var(--default-border-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@@ -151,26 +147,24 @@
|
||||
|
||||
.themeOption {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 2px solid var(--color-gray-30);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: var(--island-bg-color);
|
||||
color: var(--color-gray-70);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
box-shadow: 2px 2px 0 var(--color-gray-30);
|
||||
box-shadow: var(--shadow-island);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
transform: translate(-1px, -1px);
|
||||
box-shadow: 3px 3px 0 var(--color-primary);
|
||||
box-shadow: var(--shadow-island-stronger);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-gray-85);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]> =>
|
||||
@@ -58,6 +61,12 @@ export const api = {
|
||||
list: (): Promise<Folder[]> => fetchApi('/folders'),
|
||||
create: (data: object): Promise<Folder> =>
|
||||
fetchApi('/folders', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: string, data: object): Promise<Folder> =>
|
||||
fetchApi(`/folders/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
delete: (id: string): Promise<void> =>
|
||||
fetchApi(`/folders/${id}`, { method: 'DELETE' }),
|
||||
reorder: (folderIds: string[]): Promise<Folder[]> =>
|
||||
fetchApi('/folders/reorder', { method: 'POST', body: JSON.stringify({ folder_ids: folderIds }) }),
|
||||
},
|
||||
teams: {
|
||||
list: (): Promise<Team[]> => fetchApi('/teams'),
|
||||
|
||||
@@ -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 |
|
||||
```
|
||||
@@ -27,5 +27,17 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'excalidraw': ['@excalidraw/excalidraw'],
|
||||
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||
'ui-vendor': ['lucide-react', 'clsx'],
|
||||
'i18n': ['i18next', 'react-i18next', 'i18next-browser-languagedetector'],
|
||||
'state': ['zustand'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE workspace_folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX idx_workspace_folders_sort_order ON workspace_folders(team_id, sort_order);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_workspace_folders_sort_order;
|
||||
ALTER TABLE workspace_folders DROP COLUMN IF EXISTS sort_order;
|
||||
+98
-9
@@ -56,6 +56,7 @@ func (a *API) Routes() chi.Router {
|
||||
r.Post("/drawings", a.handleCreateDrawing)
|
||||
r.Get("/drawings/{drawingID}", a.handleGetDrawing)
|
||||
r.Patch("/drawings/{drawingID}", a.handleUpdateDrawing)
|
||||
r.Patch("/drawings/{drawingID}/autosave", a.handleAutosaveDrawing)
|
||||
r.Delete("/drawings/{drawingID}", a.handleArchiveDrawing)
|
||||
r.Get("/drawings/{drawingID}/revisions", a.handleListRevisions)
|
||||
r.Post("/drawings/{drawingID}/revisions", a.handleCreateRevision)
|
||||
@@ -78,6 +79,9 @@ func (a *API) Routes() chi.Router {
|
||||
r.Get("/stats", a.handleStats)
|
||||
r.Get("/folders", a.handleListFolders)
|
||||
r.Post("/folders", a.handleCreateFolder)
|
||||
r.Patch("/folders/{folderID}", a.handleUpdateFolder)
|
||||
r.Delete("/folders/{folderID}", a.handleDeleteFolder)
|
||||
r.Post("/folders/reorder", a.handleReorderFolders)
|
||||
r.Get("/projects", a.handleListProjects)
|
||||
r.Post("/projects", a.handleCreateProject)
|
||||
r.Get("/notifications", a.handleListNotifications)
|
||||
@@ -134,6 +138,14 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// If the request carries a valid session cookie, it has already been
|
||||
// authenticated by requireSession middleware. The SameSite=Lax cookie
|
||||
// attribute provides sufficient CSRF protection for same-site requests,
|
||||
// so we trust authenticated mutations without a strict Origin check.
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -150,17 +162,34 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
|
||||
proto = "https"
|
||||
}
|
||||
expected := proto + "://" + host
|
||||
if origin != expected {
|
||||
// also allow without port in case proxy strips it
|
||||
expectedNoPort := proto + "://" + strings.SplitN(host, ":", 2)[0]
|
||||
originNoPort := strings.SplitN(origin, "://", 2)[1]
|
||||
originNoPort = strings.SplitN(originNoPort, ":", 2)[0]
|
||||
if originNoPort != expectedNoPort {
|
||||
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
|
||||
if origin == expected {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// allow without port in case proxy strips it
|
||||
expectedHost := strings.SplitN(host, ":", 2)[0]
|
||||
originHost := ""
|
||||
if parts := strings.SplitN(origin, "://", 2); len(parts) == 2 {
|
||||
originHost = strings.SplitN(parts[1], ":", 2)[0]
|
||||
}
|
||||
if originHost != "" && originHost == expectedHost {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// fallback: check Referer hostname matches
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer != "" {
|
||||
refHost := ""
|
||||
if parts := strings.SplitN(referer, "://", 2); len(parts) == 2 {
|
||||
refHost = strings.SplitN(parts[1], "/", 2)[0]
|
||||
refHost = strings.SplitN(refHost, ":", 2)[0]
|
||||
}
|
||||
if refHost != "" && refHost == expectedHost {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -356,6 +385,21 @@ func (a *API) handleUpdateDrawing(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, drawing)
|
||||
}
|
||||
|
||||
func (a *API) handleAutosaveDrawing(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req struct {
|
||||
Snapshot json.RawMessage `json:"snapshot"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req, 10<<20) {
|
||||
return
|
||||
}
|
||||
if err := a.store.AutosaveDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req.Snapshot); err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) handleArchiveDrawing(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
if err := a.store.ArchiveDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID")); err != nil {
|
||||
@@ -584,6 +628,51 @@ func (a *API) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, folder)
|
||||
}
|
||||
|
||||
func (a *API) handleUpdateFolder(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
folderID := chi.URLParam(r, "folderID")
|
||||
var req UpdateFolderRequest
|
||||
if !decodeJSON(w, r, &req, 128<<10) {
|
||||
return
|
||||
}
|
||||
folder, err := a.store.UpdateFolder(r.Context(), user.ID, folderID, req)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, folder)
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
folderID := chi.URLParam(r, "folderID")
|
||||
if err := a.store.DeleteFolder(r.Context(), user.ID, folderID); err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) handleReorderFolders(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req struct {
|
||||
FolderIDs []string `json:"folder_ids"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req, 128<<10) {
|
||||
return
|
||||
}
|
||||
if err := a.store.ReorderFolders(r.Context(), user.ID, req.FolderIDs); err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
folders, err := a.store.ListFolders(r.Context(), user.ID, "")
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, folders)
|
||||
}
|
||||
|
||||
func (a *API) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||
@@ -639,7 +728,7 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any, limit int64) bo
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
// Allow unknown fields so frontend can send extra data without breaking
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return false
|
||||
|
||||
+184
-13
@@ -40,12 +40,33 @@ type CreateDrawingRequest struct {
|
||||
Snapshot json.RawMessage `json:"snapshot"`
|
||||
}
|
||||
|
||||
// NullString distinguishes JSON null (Valid=true, Value=nil) from absent field (Valid=false).
|
||||
type NullString struct {
|
||||
Value *string
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func (n *NullString) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
n.Value = nil
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
n.Value = &s
|
||||
n.Valid = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateDrawingRequest struct {
|
||||
FolderID *string `json:"folder_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Visibility *string `json:"visibility"`
|
||||
FolderID NullString `json:"folder_id"`
|
||||
ProjectID NullString `json:"project_id"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Visibility *string `json:"visibility"`
|
||||
}
|
||||
|
||||
type CreateRevisionRequest struct {
|
||||
@@ -61,6 +82,11 @@ type CreateFolderRequest struct {
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
type UpdateFolderRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Visibility *string `json:"visibility"`
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
TeamID string `json:"team_id"`
|
||||
Name string `json:"name"`
|
||||
@@ -616,11 +642,11 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
|
||||
}
|
||||
current.Visibility = *req.Visibility
|
||||
}
|
||||
if req.FolderID != nil {
|
||||
current.FolderID = req.FolderID
|
||||
if req.FolderID.Valid {
|
||||
current.FolderID = req.FolderID.Value
|
||||
}
|
||||
if req.ProjectID != nil {
|
||||
current.ProjectID = req.ProjectID
|
||||
if req.ProjectID.Valid {
|
||||
current.ProjectID = req.ProjectID.Value
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings
|
||||
@@ -635,6 +661,47 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
|
||||
return s.GetDrawing(ctx, userID, drawingID)
|
||||
}
|
||||
|
||||
func (s *Store) AutosaveDrawing(ctx context.Context, userID, drawingID string, snapshot json.RawMessage) error {
|
||||
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(snapshot) == 0 || !json.Valid(snapshot) {
|
||||
return fmt.Errorf("snapshot must be valid JSON")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE workspace_drawings SET updated_at = ? WHERE id = ?`, now, drawingID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Upsert the latest revision snapshot directly without creating a new revision entry
|
||||
var existingRevID string
|
||||
var revNumber int
|
||||
err = s.db.QueryRowContext(ctx, `SELECT id, revision_number FROM workspace_drawing_revisions WHERE drawing_id = ? ORDER BY revision_number DESC LIMIT 1`, drawingID).Scan(&existingRevID, &revNumber)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Create initial revision if none exists
|
||||
revID := newID()
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO workspace_drawing_revisions
|
||||
(id, drawing_id, revision_number, snapshot_path, snapshot_size, content_hash, snapshot_json, created_by, created_at, change_summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
revID, drawingID, 1, fmt.Sprintf("teams/drawings/%s/revisions/1.json", drawingID), int64(len(snapshot)),
|
||||
func() string { sum := sha256.Sum256(snapshot); return hex.EncodeToString(sum[:]) }(),
|
||||
[]byte(snapshot), userID, now, "Auto-save",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings SET latest_revision_id = ?, updated_at = ? WHERE id = ?`, revID, now, drawingID)
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Update existing latest revision snapshot
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawing_revisions SET snapshot_json = ?, snapshot_size = ?, content_hash = ?, updated_at = ? WHERE id = ?`,
|
||||
[]byte(snapshot), int64(len(snapshot)), func() string { sum := sha256.Sum256(snapshot); return hex.EncodeToString(sum[:]) }(), now, existingRevID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ArchiveDrawing(ctx context.Context, userID, drawingID string) error {
|
||||
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
|
||||
return err
|
||||
@@ -881,7 +948,7 @@ func (s *Store) ListFolders(ctx context.Context, userID, teamID string) ([]Folde
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at
|
||||
FROM workspace_folders WHERE team_id = ? ORDER BY path_cache ASC`, teamID)
|
||||
FROM workspace_folders WHERE team_id = ? ORDER BY sort_order ASC, created_at ASC`, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -931,10 +998,12 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
var maxOrder int
|
||||
s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) + 1 FROM workspace_folders WHERE team_id = ?`, teamID).Scan(&maxOrder)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_folders
|
||||
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt,
|
||||
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt, maxOrder,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -942,6 +1011,108 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateFolder(ctx context.Context, userID, folderID string, req UpdateFolderRequest) (*Folder, error) {
|
||||
var teamID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
var updates []string
|
||||
var args []any
|
||||
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" || len(name) > 120 {
|
||||
return nil, fmt.Errorf("folder name must be between 1 and 120 characters")
|
||||
}
|
||||
updates = append(updates, "name = ?")
|
||||
args = append(args, name)
|
||||
updates = append(updates, "slug = ?")
|
||||
args = append(args, slugify(name))
|
||||
updates = append(updates, "path_cache = ?")
|
||||
args = append(args, slugify(name))
|
||||
}
|
||||
if req.Visibility != nil {
|
||||
updates = append(updates, "visibility = ?")
|
||||
args = append(args, *req.Visibility)
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return s.GetFolder(ctx, folderID)
|
||||
}
|
||||
|
||||
updates = append(updates, "updated_at = ?")
|
||||
args = append(args, time.Now().UTC())
|
||||
args = append(args, folderID)
|
||||
|
||||
query := "UPDATE workspace_folders SET " + strings.Join(updates, ", ") + " WHERE id = ?"
|
||||
_, err = s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetFolder(ctx, folderID)
|
||||
}
|
||||
|
||||
func (s *Store) GetFolder(ctx context.Context, folderID string) (*Folder, error) {
|
||||
var folder Folder
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at FROM workspace_folders WHERE id = ?`, folderID).Scan(
|
||||
&folder.ID, &folder.TeamID, &folder.ProjectID, &folder.ParentFolderID, &folder.Name, &folder.Slug, &folder.PathCache, &folder.Visibility, &folder.CreatedBy, &folder.CreatedAt, &folder.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &folder, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFolder(ctx context.Context, userID, folderID string) error {
|
||||
var teamID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return ErrForbidden
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `DELETE FROM workspace_folders WHERE id = ?`, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ReorderFolders(ctx context.Context, userID string, folderIDs []string) error {
|
||||
if len(folderIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var teamID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderIDs[0]).Scan(&teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return ErrForbidden
|
||||
}
|
||||
for i, id := range folderIDs {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE workspace_folders SET sort_order = ? WHERE id = ? AND team_id = ?`, i, id, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListProjects(ctx context.Context, userID, teamID string) ([]Project, error) {
|
||||
if teamID == "" {
|
||||
teamID, _ = s.defaultTeamID(ctx, userID)
|
||||
|
||||
Reference in New Issue
Block a user