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
|
id: image
|
||||||
run: echo "repository=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
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
|
- name: Use GitHub token for GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
+1
-1
Submodule excalidraw updated: 278cd35772...f6d85bc80f
@@ -10,15 +10,14 @@
|
|||||||
/* Excalidraw Context Menu Styling Overrides */
|
/* Excalidraw Context Menu Styling Overrides */
|
||||||
:global(.excalidraw .context-menu) {
|
:global(.excalidraw .context-menu) {
|
||||||
background: var(--island-bg-color) !important;
|
background: var(--island-bg-color) !important;
|
||||||
border: 2px solid var(--color-gray-85) !important;
|
border: 1px solid var(--default-border-color) !important;
|
||||||
border-radius: 2px !important;
|
border-radius: var(--border-radius-lg) !important;
|
||||||
box-shadow: 4px 4px 0 var(--color-gray-85) !important;
|
box-shadow: var(--shadow-island-stronger) !important;
|
||||||
transform: rotate(-0.2deg) !important;
|
|
||||||
padding: 2px !important;
|
padding: 2px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.excalidraw .context-menu-item) {
|
:global(.excalidraw .context-menu-item) {
|
||||||
border-radius: 2px !important;
|
border-radius: var(--border-radius-md) !important;
|
||||||
color: var(--color-gray-85) !important;
|
color: var(--color-gray-85) !important;
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
padding: 6px 12px !important;
|
padding: 6px 12px !important;
|
||||||
@@ -27,11 +26,10 @@
|
|||||||
:global(.excalidraw .context-menu-item:hover) {
|
:global(.excalidraw .context-menu-item:hover) {
|
||||||
background: var(--color-primary-light) !important;
|
background: var(--color-primary-light) !important;
|
||||||
color: var(--color-primary-darkest) !important;
|
color: var(--color-primary-darkest) !important;
|
||||||
transform: translateX(1px) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.excalidraw .context-menu-item-separator) {
|
: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;
|
margin: 2px 4px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border-right: 2px solid var(--color-gray-85);
|
border-right: 1px solid var(--default-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
@@ -18,15 +18,6 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
transition: transform var(--duration-normal) var(--ease-out);
|
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) {
|
@media (max-width: 768px) {
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
@@ -81,17 +72,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logoImg {
|
.logoImg {
|
||||||
width: 28px;
|
width: auto;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.1));
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoMark {
|
.logoMark {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 9px;
|
border-radius: var(--border-radius-md);
|
||||||
color: var(--color-gray-85);
|
color: var(--color-gray-85);
|
||||||
background: var(--color-primary-light);
|
background: var(--color-primary-light);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -99,7 +90,6 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
transform: rotate(-4deg);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,25 +131,22 @@
|
|||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
color: var(--color-gray-70);
|
color: var(--color-gray-70);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: 2px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
transition: all var(--duration-fast) var(--ease-out);
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
border-color: var(--color-gray-30);
|
border-color: var(--default-border-color);
|
||||||
transform: rotate(-0.5deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--color-surface-primary-container);
|
background: var(--color-surface-primary-container);
|
||||||
color: var(--color-primary-darkest);
|
color: var(--color-primary-darkest);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-color: var(--color-gray-85);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
|
||||||
transform: rotate(-0.3deg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,8 +223,7 @@
|
|||||||
.header {
|
.header {
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border-bottom: 2px solid var(--color-gray-85);
|
border-bottom: 1px solid var(--default-border-color);
|
||||||
box-shadow: 0 3px 0 var(--color-gray-85);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -297,11 +283,11 @@
|
|||||||
.iconButton {
|
.iconButton {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: none;
|
background: none;
|
||||||
border: 2px solid transparent;
|
border: 1px solid transparent;
|
||||||
color: var(--color-gray-60);
|
color: var(--color-gray-60);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -310,9 +296,7 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
border-color: var(--color-gray-30);
|
border-color: var(--default-border-color);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
|
||||||
transform: rotate(-1deg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,8 +380,8 @@
|
|||||||
|
|
||||||
.nameModal {
|
.nameModal {
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: var(--modal-shadow);
|
box-shadow: var(--modal-shadow);
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
width: 360px;
|
width: 360px;
|
||||||
@@ -483,14 +467,13 @@
|
|||||||
top: calc(100% + var(--space-2));
|
top: calc(100% + var(--space-2));
|
||||||
right: 100px;
|
right: 100px;
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 5px 5px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
width: 320px;
|
width: 320px;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
transform: rotate(-0.2deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifHeader {
|
.notifHeader {
|
||||||
@@ -498,14 +481,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-bottom: 2px solid var(--color-gray-85);
|
border-bottom: 1px solid var(--default-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifTitle {
|
.notifTitle {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-gray-85);
|
color: var(--color-gray-85);
|
||||||
font-family: 'Georgia', serif;
|
font-family: var(--ui-font);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifMarkAll {
|
.notifMarkAll {
|
||||||
|
|||||||
@@ -37,14 +37,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ open, onClose }) => {
|
|||||||
>
|
>
|
||||||
<div className={styles.sidebarHeader}>
|
<div className={styles.sidebarHeader}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img
|
<svg viewBox="0 0 120 28" className={styles.logoImg} aria-label="Excalidraw+">
|
||||||
src="https://plus.excalidraw.com/images/logo.svg"
|
<text x="0" y="22" fontFamily="Virgil, Segoe UI Emoji, sans-serif" fontSize="20" fontWeight="700" fill="#ffffff">Excalidraw</text>
|
||||||
alt="Excalidraw"
|
<text x="96" y="22" fontFamily="Virgil, Segoe UI Emoji, sans-serif" fontSize="20" fontWeight="700" fill="#ffffff" opacity="0.7">+</text>
|
||||||
className={styles.logoImg}
|
</svg>
|
||||||
width={28}
|
|
||||||
height={28}
|
|
||||||
/>
|
|
||||||
<span className={styles.logoText}>Excalidraw</span>
|
|
||||||
</div>
|
</div>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -13,15 +13,14 @@
|
|||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 8px 8px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
transform: rotate(-0.1deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -42,17 +41,16 @@
|
|||||||
|
|
||||||
.closeBtn {
|
.closeBtn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 2px solid transparent;
|
border: 1px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-gray-60);
|
color: var(--color-gray-60);
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--color-gray-85);
|
border-color: var(--default-border-color);
|
||||||
color: var(--color-gray-90);
|
color: var(--color-gray-90);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
background: var(--color-surface-low);
|
||||||
transform: rotate(-1deg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,21 +67,20 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-6) var(--space-4);
|
padding: var(--space-6) var(--space-4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
transition: all var(--duration-fast);
|
transition: all var(--duration-fast);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
transform: translateY(-2px) rotate(-0.3deg);
|
transform: translateY(-2px);
|
||||||
box-shadow: 4px 4px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: translateY(0) rotate(0);
|
transform: translateY(0);
|
||||||
box-shadow: 1px 1px 0 var(--color-gray-85);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
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 { Card } from '@/components';
|
||||||
import styles from './TemplatePicker.module.scss';
|
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 {
|
interface TemplatePickerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -21,7 +21,7 @@ interface TemplateOption {
|
|||||||
elements: RawElement[];
|
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 {
|
return {
|
||||||
id: `el-${Math.random().toString(36).slice(2)}`,
|
id: `el-${Math.random().toString(36).slice(2)}`,
|
||||||
type: 'rectangle',
|
type: 'rectangle',
|
||||||
@@ -41,15 +41,15 @@ function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: st
|
|||||||
version: 2,
|
version: 2,
|
||||||
versionNonce: Math.floor(Math.random() * 100000),
|
versionNonce: Math.floor(Math.random() * 100000),
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
boundElements: text ? [{ id: `txt-${Math.random().toString(36).slice(2)}`, type: 'text' }] : [],
|
boundElements: [],
|
||||||
updated: Date.now(),
|
updated: Date.now(),
|
||||||
link: null,
|
link: null,
|
||||||
locked: false,
|
locked: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeText(x: number, y: number, text: string, fontSize = 20) {
|
function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: string, customData?: Record<string, unknown>) {
|
||||||
return {
|
const el: RawElement = {
|
||||||
id: `txt-${Math.random().toString(36).slice(2)}`,
|
id: `txt-${Math.random().toString(36).slice(2)}`,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
x, y, width: text.length * (fontSize * 0.55), height: fontSize * 1.4,
|
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',
|
strokeStyle: 'solid',
|
||||||
roughness: 1,
|
roughness: 1,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
groupIds: [],
|
groupIds: groupId ? [groupId] : [],
|
||||||
frameId: null,
|
frameId: null,
|
||||||
roundness: null,
|
roundness: null,
|
||||||
seed: Math.floor(Math.random() * 10000),
|
seed: Math.floor(Math.random() * 10000),
|
||||||
@@ -77,11 +77,15 @@ function makeText(x: number, y: number, text: string, fontSize = 20) {
|
|||||||
fontFamily: 1,
|
fontFamily: 1,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
baseline: 18,
|
baseline: Math.round(fontSize * 0.7),
|
||||||
containerId: null,
|
containerId: null,
|
||||||
originalText: text,
|
originalText: text,
|
||||||
lineHeight: 1.25,
|
lineHeight: 1.25,
|
||||||
};
|
};
|
||||||
|
if (customData) {
|
||||||
|
el.customData = customData;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCheckbox(x: number, y: number, checked = false) {
|
function makeCheckbox(x: number, y: number, checked = false) {
|
||||||
@@ -158,7 +162,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeCheckbox(60, 210, false),
|
makeCheckbox(60, 210, false),
|
||||||
makeText(90, 210, 'Third task'),
|
makeText(90, 210, 'Third task'),
|
||||||
makeAddButton(60, 250, '+', 'todo-add'),
|
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),
|
makeHandDrawnRect(50, 290, 500, 2),
|
||||||
makeText(60, 310, 'Notes:', 18),
|
makeText(60, 310, 'Notes:', 18),
|
||||||
],
|
],
|
||||||
@@ -172,7 +176,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeCheckbox(60, 210, false),
|
makeCheckbox(60, 210, false),
|
||||||
makeText(90, 210, 'Another task', 18),
|
makeText(90, 210, 'Another task', 18),
|
||||||
makeAddButton(60, 250, '+', 'checklist-add'),
|
makeAddButton(60, 250, '+', 'checklist-add'),
|
||||||
makeText(92, 250, 'Add item...', 16),
|
makeText(92, 250, 'Add item...', 16, undefined, { templateRole: 'checklist-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
list: [
|
list: [
|
||||||
makeHandDrawnRect(50, 50, 500, 50),
|
makeHandDrawnRect(50, 50, 500, 50),
|
||||||
@@ -182,7 +186,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeText(60, 210, '• Third bullet point'),
|
makeText(60, 210, '• Third bullet point'),
|
||||||
makeText(60, 250, '• Fourth item with details'),
|
makeText(60, 250, '• Fourth item with details'),
|
||||||
makeAddButton(60, 290, '+', 'list-add'),
|
makeAddButton(60, 290, '+', 'list-add'),
|
||||||
makeText(92, 290, 'Add bullet...', 16),
|
makeText(92, 290, 'Add bullet...', 16, undefined, { templateRole: 'list-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
flow: [
|
flow: [
|
||||||
makeHandDrawnRect(200, 50, 200, 60),
|
makeHandDrawnRect(200, 50, 200, 60),
|
||||||
@@ -197,7 +201,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(200, 350, 200, 60),
|
makeHandDrawnRect(200, 350, 200, 60),
|
||||||
makeText(230, 370, 'End', 20),
|
makeText(230, 370, 'End', 20),
|
||||||
makeAddButton(420, 180, '+', 'flow-add'),
|
makeAddButton(420, 180, '+', 'flow-add'),
|
||||||
makeText(452, 180, 'Add step', 14),
|
makeText(452, 180, 'Add step', 14, undefined, { templateRole: 'flow-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
kanban: [
|
kanban: [
|
||||||
makeText(50, 40, 'Kanban Board', 30),
|
makeText(50, 40, 'Kanban Board', 30),
|
||||||
@@ -208,14 +212,14 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeText(285, 120, 'Doing', 20),
|
makeText(285, 120, 'Doing', 20),
|
||||||
makeText(495, 120, 'Done', 20),
|
makeText(495, 120, 'Done', 20),
|
||||||
// Card 1 - grouped
|
// Card 1 - grouped
|
||||||
makeHandDrawnRect(70, 170, 140, 70, undefined, 'card1'),
|
makeHandDrawnRect(70, 170, 140, 70, 'card1'),
|
||||||
makeText(85, 190, 'User research', 16),
|
makeText(85, 190, 'User research', 16, 'card1'),
|
||||||
// Card 2 - grouped
|
// Card 2 - grouped
|
||||||
makeHandDrawnRect(280, 170, 140, 70, undefined, 'card2'),
|
makeHandDrawnRect(280, 170, 140, 70, 'card2'),
|
||||||
makeText(295, 190, 'Sketch flow', 16),
|
makeText(295, 190, 'Sketch flow', 16, 'card2'),
|
||||||
// Card 3 - grouped
|
// Card 3 - grouped
|
||||||
makeHandDrawnRect(490, 170, 140, 70, undefined, 'card3'),
|
makeHandDrawnRect(490, 170, 140, 70, 'card3'),
|
||||||
makeText(505, 190, 'Project brief', 16),
|
makeText(505, 190, 'Project brief', 16, 'card3'),
|
||||||
// Add card buttons per column
|
// Add card buttons per column
|
||||||
makeAddButton(110, 380, '+', 'kanban-add-backlog'),
|
makeAddButton(110, 380, '+', 'kanban-add-backlog'),
|
||||||
makeAddButton(320, 380, '+', 'kanban-add-doing'),
|
makeAddButton(320, 380, '+', 'kanban-add-doing'),
|
||||||
@@ -234,7 +238,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeCheckbox(70, 390, false),
|
makeCheckbox(70, 390, false),
|
||||||
makeText(105, 390, 'Owner and next step', 18),
|
makeText(105, 390, 'Owner and next step', 18),
|
||||||
makeAddButton(70, 430, '+', 'meeting-add-action'),
|
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: [
|
wireframe: [
|
||||||
makeText(50, 35, 'Page Wireframe', 30),
|
makeText(50, 35, 'Page Wireframe', 30),
|
||||||
@@ -248,7 +252,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(265, 380, 190, 110),
|
makeHandDrawnRect(265, 380, 190, 110),
|
||||||
makeHandDrawnRect(480, 380, 190, 110),
|
makeHandDrawnRect(480, 380, 190, 110),
|
||||||
makeAddButton(480, 500, '+', 'wireframe-add-section'),
|
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: [
|
mindmap: [
|
||||||
makeHandDrawnRect(240, 200, 200, 70),
|
makeHandDrawnRect(240, 200, 200, 70),
|
||||||
@@ -285,12 +289,173 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeText(520, 196, 'Idea 3', 18),
|
makeText(520, 196, 'Idea 3', 18),
|
||||||
makeArrow(460, 110, 580, 180),
|
makeArrow(460, 110, 580, 180),
|
||||||
makeAddButton(50, 240, '+', 'brainstorm-add'),
|
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
|
// Notes area
|
||||||
makeHandDrawnRect(50, 280, 610, 100),
|
makeHandDrawnRect(50, 280, 610, 100),
|
||||||
makeText(70, 300, 'Notes & connections:', 18),
|
makeText(70, 300, 'Notes & connections:', 18),
|
||||||
makeText(70, 330, '- Write insights here', 16),
|
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: [
|
retrospective: [
|
||||||
makeText(50, 30, 'Retrospective', 30),
|
makeText(50, 30, 'Retrospective', 30),
|
||||||
// Went Well
|
// Went Well
|
||||||
@@ -368,19 +533,19 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(50, 300, 600, 2),
|
makeHandDrawnRect(50, 300, 600, 2),
|
||||||
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
|
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
|
||||||
makeAddButton(50, 350, '+', 'storymap-add-row'),
|
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: [
|
timeline: [
|
||||||
makeText(50, 30, 'Project Timeline', 30),
|
makeText(50, 30, 'Project Timeline', 30),
|
||||||
makeHandDrawnRect(50, 90, 600, 4),
|
makeHandDrawnRect(50, 90, 600, 4),
|
||||||
// Milestones
|
// Milestones
|
||||||
makeHandDrawnRect(80, 70, 20, 44, undefined, 'milestone-1'),
|
makeHandDrawnRect(80, 70, 20, 44, 'milestone-1'),
|
||||||
makeText(60, 125, 'Q1 Kickoff', 14),
|
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),
|
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),
|
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),
|
makeText(480, 125, 'Launch', 14),
|
||||||
// Tasks below timeline
|
// Tasks below timeline
|
||||||
makeHandDrawnRect(50, 170, 130, 50),
|
makeHandDrawnRect(50, 170, 130, 50),
|
||||||
@@ -392,7 +557,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(500, 170, 130, 50),
|
makeHandDrawnRect(500, 170, 130, 50),
|
||||||
makeText(515, 185, 'Deploy', 14),
|
makeText(515, 185, 'Deploy', 14),
|
||||||
makeAddButton(80, 240, '+', 'timeline-add'),
|
makeAddButton(80, 240, '+', 'timeline-add'),
|
||||||
makeText(112, 240, 'Add phase...', 14),
|
makeText(112, 240, 'Add phase...', 14, undefined, { templateRole: 'timeline-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
architecture: [
|
architecture: [
|
||||||
makeText(50, 30, 'System Architecture', 30),
|
makeText(50, 30, 'System Architecture', 30),
|
||||||
@@ -419,7 +584,85 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(50, 200, 160, 70),
|
makeHandDrawnRect(50, 200, 160, 70),
|
||||||
makeText(90, 220, 'CDN', 18),
|
makeText(90, 220, 'CDN', 18),
|
||||||
makeAddButton(300, 290, '+', 'architecture-add'),
|
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: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: PanelsTopLeft, elements: [] },
|
||||||
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: GitFork, 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', 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: '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: '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: '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: 'timeline', label: 'Timeline', description: 'Project phases and milestones', icon: Timer, elements: [] },
|
||||||
{ id: 'architecture', label: 'Architecture', description: 'System components and connections', icon: Layers, 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 }) => {
|
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
|
||||||
|
|||||||
@@ -13,17 +13,15 @@
|
|||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
padding: var(--space-5) var(--space-6);
|
padding: var(--space-5) var(--space-6);
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 4px 4px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
transform: rotate(-0.3deg);
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-gray-85);
|
color: var(--color-gray-85);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
font-family: 'Georgia', serif;
|
font-family: var(--ui-font);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,21 +82,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statCardWrapper {
|
.statCardWrapper {
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
transform: rotate(0.15deg);
|
transition: all 0.2s var(--ease-out);
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
background: var(--island-bg-color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: rotate(0) translate(-1px, -1px);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
box-shadow: 5px 5px 0 var(--color-gray-85);
|
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 {
|
.statCard {
|
||||||
@@ -107,7 +100,9 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
min-height: 150px;
|
min-height: 140px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statTop {
|
.statTop {
|
||||||
@@ -115,69 +110,70 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statIcon {
|
.statIcon {
|
||||||
width: 40px;
|
width: 36px;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: var(--border-radius-lg);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--color-primary-light);
|
background: var(--color-surface-low);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--color-gray-20);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
opacity: 0.9;
|
||||||
transform: rotate(-2deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.handChart {
|
|
||||||
width: 80px;
|
|
||||||
height: 40px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transform: rotate(1deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sparkline {
|
|
||||||
width: 100%;
|
|
||||||
height: 28px;
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.statValue {
|
.statValue {
|
||||||
font-size: var(--text-3xl);
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-family: 'Georgia', serif;
|
font-family: var(--ui-font);
|
||||||
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statLabel {
|
.statLabel {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
margin-top: var(--space-1);
|
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;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
margin-top: var(--space-3);
|
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: var(--border-radius-full);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartBarBg {
|
.progressBarBg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: var(--color-gray-20);
|
background: var(--color-gray-20);
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: var(--border-radius-full);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartBar {
|
.progressBarFill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: var(--border-radius-full);
|
||||||
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-darkest));
|
|
||||||
transition: width 0.4s var(--ease-out);
|
transition: width 0.4s var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,22 +226,21 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3) var(--space-2);
|
padding: var(--space-3) var(--space-2);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
transform: translateX(2px) rotate(-0.3deg);
|
transform: translateX(2px);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: 2px solid var(--color-gray-30);
|
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,12 +248,12 @@
|
|||||||
.drawingThumb {
|
.drawingThumb {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -346,20 +341,26 @@
|
|||||||
|
|
||||||
.activityItem {
|
.activityItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3) 0;
|
padding: var(--space-3) var(--space-2);
|
||||||
border-bottom: 1px solid var(--color-gray-20);
|
border-bottom: 1px solid var(--color-gray-20);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-low);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityAvatar {
|
.activityAvatar {
|
||||||
width: 32px;
|
width: 34px;
|
||||||
height: 32px;
|
height: 34px;
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-full);
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -368,17 +369,19 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 2px solid var(--island-bg-color);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityInfo {
|
.activityInfo {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityText {
|
.activityText {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-gray-80);
|
color: var(--color-gray-80);
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activityTime {
|
.activityTime {
|
||||||
@@ -399,12 +402,11 @@
|
|||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 5px 5px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
width: 420px;
|
width: 420px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
transform: rotate(-0.3deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalHeader {
|
.modalHeader {
|
||||||
@@ -412,12 +414,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-4) var(--space-5);
|
padding: var(--space-4) var(--space-5);
|
||||||
border-bottom: 2px solid var(--color-gray-85);
|
border-bottom: 1px solid var(--default-border-color);
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
color: var(--color-gray-85);
|
color: var(--color-gray-85);
|
||||||
font-family: 'Georgia', serif;
|
font-family: var(--ui-font);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,15 +446,15 @@
|
|||||||
.modalInput {
|
.modalInput {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
background: var(--input-bg-color);
|
background: var(--input-bg-color);
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
outline: none;
|
outline: none;
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,25 +467,25 @@
|
|||||||
|
|
||||||
.modalBtnSecondary {
|
.modalBtnSecondary {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-gray-70);
|
color: var(--color-gray-70);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
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 {
|
.modalBtnPrimary {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
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; }
|
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,86 +9,15 @@ import styles from './Dashboard.module.scss';
|
|||||||
|
|
||||||
const ACTIVITY_LIMIT = 5;
|
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 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 (
|
return (
|
||||||
<svg className={styles.handChart} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
|
<div className={styles.statBarTrack} aria-hidden="true">
|
||||||
<path
|
<div
|
||||||
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`}
|
className={styles.statBarFill}
|
||||||
fill="none"
|
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||||
stroke="var(--color-gray-40)"
|
|
||||||
strokeWidth="1"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
/>
|
||||||
{pct > 0 && (
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,8 +27,6 @@ export const Dashboard: React.FC = () => {
|
|||||||
const { recentDrawings, setRecentDrawings, activity, setActivity } = useDrawingStore();
|
const { recentDrawings, setRecentDrawings, activity, setActivity } = useDrawingStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [showNameModal, setShowNameModal] = useState(false);
|
|
||||||
const [newDrawingName, setNewDrawingName] = useState('');
|
|
||||||
const [statsData, setStatsData] = useState({
|
const [statsData, setStatsData] = useState({
|
||||||
teams: 0,
|
teams: 0,
|
||||||
members: 0,
|
members: 0,
|
||||||
@@ -130,18 +57,11 @@ export const Dashboard: React.FC = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [setRecentDrawings, setActivity]);
|
}, [setRecentDrawings, setActivity]);
|
||||||
|
|
||||||
const handleCreateDrawing = () => {
|
const handleCreateDrawing = async () => {
|
||||||
setNewDrawingName('');
|
|
||||||
setShowNameModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmCreateDrawing = async () => {
|
|
||||||
const title = newDrawingName.trim() || 'Untitled Drawing';
|
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
setShowNameModal(false);
|
|
||||||
try {
|
try {
|
||||||
const newDrawing = await api.drawings.create({
|
const newDrawing = await api.drawings.create({
|
||||||
title,
|
title: 'Untitled Drawing',
|
||||||
visibility: 'team',
|
visibility: 'team',
|
||||||
});
|
});
|
||||||
setRecentDrawings([newDrawing, ...recentDrawings]);
|
setRecentDrawings([newDrawing, ...recentDrawings]);
|
||||||
@@ -165,13 +85,6 @@ export const Dashboard: React.FC = () => {
|
|||||||
const storageMax = Math.max(Number(statsData.storage_bytes), 1024 * 1024);
|
const storageMax = Math.max(Number(statsData.storage_bytes), 1024 * 1024);
|
||||||
|
|
||||||
const statColors = ['#6965db', '#339af0', '#40c057', '#fcc419', '#ff6b6b'];
|
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 = [
|
const stats = [
|
||||||
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, chartValue: statsData.drawings, max: maxStat, icon: FileText, color: statColors[0] },
|
{ 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>
|
||||||
|
|
||||||
<div className={styles.statsGrid}>
|
<div className={styles.statsGrid}>
|
||||||
{stats.map((stat, idx) => (
|
{stats.map((stat) => (
|
||||||
<Card key={stat.label} className={styles.statCardWrapper}>
|
<Card key={stat.label} className={styles.statCardWrapper}>
|
||||||
<CardContent className={styles.statCard}>
|
<CardContent className={styles.statCard}>
|
||||||
<div className={styles.statTop}>
|
<div className={styles.statTop}>
|
||||||
<div className={styles.statIcon} style={{ color: stat.color, borderColor: stat.color }}>
|
<div className={styles.statIcon} style={{ color: stat.color, borderColor: stat.color }}>
|
||||||
<stat.icon size={22} />
|
<stat.icon size={22} />
|
||||||
</div>
|
</div>
|
||||||
<HandDrawnChart value={stat.chartValue} max={stat.max} color={stat.color} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statValue} style={{ color: stat.color }}>{stat.value}</div>
|
<div className={styles.statValue} style={{ color: stat.color }}>{stat.value}</div>
|
||||||
<div className={styles.statLabel}>{stat.label}</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -342,35 +254,6 @@ export const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,13 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbarDivider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--default-border-color);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-gray-85);
|
color: var(--color-gray-85);
|
||||||
@@ -73,11 +80,46 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
:global(.excalidraw) {
|
:global(.excalidraw) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
.loadingCanvas {
|
||||||
@@ -390,11 +432,18 @@
|
|||||||
|
|
||||||
.presentationOverlay {
|
.presentationOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: 0;
|
||||||
right: 12px;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
pointer-events: auto;
|
pointer-events: none;
|
||||||
animation: presentationFadeIn 0.3s var(--ease-out);
|
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 {
|
@keyframes presentationFadeIn {
|
||||||
@@ -406,19 +455,85 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
background: var(--island-bg-color);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-xl);
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-3) var(--space-5);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
transform: rotate(-0.3deg);
|
margin-bottom: var(--space-4);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.presentationLabel {
|
.presentationLabel {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-gray-70);
|
color: var(--color-gray-70);
|
||||||
font-weight: 500;
|
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 {
|
.modalOverlay {
|
||||||
@@ -433,8 +548,8 @@
|
|||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: var(--modal-shadow);
|
box-shadow: var(--modal-shadow);
|
||||||
width: 420px;
|
width: 420px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
@@ -555,3 +670,59 @@
|
|||||||
z-index: 80;
|
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;
|
flex-wrap: wrap;
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 4px 4px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
transform: rotate(0.2deg);
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -115,9 +114,9 @@
|
|||||||
width: 240px;
|
width: 240px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
|
||||||
@@ -133,6 +132,10 @@
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.folderItem {
|
.folderItem {
|
||||||
@@ -140,30 +143,32 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
color: var(--color-gray-70);
|
color: var(--color-gray-70);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--duration-fast) var(--ease-out);
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
background: none;
|
background: none;
|
||||||
border: 2px solid transparent;
|
border: 1px solid transparent;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
border-color: var(--color-gray-30);
|
border-color: var(--default-border-color);
|
||||||
transform: rotate(-0.3deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.folderActive {
|
&.folderActive {
|
||||||
background: var(--color-surface-primary-container);
|
background: var(--color-surface-primary-container);
|
||||||
color: var(--color-primary-darkest);
|
color: var(--color-primary-darkest);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-color: var(--color-gray-85);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
}
|
||||||
transform: rotate(-0.2deg);
|
|
||||||
|
&.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
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 {
|
.grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -228,15 +324,13 @@
|
|||||||
|
|
||||||
.drawingCard {
|
.drawingCard {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
transform: rotate(0.1deg);
|
transition: box-shadow 0.15s ease;
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: rotate(0) translate(-1px, -1px);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
box-shadow: 5px 5px 0 var(--color-gray-85);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,9 +406,9 @@
|
|||||||
top: calc(100% + var(--space-1));
|
top: calc(100% + var(--space-1));
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -328,7 +422,7 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-md);
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|
||||||
@@ -351,17 +445,88 @@
|
|||||||
margin: var(--space-1) 0;
|
margin: var(--space-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownSubmenu {
|
.batchBar {
|
||||||
display: flex;
|
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);
|
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);
|
color: var(--color-muted);
|
||||||
text-transform: uppercase;
|
display: flex;
|
||||||
letter-spacing: 0.05em;
|
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 {
|
.newProjectForm {
|
||||||
@@ -371,58 +536,55 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
|
|
||||||
.newProjectInput {
|
.newProjectInput {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
background: var(--input-bg-color);
|
background: var(--input-bg-color);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
color: var(--text-primary-color);
|
color: var(--text-primary-color);
|
||||||
font-size: var(--text-sm);
|
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.newProjectBtn {
|
.newProjectBtn {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-primary-darkest);
|
background: var(--color-primary-darkest);
|
||||||
transform: rotate(-0.5deg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.newProjectBtnCancel {
|
.newProjectBtnCancel {
|
||||||
background: none;
|
background: none;
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
transform: rotate(-0.5deg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,12 +628,11 @@
|
|||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 5px 5px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
width: 420px;
|
width: 420px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
transform: rotate(-0.3deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalHeader {
|
.modalHeader {
|
||||||
@@ -479,13 +640,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-4) var(--space-5);
|
padding: var(--space-4) var(--space-5);
|
||||||
border-bottom: 2px solid var(--color-gray-85);
|
border-bottom: 1px solid var(--default-border-color);
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
color: var(--color-gray-85);
|
color: var(--color-gray-85);
|
||||||
font-family: 'Georgia', serif;
|
font-family: var(--ui-font);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,8 +675,8 @@
|
|||||||
.modalInput {
|
.modalInput {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
background: var(--input-bg-color);
|
background: var(--input-bg-color);
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
@@ -523,7 +684,7 @@
|
|||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,27 +697,82 @@
|
|||||||
|
|
||||||
.modalBtnSecondary {
|
.modalBtnSecondary {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-gray-70);
|
color: var(--color-gray-70);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
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 {
|
.modalBtnPrimary {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
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; }
|
&: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 React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, 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 { Card, Button, Modal } from '@/components';
|
||||||
import { useDrawingStore } from '@/stores';
|
import { useDrawingStore } from '@/stores';
|
||||||
import { api } from '@/services';
|
import { api } from '@/services';
|
||||||
import type { Drawing } from '@/types';
|
import type { Drawing, Folder as FolderType } from '@/types';
|
||||||
import styles from './FileBrowser.module.scss';
|
import styles from './FileBrowser.module.scss';
|
||||||
|
|
||||||
export const FileBrowser: React.FC = () => {
|
export const FileBrowser: React.FC = () => {
|
||||||
@@ -35,7 +35,22 @@ export const FileBrowser: React.FC = () => {
|
|||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
|
||||||
// Move state
|
// 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
|
// New drawing name modal state
|
||||||
const [showNameModal, setShowNameModal] = useState(false);
|
const [showNameModal, setShowNameModal] = useState(false);
|
||||||
@@ -173,6 +188,11 @@ export const FileBrowser: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await api.drawings.delete(drawing.id);
|
await api.drawings.delete(drawing.id);
|
||||||
removeDrawing(drawing.id);
|
removeDrawing(drawing.id);
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(drawing.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setActiveMenu(null);
|
setActiveMenu(null);
|
||||||
setModal(m => ({ ...m, open: false }));
|
setModal(m => ({ ...m, open: false }));
|
||||||
} catch (err) {
|
} 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) => {
|
const handleDuplicateDrawing = async (drawing: Drawing) => {
|
||||||
try {
|
try {
|
||||||
const newDrawing = await api.drawings.create({
|
const newDrawing = await api.drawings.create({
|
||||||
@@ -219,20 +288,165 @@ export const FileBrowser: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await api.drawings.update(drawing.id, { folder_id: folderId });
|
await api.drawings.update(drawing.id, { folder_id: folderId });
|
||||||
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
|
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
|
||||||
setMovingId(null);
|
setMoveModalDrawing(null);
|
||||||
setActiveMenu(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to move drawing:', err);
|
console.error('Failed to move drawing:', err);
|
||||||
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
|
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
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onClick = (e: MouseEvent) => {
|
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);
|
setActiveMenu(null);
|
||||||
}
|
}
|
||||||
|
if (folderMenuRef.current && !folderMenuRef.current.contains(target)) {
|
||||||
|
const isMenuBtn = target.closest(`.${styles.folderMenuBtn}`) !== null;
|
||||||
|
if (!isMenuBtn) {
|
||||||
|
setFolderMenuId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', onClick);
|
document.addEventListener('mousedown', onClick);
|
||||||
return () => document.removeEventListener('mousedown', onClick);
|
return () => document.removeEventListener('mousedown', onClick);
|
||||||
@@ -347,6 +561,28 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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}>
|
<div className={styles.content}>
|
||||||
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
|
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
|
||||||
{showNewProject && (
|
{showNewProject && (
|
||||||
@@ -374,7 +610,20 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className={styles.folderTree} role="tree">
|
<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
|
<button
|
||||||
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
|
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
|
||||||
onClick={() => handleFolderClick(null)}
|
onClick={() => handleFolderClick(null)}
|
||||||
@@ -386,16 +635,93 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{folders.map((folder) => (
|
{folders.map((folder) => (
|
||||||
<li key={folder.id}>
|
<li
|
||||||
<button
|
key={folder.id}
|
||||||
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
|
draggable
|
||||||
onClick={() => handleFolderClick(folder.id)}
|
onDragStart={(e) => handleDragStart(e, folder.id)}
|
||||||
aria-current={activeFolderId === folder.id ? 'true' : undefined}
|
onDragOver={(e) => {
|
||||||
role="treeitem"
|
e.preventDefault();
|
||||||
>
|
e.dataTransfer.dropEffect = 'move';
|
||||||
<Folder size={18} aria-hidden="true" />
|
if ((draggedFolderId && draggedFolderId !== folder.id) || (draggedDrawingId && draggedDrawingId !== folder.id)) {
|
||||||
<span>{folder.name}</span>
|
setDragOverFolderId(folder.id);
|
||||||
</button>
|
}
|
||||||
|
}}
|
||||||
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -410,93 +736,99 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
visibleDrawings.map((drawing) => (
|
visibleDrawings.map((drawing) => {
|
||||||
<Card
|
const isSelected = selectedIds.has(drawing.id);
|
||||||
key={drawing.id}
|
return (
|
||||||
className={styles.drawingCard}
|
<Card
|
||||||
hover
|
key={drawing.id}
|
||||||
role="listitem"
|
className={`${styles.drawingCard} ${isSelected ? styles.drawingSelected : ''}`}
|
||||||
tabIndex={0}
|
hover
|
||||||
onClick={() => handleDrawingClick(drawing)}
|
role="listitem"
|
||||||
onKeyDown={(e: React.KeyboardEvent) => {
|
tabIndex={0}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
draggable
|
||||||
e.preventDefault();
|
onDragStart={(e) => handleDrawingDragStart(e as unknown as React.DragEvent, drawing.id)}
|
||||||
handleDrawingClick(drawing);
|
onDragEnd={handleDrawingDragEnd}
|
||||||
}
|
onClick={() => handleDrawingClick(drawing)}
|
||||||
}}
|
onKeyDown={(e: React.KeyboardEvent) => {
|
||||||
aria-label={`Open drawing ${drawing.title}`}
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
>
|
e.preventDefault();
|
||||||
<div className={styles.thumbnail}>
|
handleDrawingClick(drawing);
|
||||||
{drawing.thumbnail_url ? (
|
}
|
||||||
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
|
}}
|
||||||
) : (
|
aria-label={`Open drawing ${drawing.title}`}
|
||||||
<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
|
<button
|
||||||
className={styles.more}
|
className={styles.selectBox}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
|
toggleSelect(drawing.id);
|
||||||
setRenamingId(null);
|
|
||||||
}}
|
}}
|
||||||
aria-label={`More options for ${drawing.title}`}
|
aria-label={isSelected ? 'Deselect' : 'Select'}
|
||||||
aria-expanded={activeMenu === drawing.id}
|
aria-pressed={isSelected}
|
||||||
>
|
>
|
||||||
<MoreVertical size={16} />
|
{isSelected ? <SquareCheck size={16} /> : <Square size={16} />}
|
||||||
</button>
|
</button>
|
||||||
{activeMenu === drawing.id && (
|
<div className={styles.thumbnail}>
|
||||||
<div className={styles.dropdown}>
|
{drawing.thumbnail_url ? (
|
||||||
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
|
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
|
||||||
<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>
|
<img
|
||||||
{movingId === drawing.id ? (
|
src={`/api/drawings/${drawing.id}/thumbnail`}
|
||||||
<div className={styles.dropdownSubmenu}>
|
alt=""
|
||||||
<button className={styles.dropdownSubheader}>Move to:</button>
|
loading="lazy"
|
||||||
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||||
{folders.map(f => (
|
/>
|
||||||
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
|
)}
|
||||||
))}
|
</div>
|
||||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
|
<div className={styles.info}>
|
||||||
</div>
|
{renamingId === drawing.id ? (
|
||||||
) : (
|
<input
|
||||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
|
autoFocus
|
||||||
)}
|
className={styles.renameInput}
|
||||||
<div className={styles.dropdownDivider} />
|
value={renameValue}
|
||||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
</div>
|
onKeyDown={(e) => {
|
||||||
)}
|
if (e.key === 'Enter') handleRenameDrawing(drawing);
|
||||||
</div>
|
if (e.key === 'Escape') setRenamingId(null);
|
||||||
</Card>
|
}}
|
||||||
))
|
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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -530,6 +862,80 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,17 +9,16 @@
|
|||||||
margin-bottom: var(--space-8);
|
margin-bottom: var(--space-8);
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 4px 4px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
transform: rotate(0.1deg);
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-gray-85);
|
color: var(--color-gray-85);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
font-family: 'Georgia', serif;
|
font-family: var(--ui-font);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +44,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
color: var(--color-gray-70);
|
color: var(--color-gray-70);
|
||||||
background: none;
|
background: none;
|
||||||
border: 2px solid transparent;
|
border: 1px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
transition: all var(--duration-fast) var(--ease-out);
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
@@ -57,17 +56,14 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
border-color: var(--color-gray-30);
|
border-color: var(--default-border-color);
|
||||||
transform: rotate(-0.2deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--color-surface-primary-container);
|
background: var(--color-surface-primary-container);
|
||||||
color: var(--color-primary-darkest);
|
color: var(--color-primary-darkest);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-color: var(--color-gray-85);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
|
||||||
transform: rotate(-0.1deg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +92,8 @@
|
|||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -151,26 +147,24 @@
|
|||||||
|
|
||||||
.themeOption {
|
.themeOption {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
color: var(--color-gray-70);
|
color: var(--color-gray-70);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--duration-fast) var(--ease-out);
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-30);
|
box-shadow: var(--shadow-island);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
transform: translate(-1px, -1px);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
box-shadow: 3px 3px 0 var(--color-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
border-color: var(--color-gray-85);
|
border-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,16 @@
|
|||||||
margin-bottom: var(--space-8);
|
margin-bottom: var(--space-8);
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 4px 4px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
transform: rotate(-0.2deg);
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-gray-85);
|
color: var(--color-gray-85);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
font-family: 'Georgia', serif;
|
font-family: var(--ui-font);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,21 +67,20 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3) var(--space-2);
|
padding: var(--space-3) var(--space-2);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
background: var(--island-bg-color);
|
background: var(--island-bg-color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
transform: translateX(2px) rotate(-0.2deg);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: 2px solid var(--color-gray-30);
|
border-bottom: 1px solid var(--default-border-color);
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,8 +96,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
box-shadow: 2px 2px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
|
|
||||||
.memberInfo {
|
.memberInfo {
|
||||||
@@ -122,13 +120,12 @@
|
|||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
padding: var(--space-1) var(--space-3);
|
padding: var(--space-1) var(--space-3);
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-gray-70);
|
color: var(--color-gray-70);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
box-shadow: 1px 1px 0 var(--color-gray-85);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inviteForm {
|
.inviteForm {
|
||||||
@@ -139,15 +136,15 @@
|
|||||||
|
|
||||||
.inviteInput {
|
.inviteInput {
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
background: var(--input-bg-color);
|
background: var(--input-bg-color);
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,12 +184,11 @@
|
|||||||
|
|
||||||
.roleSelect {
|
.roleSelect {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border: 2px solid var(--color-gray-30);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
background: var(--input-bg-color);
|
background: var(--input-bg-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 1px 1px 0 var(--color-gray-85);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
|||||||
@@ -78,15 +78,13 @@
|
|||||||
|
|
||||||
.templateCard {
|
.templateCard {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 2px solid var(--color-gray-85);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: 2px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
transform: rotate(0.1deg);
|
transition: box-shadow 0.15s ease;
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: rotate(0) translate(-1px, -1px);
|
box-shadow: var(--shadow-island-stronger);
|
||||||
box-shadow: 5px 5px 0 var(--color-gray-85);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
@@ -44,6 +45,8 @@ export const api = {
|
|||||||
fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||||
delete: (id: string): Promise<{ ok: boolean }> =>
|
delete: (id: string): Promise<{ ok: boolean }> =>
|
||||||
fetchApi(`/drawings/${id}`, { method: 'DELETE' }),
|
fetchApi(`/drawings/${id}`, { method: 'DELETE' }),
|
||||||
|
autosave: (id: string, snapshot: object): Promise<{ ok: boolean }> =>
|
||||||
|
fetchApi(`/drawings/${id}/autosave`, { method: 'PATCH', body: JSON.stringify({ snapshot }) }),
|
||||||
},
|
},
|
||||||
revisions: {
|
revisions: {
|
||||||
list: (drawingId: string): Promise<DrawingRevision[]> =>
|
list: (drawingId: string): Promise<DrawingRevision[]> =>
|
||||||
@@ -58,6 +61,12 @@ export const api = {
|
|||||||
list: (): Promise<Folder[]> => fetchApi('/folders'),
|
list: (): Promise<Folder[]> => fetchApi('/folders'),
|
||||||
create: (data: object): Promise<Folder> =>
|
create: (data: object): Promise<Folder> =>
|
||||||
fetchApi('/folders', { method: 'POST', body: JSON.stringify(data) }),
|
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: {
|
teams: {
|
||||||
list: (): Promise<Team[]> => fetchApi('/teams'),
|
list: (): Promise<Team[]> => fetchApi('/teams'),
|
||||||
|
|||||||
@@ -122,17 +122,17 @@ a {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
--border-radius-md: 2px;
|
--border-radius-md: var(--border-radius-lg);
|
||||||
|
|
||||||
.context-menu {
|
.context-menu {
|
||||||
border: 2px solid var(--color-gray-85) !important;
|
border: 1px solid var(--default-border-color) !important;
|
||||||
border-radius: 2px !important;
|
border-radius: var(--border-radius-lg) !important;
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85) !important;
|
box-shadow: var(--shadow-island) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-menu-items-container {
|
.library-menu-items-container {
|
||||||
border: 2px solid var(--color-gray-85) !important;
|
border: 1px solid var(--default-border-color) !important;
|
||||||
border-radius: 2px !important;
|
border-radius: var(--border-radius-lg) !important;
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85) !important;
|
box-shadow: var(--shadow-island) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
{
|
{
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"failedTests": [
|
"failedTests": []
|
||||||
"c31ff144dc4fee3acd0a-bec551c658216ec9862a",
|
|
||||||
"c31ff144dc4fee3acd0a-f87315abf5d197970540",
|
|
||||||
"c31ff144dc4fee3acd0a-fc5e81ebcffdb7687b8e",
|
|
||||||
"c31ff144dc4fee3acd0a-989f5dcca4211fe0b2e4",
|
|
||||||
"c31ff144dc4fee3acd0a-ac5aa3cfe7537125a151",
|
|
||||||
"c31ff144dc4fee3acd0a-7f990aaafdc09c3794e8"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -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: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: true,
|
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.Post("/drawings", a.handleCreateDrawing)
|
||||||
r.Get("/drawings/{drawingID}", a.handleGetDrawing)
|
r.Get("/drawings/{drawingID}", a.handleGetDrawing)
|
||||||
r.Patch("/drawings/{drawingID}", a.handleUpdateDrawing)
|
r.Patch("/drawings/{drawingID}", a.handleUpdateDrawing)
|
||||||
|
r.Patch("/drawings/{drawingID}/autosave", a.handleAutosaveDrawing)
|
||||||
r.Delete("/drawings/{drawingID}", a.handleArchiveDrawing)
|
r.Delete("/drawings/{drawingID}", a.handleArchiveDrawing)
|
||||||
r.Get("/drawings/{drawingID}/revisions", a.handleListRevisions)
|
r.Get("/drawings/{drawingID}/revisions", a.handleListRevisions)
|
||||||
r.Post("/drawings/{drawingID}/revisions", a.handleCreateRevision)
|
r.Post("/drawings/{drawingID}/revisions", a.handleCreateRevision)
|
||||||
@@ -78,6 +79,9 @@ func (a *API) Routes() chi.Router {
|
|||||||
r.Get("/stats", a.handleStats)
|
r.Get("/stats", a.handleStats)
|
||||||
r.Get("/folders", a.handleListFolders)
|
r.Get("/folders", a.handleListFolders)
|
||||||
r.Post("/folders", a.handleCreateFolder)
|
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.Get("/projects", a.handleListProjects)
|
||||||
r.Post("/projects", a.handleCreateProject)
|
r.Post("/projects", a.handleCreateProject)
|
||||||
r.Get("/notifications", a.handleListNotifications)
|
r.Get("/notifications", a.handleListNotifications)
|
||||||
@@ -134,6 +138,14 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
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")
|
origin := r.Header.Get("Origin")
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -150,17 +162,34 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
|
|||||||
proto = "https"
|
proto = "https"
|
||||||
}
|
}
|
||||||
expected := proto + "://" + host
|
expected := proto + "://" + host
|
||||||
if origin != expected {
|
if origin == expected {
|
||||||
// also allow without port in case proxy strips it
|
next.ServeHTTP(w, r)
|
||||||
expectedNoPort := proto + "://" + strings.SplitN(host, ":", 2)[0]
|
return
|
||||||
originNoPort := strings.SplitN(origin, "://", 2)[1]
|
}
|
||||||
originNoPort = strings.SplitN(originNoPort, ":", 2)[0]
|
// allow without port in case proxy strips it
|
||||||
if originNoPort != expectedNoPort {
|
expectedHost := strings.SplitN(host, ":", 2)[0]
|
||||||
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
|
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
|
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)
|
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) {
|
func (a *API) handleArchiveDrawing(w http.ResponseWriter, r *http.Request) {
|
||||||
user, _ := currentUser(r)
|
user, _ := currentUser(r)
|
||||||
if err := a.store.ArchiveDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID")); err != nil {
|
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)
|
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) {
|
func (a *API) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
user, _ := currentUser(r)
|
user, _ := currentUser(r)
|
||||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
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()
|
defer r.Body.Close()
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||||
decoder := json.NewDecoder(r.Body)
|
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 {
|
if err := decoder.Decode(dst); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Invalid request body")
|
writeError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
return false
|
return false
|
||||||
|
|||||||
+184
-13
@@ -40,12 +40,33 @@ type CreateDrawingRequest struct {
|
|||||||
Snapshot json.RawMessage `json:"snapshot"`
|
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 {
|
type UpdateDrawingRequest struct {
|
||||||
FolderID *string `json:"folder_id"`
|
FolderID NullString `json:"folder_id"`
|
||||||
ProjectID *string `json:"project_id"`
|
ProjectID NullString `json:"project_id"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Visibility *string `json:"visibility"`
|
Visibility *string `json:"visibility"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateRevisionRequest struct {
|
type CreateRevisionRequest struct {
|
||||||
@@ -61,6 +82,11 @@ type CreateFolderRequest struct {
|
|||||||
Visibility string `json:"visibility"`
|
Visibility string `json:"visibility"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateFolderRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Visibility *string `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreateProjectRequest struct {
|
type CreateProjectRequest struct {
|
||||||
TeamID string `json:"team_id"`
|
TeamID string `json:"team_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -616,11 +642,11 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
|
|||||||
}
|
}
|
||||||
current.Visibility = *req.Visibility
|
current.Visibility = *req.Visibility
|
||||||
}
|
}
|
||||||
if req.FolderID != nil {
|
if req.FolderID.Valid {
|
||||||
current.FolderID = req.FolderID
|
current.FolderID = req.FolderID.Value
|
||||||
}
|
}
|
||||||
if req.ProjectID != nil {
|
if req.ProjectID.Valid {
|
||||||
current.ProjectID = req.ProjectID
|
current.ProjectID = req.ProjectID.Value
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings
|
_, 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)
|
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 {
|
func (s *Store) ArchiveDrawing(ctx context.Context, userID, drawingID string) error {
|
||||||
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
|
if err := s.ensureDrawingAccess(ctx, userID, drawingID, "edit"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -881,7 +948,7 @@ func (s *Store) ListFolders(ctx context.Context, userID, teamID string) ([]Folde
|
|||||||
return nil, ErrForbidden
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -931,10 +998,12 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: 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
|
_, 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)
|
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at, sort_order)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -942,6 +1011,108 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
|||||||
return folder, nil
|
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) {
|
func (s *Store) ListProjects(ctx context.Context, userID, teamID string) ([]Project, error) {
|
||||||
if teamID == "" {
|
if teamID == "" {
|
||||||
teamID, _ = s.defaultTeamID(ctx, userID)
|
teamID, _ = s.defaultTeamID(ctx, userID)
|
||||||
|
|||||||
Reference in New Issue
Block a user