mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71dda9d45d | |||
| b79c214ad2 |
@@ -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,14 +41,14 @@ 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) {
|
||||||
return {
|
return {
|
||||||
id: `txt-${Math.random().toString(36).slice(2)}`,
|
id: `txt-${Math.random().toString(36).slice(2)}`,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -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,7 +77,7 @@ 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,
|
||||||
@@ -208,14 +208,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'),
|
||||||
@@ -291,6 +291,167 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
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),
|
||||||
|
],
|
||||||
|
'brainstorm-matrix': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Matrix', 30),
|
||||||
|
// 2x2 grid
|
||||||
|
makeHandDrawnRect(50, 90, 300, 160),
|
||||||
|
makeText(150, 130, 'Quadrant A', 20),
|
||||||
|
makeText(80, 170, '- Idea 1', 16),
|
||||||
|
makeText(80, 200, '- Idea 2', 16),
|
||||||
|
makeHandDrawnRect(370, 90, 300, 160),
|
||||||
|
makeText(470, 130, 'Quadrant B', 20),
|
||||||
|
makeText(400, 170, '- Idea 1', 16),
|
||||||
|
makeText(400, 200, '- Idea 2', 16),
|
||||||
|
makeHandDrawnRect(50, 270, 300, 160),
|
||||||
|
makeText(150, 310, 'Quadrant C', 20),
|
||||||
|
makeText(80, 350, '- Idea 1', 16),
|
||||||
|
makeText(80, 380, '- Idea 2', 16),
|
||||||
|
makeHandDrawnRect(370, 270, 300, 160),
|
||||||
|
makeText(470, 310, 'Quadrant D', 20),
|
||||||
|
makeText(400, 350, '- Idea 1', 16),
|
||||||
|
makeText(400, 380, '- Idea 2', 16),
|
||||||
|
makeAddButton(50, 450, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 450, 'Add idea...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-freeform': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Freeform', 30),
|
||||||
|
makeText(50, 70, 'Drag sticky notes anywhere!', 16),
|
||||||
|
// Scattered sticky notes
|
||||||
|
makeHandDrawnRect(60, 110, 160, 80),
|
||||||
|
makeText(80, 140, '💡 Idea 1', 18),
|
||||||
|
makeHandDrawnRect(260, 130, 160, 80),
|
||||||
|
makeText(280, 160, '🚀 Idea 2', 18),
|
||||||
|
makeHandDrawnRect(460, 110, 160, 80),
|
||||||
|
makeText(480, 140, '🎯 Idea 3', 18),
|
||||||
|
makeHandDrawnRect(120, 230, 160, 80),
|
||||||
|
makeText(140, 260, '❓ Idea 4', 18),
|
||||||
|
makeHandDrawnRect(340, 250, 160, 80),
|
||||||
|
makeText(360, 280, '✨ Idea 5', 18),
|
||||||
|
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 360, 'Add note...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-fishbone': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Fishbone', 30),
|
||||||
|
// Spine
|
||||||
|
makeArrow(100, 250, 600, 250),
|
||||||
|
// Problem head
|
||||||
|
makeHandDrawnRect(600, 220, 140, 60),
|
||||||
|
makeText(620, 240, 'Problem', 18),
|
||||||
|
// Top branches
|
||||||
|
makeArrow(220, 250, 180, 160),
|
||||||
|
makeHandDrawnRect(120, 110, 160, 50),
|
||||||
|
makeText(140, 128, 'People', 16),
|
||||||
|
makeArrow(380, 250, 340, 160),
|
||||||
|
makeHandDrawnRect(280, 110, 160, 50),
|
||||||
|
makeText(300, 128, 'Process', 16),
|
||||||
|
makeArrow(540, 250, 500, 160),
|
||||||
|
makeHandDrawnRect(440, 110, 160, 50),
|
||||||
|
makeText(460, 128, 'Policy', 16),
|
||||||
|
// Bottom branches
|
||||||
|
makeArrow(280, 250, 240, 340),
|
||||||
|
makeHandDrawnRect(180, 340, 160, 50),
|
||||||
|
makeText(200, 358, 'Place', 16),
|
||||||
|
makeArrow(460, 250, 420, 340),
|
||||||
|
makeHandDrawnRect(360, 340, 160, 50),
|
||||||
|
makeText(380, 358, 'Product', 16),
|
||||||
|
makeAddButton(50, 420, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 420, 'Add cause...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-venn': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Venn', 30),
|
||||||
|
// Three overlapping circles
|
||||||
|
makeHandDrawnRect(120, 120, 160, 160),
|
||||||
|
makeText(170, 130, 'A', 20),
|
||||||
|
makeText(140, 190, 'Set A traits', 14),
|
||||||
|
makeHandDrawnRect(280, 120, 160, 160),
|
||||||
|
makeText(330, 130, 'B', 20),
|
||||||
|
makeText(300, 190, 'Set B traits', 14),
|
||||||
|
makeHandDrawnRect(200, 220, 160, 160),
|
||||||
|
makeText(250, 230, 'C', 20),
|
||||||
|
makeText(220, 290, 'Set C traits', 14),
|
||||||
|
// Center overlap note
|
||||||
|
makeText(245, 190, 'Overlap', 12),
|
||||||
|
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 400, 'Add set...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-tree': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Tree', 30),
|
||||||
|
// Root
|
||||||
|
makeHandDrawnRect(280, 90, 160, 50),
|
||||||
|
makeText(310, 110, 'Root Topic', 18),
|
||||||
|
// Branches
|
||||||
|
makeArrow(360, 140, 200, 200),
|
||||||
|
makeHandDrawnRect(120, 190, 160, 50),
|
||||||
|
makeText(145, 210, 'Branch 1', 16),
|
||||||
|
makeArrow(360, 140, 360, 200),
|
||||||
|
makeHandDrawnRect(280, 190, 160, 50),
|
||||||
|
makeText(305, 210, 'Branch 2', 16),
|
||||||
|
makeArrow(360, 140, 520, 200),
|
||||||
|
makeHandDrawnRect(440, 190, 160, 50),
|
||||||
|
makeText(465, 210, 'Branch 3', 16),
|
||||||
|
// Leaves
|
||||||
|
makeArrow(200, 240, 140, 300),
|
||||||
|
makeHandDrawnRect(60, 290, 140, 40),
|
||||||
|
makeText(85, 305, 'Leaf 1a', 14),
|
||||||
|
makeArrow(200, 240, 260, 300),
|
||||||
|
makeHandDrawnRect(200, 290, 140, 40),
|
||||||
|
makeText(225, 305, 'Leaf 1b', 14),
|
||||||
|
makeArrow(520, 240, 460, 300),
|
||||||
|
makeHandDrawnRect(440, 290, 140, 40),
|
||||||
|
makeText(465, 305, 'Leaf 3a', 14),
|
||||||
|
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 360, 'Add branch...', 16),
|
||||||
|
],
|
||||||
|
'brainstorm-converge': [
|
||||||
|
makeText(50, 30, 'Brainstorm — Converge', 30),
|
||||||
|
// Diverging ideas (top)
|
||||||
|
makeHandDrawnRect(80, 90, 140, 50),
|
||||||
|
makeText(105, 110, 'Idea A', 16),
|
||||||
|
makeHandDrawnRect(280, 90, 140, 50),
|
||||||
|
makeText(305, 110, 'Idea B', 16),
|
||||||
|
makeHandDrawnRect(480, 90, 140, 50),
|
||||||
|
makeText(505, 110, 'Idea C', 16),
|
||||||
|
// Converging arrows
|
||||||
|
makeArrow(150, 140, 290, 220),
|
||||||
|
makeArrow(350, 140, 330, 220),
|
||||||
|
makeArrow(550, 140, 370, 220),
|
||||||
|
// Converged outcome
|
||||||
|
makeHandDrawnRect(220, 220, 260, 70),
|
||||||
|
makeText(260, 245, 'Combined Solution', 20),
|
||||||
|
// Next steps
|
||||||
|
makeArrow(350, 290, 350, 350),
|
||||||
|
makeHandDrawnRect(240, 350, 220, 50),
|
||||||
|
makeText(265, 370, 'Action Plan', 16),
|
||||||
|
makeAddButton(50, 430, '+', 'brainstorm-add'),
|
||||||
|
makeText(82, 430, 'Add idea...', 16),
|
||||||
|
],
|
||||||
retrospective: [
|
retrospective: [
|
||||||
makeText(50, 30, 'Retrospective', 30),
|
makeText(50, 30, 'Retrospective', 30),
|
||||||
// Went Well
|
// Went Well
|
||||||
@@ -374,13 +535,13 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
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),
|
||||||
@@ -421,6 +582,84 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeAddButton(300, 290, '+', 'architecture-add'),
|
makeAddButton(300, 290, '+', 'architecture-add'),
|
||||||
makeText(332, 290, 'Add component...', 14),
|
makeText(332, 290, 'Add component...', 14),
|
||||||
],
|
],
|
||||||
|
'er-diagram': [
|
||||||
|
makeText(50, 30, 'ER Diagram', 30),
|
||||||
|
// User entity
|
||||||
|
makeHandDrawnRect(50, 90, 160, 120),
|
||||||
|
makeText(70, 110, 'User', 18),
|
||||||
|
makeText(70, 140, 'id: PK', 14),
|
||||||
|
makeText(70, 164, 'email: string', 14),
|
||||||
|
makeText(70, 188, 'name: string', 14),
|
||||||
|
// Relationship line
|
||||||
|
makeArrow(210, 150, 270, 150),
|
||||||
|
makeText(220, 130, '1:N', 12),
|
||||||
|
// Order entity
|
||||||
|
makeHandDrawnRect(270, 90, 160, 120),
|
||||||
|
makeText(290, 110, 'Order', 18),
|
||||||
|
makeText(290, 140, 'id: PK', 14),
|
||||||
|
makeText(290, 164, 'user_id: FK', 14),
|
||||||
|
makeText(290, 188, 'total: number', 14),
|
||||||
|
],
|
||||||
|
'api-design': [
|
||||||
|
makeText(50, 30, 'API Design', 30),
|
||||||
|
makeHandDrawnRect(50, 90, 600, 50),
|
||||||
|
makeText(70, 110, 'GET /users → List users', 16),
|
||||||
|
makeHandDrawnRect(50, 150, 600, 50),
|
||||||
|
makeText(70, 170, 'POST /users → Create user', 16),
|
||||||
|
makeHandDrawnRect(50, 210, 600, 50),
|
||||||
|
makeText(70, 230, 'GET /users/:id → Get user', 16),
|
||||||
|
makeHandDrawnRect(50, 270, 600, 50),
|
||||||
|
makeText(70, 290, 'PATCH /users/:id → Update user', 16),
|
||||||
|
makeHandDrawnRect(50, 330, 600, 50),
|
||||||
|
makeText(70, 350, 'DELETE /users/:id → Delete user', 16),
|
||||||
|
makeAddButton(50, 400, '+', 'api-add'),
|
||||||
|
makeText(82, 400, 'Add endpoint...', 14),
|
||||||
|
],
|
||||||
|
'sitemap': [
|
||||||
|
makeText(50, 30, 'Site Map', 30),
|
||||||
|
// Home
|
||||||
|
makeHandDrawnRect(280, 90, 140, 50),
|
||||||
|
makeText(320, 112, 'Home', 18),
|
||||||
|
// Pages below
|
||||||
|
makeHandDrawnRect(50, 180, 140, 50),
|
||||||
|
makeText(75, 202, 'Products', 16),
|
||||||
|
makeHandDrawnRect(220, 180, 140, 50),
|
||||||
|
makeText(250, 202, 'Pricing', 16),
|
||||||
|
makeHandDrawnRect(390, 180, 140, 50),
|
||||||
|
makeText(420, 202, 'About', 16),
|
||||||
|
makeHandDrawnRect(560, 180, 140, 50),
|
||||||
|
makeText(585, 202, 'Contact', 16),
|
||||||
|
// Connections
|
||||||
|
makeArrow(350, 140, 120, 180),
|
||||||
|
makeArrow(350, 140, 290, 180),
|
||||||
|
makeArrow(350, 140, 460, 180),
|
||||||
|
makeArrow(350, 140, 630, 180),
|
||||||
|
makeAddButton(50, 260, '+', 'sitemap-add'),
|
||||||
|
makeText(82, 260, 'Add page...', 14),
|
||||||
|
],
|
||||||
|
'user-persona': [
|
||||||
|
makeText(50, 30, 'User Persona', 30),
|
||||||
|
// Name & role
|
||||||
|
makeHandDrawnRect(50, 90, 300, 70),
|
||||||
|
makeText(70, 112, 'Name: Alex, 32, Designer', 18),
|
||||||
|
// Goals
|
||||||
|
makeHandDrawnRect(50, 180, 300, 130),
|
||||||
|
makeText(70, 200, 'Goals 🎯', 18),
|
||||||
|
makeText(70, 230, '- Save time on workflows', 14),
|
||||||
|
makeText(70, 254, '- Collaborate easily', 14),
|
||||||
|
// Frustrations
|
||||||
|
makeHandDrawnRect(370, 90, 300, 220),
|
||||||
|
makeText(390, 112, 'Frustrations 😤', 18),
|
||||||
|
makeText(390, 142, '- Too many tools', 14),
|
||||||
|
makeText(390, 166, '- Slow feedback loops', 14),
|
||||||
|
makeText(390, 190, '- Hard to share ideas', 14),
|
||||||
|
makeText(390, 214, '- No single source of truth', 14),
|
||||||
|
// Behaviors
|
||||||
|
makeHandDrawnRect(50, 330, 620, 70),
|
||||||
|
makeText(70, 352, 'Behaviors: Uses Figma, Slack, Notion. Prefers visual tools.', 14),
|
||||||
|
makeAddButton(50, 420, '+', 'persona-add'),
|
||||||
|
makeText(82, 420, 'Add trait...', 14),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const OPTIONS: TemplateOption[] = [
|
const OPTIONS: TemplateOption[] = [
|
||||||
@@ -434,11 +673,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,11 +73,23 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loadingCanvas {
|
.loadingCanvas {
|
||||||
@@ -407,18 +419,81 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
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);
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
box-shadow: 3px 3px 0 var(--color-gray-85);
|
box-shadow: var(--shadow-island);
|
||||||
transform: rotate(-0.3deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 +508,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;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, StickyNote, LayoutTemplate, MonitorPlay, X, Plus } from 'lucide-react';
|
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, ChevronLeft, StickyNote, LayoutTemplate, MonitorPlay, X, Plus, Frame } from 'lucide-react';
|
||||||
import { Button } from '@/components';
|
import { Button } from '@/components';
|
||||||
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
|
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
@@ -34,8 +34,15 @@ interface EditorState {
|
|||||||
function prepareElementsForImport(sourceElements: LooseElement[], offsetX: number, offsetY: number): LooseElement[] {
|
function prepareElementsForImport(sourceElements: LooseElement[], offsetX: number, offsetY: number): LooseElement[] {
|
||||||
if (!sourceElements || !sourceElements.length) return [];
|
if (!sourceElements || !sourceElements.length) return [];
|
||||||
const idMap = new Map<string, string>();
|
const idMap = new Map<string, string>();
|
||||||
|
const groupIdMap = new Map<string, string>();
|
||||||
sourceElements.forEach((el) => {
|
sourceElements.forEach((el) => {
|
||||||
idMap.set(el.id as string, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
|
idMap.set(el.id as string, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
|
||||||
|
const gids = ((el as { groupIds?: string[] }).groupIds) || [];
|
||||||
|
gids.forEach((gid) => {
|
||||||
|
if (!groupIdMap.has(gid)) {
|
||||||
|
groupIdMap.set(gid, `group-${Math.random().toString(36).slice(2, 9)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return sourceElements.map((el) => {
|
return sourceElements.map((el) => {
|
||||||
const newEl: LooseElement = { ...el };
|
const newEl: LooseElement = { ...el };
|
||||||
@@ -55,6 +62,10 @@ function prepareElementsForImport(sourceElements: LooseElement[], offsetX: numbe
|
|||||||
if (newEl.containerId && idMap.has(newEl.containerId as string)) {
|
if (newEl.containerId && idMap.has(newEl.containerId as string)) {
|
||||||
newEl.containerId = idMap.get(newEl.containerId as string);
|
newEl.containerId = idMap.get(newEl.containerId as string);
|
||||||
}
|
}
|
||||||
|
const gids = (newEl as { groupIds?: string[] }).groupIds;
|
||||||
|
if (gids && gids.length) {
|
||||||
|
(newEl as { groupIds?: string[] }).groupIds = gids.map((gid) => groupIdMap.get(gid) || gid);
|
||||||
|
}
|
||||||
return newEl;
|
return newEl;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -88,6 +99,9 @@ export const Editor: React.FC = () => {
|
|||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastSavedDataRef = useRef<string>('');
|
const lastSavedDataRef = useRef<string>('');
|
||||||
const lastToggledCheckboxRef = useRef<string | null>(null);
|
const lastToggledCheckboxRef = useRef<string | null>(null);
|
||||||
|
const lastProcessedAddRef = useRef<string | null>(null);
|
||||||
|
const saveDrawingRef = useRef<() => Promise<void>>(async () => {});
|
||||||
|
const isMutatingSceneRef = useRef(false);
|
||||||
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
|
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
|
||||||
|
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
@@ -96,6 +110,8 @@ export const Editor: React.FC = () => {
|
|||||||
const [templateName, setTemplateName] = useState('');
|
const [templateName, setTemplateName] = useState('');
|
||||||
const [templateDesc, setTemplateDesc] = useState('');
|
const [templateDesc, setTemplateDesc] = useState('');
|
||||||
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
|
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
|
||||||
|
const [slideIndex, setSlideIndex] = useState(0);
|
||||||
|
const [slides, setSlides] = useState<ExcalidrawElement[]>([]);
|
||||||
|
|
||||||
// Load drawing data
|
// Load drawing data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -176,6 +192,22 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Handle changes from Excalidraw
|
// Handle changes from Excalidraw
|
||||||
const handleExcalidrawChange = useCallback((elements: readonly ExcalidrawElement[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
|
const handleExcalidrawChange = useCallback((elements: readonly ExcalidrawElement[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
|
||||||
|
// Skip mutation processing if we are in the middle of applying a scene mutation
|
||||||
|
// to prevent React error #185 (Maximum update depth exceeded)
|
||||||
|
if (isMutatingSceneRef.current) {
|
||||||
|
currentStateRef.current = {
|
||||||
|
elements: elements as unknown as ExcalidrawElement[],
|
||||||
|
appState: appStateWithoutGrid(appState),
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
saveDrawingRef.current();
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
|
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
|
||||||
const selectedEl = selectedIds.length === 1
|
const selectedEl = selectedIds.length === 1
|
||||||
? elements.find((el) => el.id === selectedIds[0] && !el.isDeleted)
|
? elements.find((el) => el.id === selectedIds[0] && !el.isDeleted)
|
||||||
@@ -202,11 +234,19 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
: el
|
: el
|
||||||
));
|
));
|
||||||
excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] });
|
const nextEls = nextElements;
|
||||||
|
const nextAppState = appStateWithoutGrid(appState);
|
||||||
|
const nextFiles = files;
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
// Defer updateScene to prevent synchronous re-trigger of onChange (React error #185)
|
||||||
|
setTimeout(() => {
|
||||||
|
excalidrawAPI.updateScene({ elements: nextEls as ExcalidrawElement[] });
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
}, 0);
|
||||||
currentStateRef.current = {
|
currentStateRef.current = {
|
||||||
elements: nextElements,
|
elements: nextEls,
|
||||||
appState: appStateWithoutGrid(appState),
|
appState: nextAppState,
|
||||||
files,
|
files: nextFiles,
|
||||||
};
|
};
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
@@ -217,6 +257,10 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Handle "+" add button click
|
// Handle "+" add button click
|
||||||
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.action === 'add' && excalidrawAPI) {
|
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.action === 'add' && excalidrawAPI) {
|
||||||
|
if (lastProcessedAddRef.current === selectedEl.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastProcessedAddRef.current = selectedEl.id;
|
||||||
const customData = (selectedEl.customData as Record<string, unknown>) || {};
|
const customData = (selectedEl.customData as Record<string, unknown>) || {};
|
||||||
const role = customData.templateRole as string;
|
const role = customData.templateRole as string;
|
||||||
const btnX = (selectedEl.x as number) || 0;
|
const btnX = (selectedEl.x as number) || 0;
|
||||||
@@ -253,7 +297,12 @@ export const Editor: React.FC = () => {
|
|||||||
? { ...el, y: newY + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
? { ...el, y: newY + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
||||||
: el
|
: el
|
||||||
);
|
);
|
||||||
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
|
const merged = [...updated, ...newElements];
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
excalidrawAPI.updateScene({ elements: merged as ExcalidrawElement[] });
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
}, 0);
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -288,7 +337,12 @@ export const Editor: React.FC = () => {
|
|||||||
? { ...el, y: newY + cardH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
? { ...el, y: newY + cardH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
||||||
: el
|
: el
|
||||||
);
|
);
|
||||||
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
|
const kanbanMerged = [...updated, ...newElements];
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
excalidrawAPI.updateScene({ elements: kanbanMerged as ExcalidrawElement[] });
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
}, 0);
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -337,7 +391,12 @@ export const Editor: React.FC = () => {
|
|||||||
? { ...el, y: newY + nodeH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
? { ...el, y: newY + nodeH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
||||||
: el
|
: el
|
||||||
);
|
);
|
||||||
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
|
const mindmapMerged = [...updated, ...newElements];
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
excalidrawAPI.updateScene({ elements: mindmapMerged as ExcalidrawElement[] });
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
}, 0);
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -364,10 +423,17 @@ export const Editor: React.FC = () => {
|
|||||||
? { ...el, y: newY + 30, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
? { ...el, y: newY + 30, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
|
||||||
: el
|
: el
|
||||||
);
|
);
|
||||||
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
|
const genericMerged = [...updated, ...newElements];
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
excalidrawAPI.updateScene({ elements: genericMerged as ExcalidrawElement[] });
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
}, 0);
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
lastProcessedAddRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentStateRef.current = {
|
currentStateRef.current = {
|
||||||
@@ -380,45 +446,30 @@ export const Editor: React.FC = () => {
|
|||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
}
|
}
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
saveDrawing();
|
saveDrawingRef.current();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
// Auto-save functionality
|
// Auto-save: updates drawing snapshot directly without creating a revision
|
||||||
const saveDrawing = useCallback(async () => {
|
const saveDrawing = useCallback(async () => {
|
||||||
if (!id || !currentStateRef.current || isSaving) return;
|
if (!id || !currentStateRef.current) return;
|
||||||
|
|
||||||
const { elements, appState, files } = currentStateRef.current;
|
|
||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
type: 'excalidraw',
|
type: 'excalidraw',
|
||||||
version: 2,
|
version: 2,
|
||||||
source: window.location.hostname,
|
source: window.location.hostname,
|
||||||
elements,
|
elements: currentStateRef.current.elements,
|
||||||
appState: {
|
appState: currentStateRef.current.appState,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
files: currentStateRef.current.files,
|
||||||
gridSize: appState.gridSize,
|
|
||||||
gridStep: appState.gridStep,
|
|
||||||
gridModeEnabled: appState.gridModeEnabled,
|
|
||||||
theme: appState.theme,
|
|
||||||
zenModeEnabled: appState.zenModeEnabled,
|
|
||||||
viewModeEnabled: appState.viewModeEnabled,
|
|
||||||
editingGroup: appState.editingGroup,
|
|
||||||
selectedElementIds: appState.selectedElementIds,
|
|
||||||
},
|
|
||||||
files,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshotJson = JSON.stringify(snapshot);
|
const snapshotJson = JSON.stringify(snapshot);
|
||||||
if (snapshotJson === lastSavedDataRef.current) {
|
if (snapshotJson === lastSavedDataRef.current) {
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
await api.revisions.create(id, snapshot, 'Auto-save');
|
await api.drawings.autosave(id, snapshot);
|
||||||
lastSavedDataRef.current = snapshotJson;
|
lastSavedDataRef.current = snapshotJson;
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -427,10 +478,16 @@ export const Editor: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [id, isSaving]);
|
}, [id]);
|
||||||
|
|
||||||
|
// Keep ref in sync with latest saveDrawing closure
|
||||||
|
useEffect(() => {
|
||||||
|
saveDrawingRef.current = saveDrawing;
|
||||||
|
}, [saveDrawing]);
|
||||||
|
|
||||||
// Remove unused revisions warning by displaying count in UI
|
// Remove unused revisions warning by displaying count in UI
|
||||||
const revisionCount = revisions.length;
|
const meaningfulRevisions = revisions.filter((r) => r.change_summary !== 'Auto-save');
|
||||||
|
const revisionCount = meaningfulRevisions.length;
|
||||||
|
|
||||||
// Restore a specific revision
|
// Restore a specific revision
|
||||||
const handleRestoreRevision = (revision: DrawingRevision) => {
|
const handleRestoreRevision = (revision: DrawingRevision) => {
|
||||||
@@ -450,12 +507,40 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Manual save
|
// Manual save: creates a named revision
|
||||||
const handleManualSave = async () => {
|
const handleManualSave = async () => {
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (!id || !currentStateRef.current) return;
|
||||||
|
const snapshot = {
|
||||||
|
type: 'excalidraw',
|
||||||
|
version: 2,
|
||||||
|
source: window.location.hostname,
|
||||||
|
elements: currentStateRef.current.elements,
|
||||||
|
appState: currentStateRef.current.appState,
|
||||||
|
files: currentStateRef.current.files,
|
||||||
|
};
|
||||||
|
const snapshotJson = JSON.stringify(snapshot);
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveStatus('saving');
|
||||||
|
// Create a named revision for manual save
|
||||||
|
await api.revisions.create(id, snapshot, 'Manual save');
|
||||||
|
lastSavedDataRef.current = snapshotJson;
|
||||||
|
setSaveStatus('saved');
|
||||||
|
// Refresh revisions list
|
||||||
|
try {
|
||||||
|
const revData = await api.revisions.list(id);
|
||||||
|
setRevisions(revData);
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save:', err);
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
await saveDrawing();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ctrl+S keyboard shortcut
|
// Ctrl+S keyboard shortcut
|
||||||
@@ -512,9 +597,20 @@ export const Editor: React.FC = () => {
|
|||||||
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: null },
|
{ id: 'wireframe', label: 'Wireframe', description: 'Editable page layout', icon: null },
|
||||||
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: null },
|
{ id: 'mindmap', label: 'Mind Map', description: 'Central idea with + branches', icon: null },
|
||||||
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a topic', icon: null },
|
{ id: 'brainstorm', label: 'Brainstorm', description: 'Ideas around a topic', icon: null },
|
||||||
|
{ id: 'brainstorm-star', label: 'Star Brainstorm', description: 'Radial branches from core', icon: null },
|
||||||
|
{ id: 'brainstorm-matrix', label: 'Matrix Brainstorm', description: '2×2 grid for ideas', icon: null },
|
||||||
|
{ id: 'brainstorm-freeform', label: 'Freeform Notes', description: 'Scattered sticky notes', icon: null },
|
||||||
|
{ id: 'brainstorm-fishbone', label: 'Fishbone Diagram', description: 'Root-cause analysis', icon: null },
|
||||||
|
{ id: 'brainstorm-venn', label: 'Venn Diagram', description: 'Compare overlapping sets', icon: null },
|
||||||
|
{ id: 'brainstorm-tree', label: 'Tree Diagram', description: 'Hierarchical branching', icon: null },
|
||||||
|
{ id: 'brainstorm-converge', label: 'Converge Map', description: 'Ideas into solution', icon: null },
|
||||||
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: null },
|
{ id: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: null },
|
||||||
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opps, threats', icon: null },
|
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opps, threats', icon: null },
|
||||||
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: null },
|
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: null },
|
||||||
|
{ id: 'er-diagram', label: 'ER Diagram', description: 'Entity relationship tables', icon: null },
|
||||||
|
{ id: 'api-design', label: 'API Design', description: 'REST endpoints and methods', icon: null },
|
||||||
|
{ id: 'sitemap', label: 'Site Map', description: 'Website page hierarchy', icon: null },
|
||||||
|
{ id: 'user-persona', label: 'User Persona', description: 'Goals, frustrations, behaviors', icon: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -529,6 +625,122 @@ export const Editor: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [excalidrawAPI]);
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
|
// Library import from URL hash (#addLibrary=...)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!excalidrawAPI) return;
|
||||||
|
const hash = window.location.hash;
|
||||||
|
const match = hash.match(/addLibrary=([^&]+)/);
|
||||||
|
if (match) {
|
||||||
|
const libraryUrl = decodeURIComponent(match[1]);
|
||||||
|
fetch(libraryUrl)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
// Excalidraw library items come in various formats
|
||||||
|
let libraryItems = data.libraryItems || data.library || data;
|
||||||
|
// Normalize to Excalidraw's expected library item format: { id, elements, status }
|
||||||
|
if (Array.isArray(libraryItems)) {
|
||||||
|
libraryItems = libraryItems.map((item: any) => {
|
||||||
|
if (item.libraryItem) {
|
||||||
|
return { id: item.id || item.libraryItem.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.libraryItem.elements || [], status: 'published' };
|
||||||
|
}
|
||||||
|
if (item.data) {
|
||||||
|
return { id: item.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.data.elements || item.elements || [], status: 'published' };
|
||||||
|
}
|
||||||
|
if (item.elements) {
|
||||||
|
return { id: item.id || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.elements, status: 'published' };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Use the Excalidraw imperative API to add library items
|
||||||
|
try {
|
||||||
|
const api = excalidrawAPI as any;
|
||||||
|
if (api.updateLibraryItems) {
|
||||||
|
api.updateLibraryItems(libraryItems, 'merge');
|
||||||
|
} else if (api.updateScene) {
|
||||||
|
// Fallback: add elements directly to the canvas at center
|
||||||
|
const currentElements = api.getSceneElements?.() || [];
|
||||||
|
const newElements = libraryItems.flatMap((item: any) => item.elements || []);
|
||||||
|
if (newElements.length > 0) {
|
||||||
|
api.updateScene({
|
||||||
|
elements: [...currentElements, ...newElements] as ExcalidrawElement[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Library import failed:', e);
|
||||||
|
}
|
||||||
|
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Failed to load library:', err));
|
||||||
|
}
|
||||||
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
|
// Build slides: first slide is whole canvas, then each frame is a slide
|
||||||
|
useEffect(() => {
|
||||||
|
if (!presentationMode || !excalidrawAPI) return;
|
||||||
|
const currentElements = (excalidrawAPI.getSceneElements?.() || []) as ExcalidrawElement[];
|
||||||
|
const frameElements = currentElements
|
||||||
|
.filter((el: any) => el.type === 'frame')
|
||||||
|
.sort((a: any, b: any) => (a.y - b.y) || (a.x - b.x));
|
||||||
|
const allSlides: ExcalidrawElement[] = [];
|
||||||
|
// Slide 0: whole canvas (represented by a virtual placeholder)
|
||||||
|
if (currentElements.length > 0) {
|
||||||
|
allSlides.push({ id: '__whole_canvas__', type: 'frame', x: 0, y: 0, width: 1, height: 1, name: 'Canvas', isDeleted: false } as any);
|
||||||
|
}
|
||||||
|
// Subsequent slides: frames
|
||||||
|
frameElements.forEach((f: any) => allSlides.push(f));
|
||||||
|
setSlides(allSlides);
|
||||||
|
setSlideIndex(0);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const api = excalidrawAPI as any;
|
||||||
|
if (allSlides.length > 0 && api.scrollToContent) {
|
||||||
|
if (allSlides[0].id === '__whole_canvas__') {
|
||||||
|
api.zoomToFit?.();
|
||||||
|
} else {
|
||||||
|
api.scrollToContent?.([allSlides[0]], { fitToContent: true, animate: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}, [presentationMode, excalidrawAPI]);
|
||||||
|
|
||||||
|
// Presentation keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!presentationMode) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlideIndex((prev) => Math.min(prev + 1, slides.length - 1));
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlideIndex((prev) => Math.max(prev - 1, 0));
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlideIndex(0);
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlideIndex(slides.length - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [presentationMode, slides.length]);
|
||||||
|
|
||||||
|
// Scroll to current slide when slideIndex changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!presentationMode || !excalidrawAPI || slides.length === 0) return;
|
||||||
|
const currentSlide = slides[slideIndex];
|
||||||
|
if (!currentSlide) return;
|
||||||
|
const api = excalidrawAPI as any;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (currentSlide.id === '__whole_canvas__') {
|
||||||
|
api.zoomToFit?.();
|
||||||
|
} else if (api.scrollToContent) {
|
||||||
|
api.scrollToContent?.([currentSlide], { fitToContent: true, animate: true });
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}, [slideIndex, slides, presentationMode, excalidrawAPI]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -555,7 +767,12 @@ export const Editor: React.FC = () => {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={`${styles.toolbar} ${presentationMode ? styles.toolbarHidden : ''}`}>
|
<div className={`${styles.toolbar} ${presentationMode ? styles.toolbarHidden : ''}`}>
|
||||||
<div className={styles.left}>
|
<div className={styles.left}>
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
|
<Button variant="ghost" size="sm" onClick={async () => {
|
||||||
|
if (saveStatus === 'unsaved') {
|
||||||
|
await saveDrawingRef.current();
|
||||||
|
}
|
||||||
|
navigate(drawing?.folder_id ? `/folder/${drawing.folder_id}` : '/files');
|
||||||
|
}}>
|
||||||
<ArrowLeft size={18} />
|
<ArrowLeft size={18} />
|
||||||
{t('editor.back')}
|
{t('editor.back')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -607,6 +824,68 @@ export const Editor: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<LayoutTemplate size={16} />
|
<LayoutTemplate size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!excalidrawAPI) return;
|
||||||
|
const appState = excalidrawAPI.getAppState?.() || {};
|
||||||
|
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
|
||||||
|
const elements = excalidrawAPI.getSceneElements?.() || [];
|
||||||
|
const selectedEls = elements.filter((el) => selectedIds.includes(el.id));
|
||||||
|
if (selectedEls.length === 0) {
|
||||||
|
alert('Select elements on canvas to create a slide');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
selectedEls.forEach((el) => {
|
||||||
|
minX = Math.min(minX, el.x);
|
||||||
|
minY = Math.min(minY, el.y);
|
||||||
|
maxX = Math.max(maxX, el.x + el.width);
|
||||||
|
maxY = Math.max(maxY, el.y + el.height);
|
||||||
|
});
|
||||||
|
const padding = 40;
|
||||||
|
const frameEl = {
|
||||||
|
id: `frame-${Math.random().toString(36).slice(2)}`,
|
||||||
|
type: 'frame',
|
||||||
|
x: minX - padding,
|
||||||
|
y: minY - padding,
|
||||||
|
width: maxX - minX + padding * 2,
|
||||||
|
height: maxY - minY + padding * 2,
|
||||||
|
angle: 0,
|
||||||
|
strokeColor: '#1e1e1e',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
fillStyle: 'hachure' as const,
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeStyle: 'solid' as const,
|
||||||
|
roughness: 1,
|
||||||
|
opacity: 100,
|
||||||
|
groupIds: [],
|
||||||
|
roundness: null,
|
||||||
|
seed: Math.floor(Math.random() * 10000),
|
||||||
|
version: 2,
|
||||||
|
versionNonce: Math.floor(Math.random() * 100000),
|
||||||
|
isDeleted: false,
|
||||||
|
boundElements: [],
|
||||||
|
updated: Date.now(),
|
||||||
|
link: null,
|
||||||
|
locked: false,
|
||||||
|
customData: { templateRole: 'slide' },
|
||||||
|
name: `Slide ${elements.filter((e) => e.type === 'frame').length + 1}`,
|
||||||
|
};
|
||||||
|
isMutatingSceneRef.current = true;
|
||||||
|
excalidrawAPI.updateScene({
|
||||||
|
elements: [...elements, frameEl] as ExcalidrawElement[],
|
||||||
|
appState: { ...appState, selectedElementIds: { [frameEl.id]: true } },
|
||||||
|
});
|
||||||
|
window.setTimeout(() => { isMutatingSceneRef.current = false; }, 50);
|
||||||
|
setSaveStatus('unsaved');
|
||||||
|
}}
|
||||||
|
title="Create slide from selection"
|
||||||
|
aria-label="Create a presentation slide from selected elements"
|
||||||
|
>
|
||||||
|
<Frame size={16} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -662,10 +941,10 @@ export const Editor: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.revisionList}>
|
<div className={styles.revisionList}>
|
||||||
{revisions.length === 0 ? (
|
{meaningfulRevisions.length === 0 ? (
|
||||||
<p className={styles.revisionEmpty}>{t('editor.noRevisions')}</p>
|
<p className={styles.revisionEmpty}>{t('editor.noRevisions')}</p>
|
||||||
) : (
|
) : (
|
||||||
revisions.map((rev) => (
|
meaningfulRevisions.map((rev) => (
|
||||||
<button
|
<button
|
||||||
key={rev.id}
|
key={rev.id}
|
||||||
className={`${styles.revisionItem} ${selectedRevision === rev.id ? styles.revisionActive : ''}`}
|
className={`${styles.revisionItem} ${selectedRevision === rev.id ? styles.revisionActive : ''}`}
|
||||||
@@ -731,11 +1010,45 @@ export const Editor: React.FC = () => {
|
|||||||
{presentationMode && (
|
{presentationMode && (
|
||||||
<div className={styles.presentationOverlay} role="presentation">
|
<div className={styles.presentationOverlay} role="presentation">
|
||||||
<div className={styles.presentationToolbar}>
|
<div className={styles.presentationToolbar}>
|
||||||
<span className={styles.presentationLabel}>Presentation Mode — Press Esc to exit</span>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSlideIndex((prev) => Math.max(prev - 1, 0))}
|
||||||
|
disabled={slideIndex <= 0}
|
||||||
|
aria-label="Previous slide"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</Button>
|
||||||
|
<span className={styles.presentationLabel}>
|
||||||
|
Slide {slides.length > 0 ? slideIndex + 1 : 0} / {slides.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSlideIndex((prev) => Math.min(prev + 1, slides.length - 1))}
|
||||||
|
disabled={slideIndex >= slides.length - 1}
|
||||||
|
aria-label="Next slide"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setPresentationMode(false)} aria-label="Exit presentation">
|
<Button variant="ghost" size="sm" onClick={() => setPresentationMode(false)} aria-label="Exit presentation">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.presentationSlides}>
|
||||||
|
{slides.map((slide, idx) => (
|
||||||
|
<button
|
||||||
|
key={slide.id || idx}
|
||||||
|
className={`${styles.presentationSlideThumb} ${idx === slideIndex ? styles.presentationSlideActive : ''}`}
|
||||||
|
onClick={() => setSlideIndex(idx)}
|
||||||
|
aria-label={`Go to slide ${idx + 1}`}
|
||||||
|
title={idx === 0 ? 'Whole canvas' : (slide as any).name || `Slide ${idx}`}
|
||||||
|
>
|
||||||
|
<div className={styles.presentationSlideNumber}>{idx + 1}</div>
|
||||||
|
<div className={styles.presentationSlideName}>{idx === 0 ? 'Canvas' : ((slide as any).name || `Slide ${idx}`)}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -140,12 +139,12 @@
|
|||||||
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);
|
||||||
@@ -153,17 +152,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.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@@ -228,15 +224,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 +306,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 +322,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);
|
||||||
|
|
||||||
@@ -371,58 +365,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 +457,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 +469,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 +504,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 +513,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 +526,27 @@
|
|||||||
|
|
||||||
.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,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[]> =>
|
||||||
|
|||||||
@@ -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 |
|
|
||||||
```
|
|
||||||
+50
-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)
|
||||||
@@ -134,6 +135,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 +159,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 +382,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 {
|
||||||
@@ -639,7 +680,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
|
||||||
|
|||||||
@@ -635,6 +635,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
|
||||||
|
|||||||
Reference in New Issue
Block a user