feat(ui,api,db): implement notifications and custom templates with hand-drawn aesthetic

This commit introduces a significant update to both the frontend and backend, focusing on enhanced user engagement and a consistent visual identity.

Key changes include:

- **Frontend UI/UX Refactor**:
  - Implemented a "hand-drawn" aesthetic across the entire application using CSS overrides, custom SVG charts, and specific border/shadow styles to match the Excalidraw experience.
  - Added a new notification system in the Header to display user updates.
  - Enhanced the Template Picker with more variety and improved interaction models.
  - Added a "Presentation Mode" in the Editor.
  - Improved Dashboard visualizations with hand-drawn style sparklines and charts.
  - Added modal dialogs for creating drawings and templates with custom names.

- **Backend & API Enhancements**:
  - Implemented full CRUD support for custom templates, allowing users to save their drawings as reusable templates.
  - Added a notification service with endpoints to list, mark as read, and mark all as read.
  - Updated the API client to handle more robust JSON responses and error states.
  - Improved CORS/Origin validation in the HTTP middleware to handle proxy headers (`X-Forwarded-Host`, `X-Forwarded-Proto`) more reliably.

- **Database & Infrastructure**:
  - Added a new PostgreSQL migration for the `notifications` table.
  - Updated the data models in the workspace to support templates (including snapshot storage) and notifications.
  - Updated `.gitignore` to exclude graphify cache and AST files.
This commit is contained in:
Tomas Dvorak
2026-05-01 15:07:38 +02:00
parent f3f9e99a97
commit 462a70933d
28 changed files with 26645 additions and 289 deletions
+7
View File
@@ -132,5 +132,12 @@ excalidraw-complete.exe
# cf-kv worker output # cf-kv worker output
cf-kv/index.js cf-kv/index.js
# graphify cache and AST (keep output html/json)
graphify-out/cache/
graphify-out/.graphify_ast.json
graphify-out/.graphify_detect.json
graphify-out/.graphify_python
graphify-out/.graphify_semantic.json
# Keep dist/.keep for empty directory tracking in git # Keep dist/.keep for empty directory tracking in git
!frontend/dist/.keep !frontend/dist/.keep
+1
View File
@@ -10,6 +10,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<script>try{var t=JSON.parse(localStorage.getItem('excalidraw-theme')||'{}').state?.theme;if(t)document.documentElement.setAttribute('data-theme',t)}catch(e){}</script>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+32
View File
@@ -6,3 +6,35 @@
font-size: var(--text-lg); font-size: var(--text-lg);
color: var(--color-muted); color: var(--color-muted);
} }
/* Excalidraw Context Menu Styling Overrides */
:global(.excalidraw .context-menu) {
background: var(--island-bg-color) !important;
border: 2px solid var(--color-gray-85) !important;
border-radius: 2px !important;
box-shadow: 4px 4px 0 var(--color-gray-85) !important;
transform: rotate(-0.2deg) !important;
padding: 2px !important;
}
:global(.excalidraw .context-menu-item) {
border-radius: 2px !important;
color: var(--color-gray-85) !important;
font-weight: 500 !important;
padding: 6px 12px !important;
}
:global(.excalidraw .context-menu-item:hover) {
background: var(--color-primary-light) !important;
color: var(--color-primary-darkest) !important;
transform: translateX(1px) !important;
}
:global(.excalidraw .context-menu-item-separator) {
border-top: 2px solid var(--color-gray-30) !important;
margin: 2px 4px !important;
}
:global(.excalidraw .context-menu-item-keybinding) {
color: var(--color-muted) !important;
}
+104 -5
View File
@@ -1,13 +1,23 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Search, Bell, Plus, FileText, Loader2, Sun, Moon } from 'lucide-react'; import { Search, Bell, Plus, FileText, Loader2, Sun, Moon, Check, X } from 'lucide-react';
import { Button } from '@/components'; import { Button } from '@/components';
import { useThemeStore } from '@/stores'; import { useThemeStore } from '@/stores';
import { api } from '@/services'; import { api } from '@/services';
import type { Drawing } from '@/types'; import type { Drawing } from '@/types';
import styles from './Layout.module.scss'; import styles from './Layout.module.scss';
interface AppNotification {
id: string;
type: 'share' | 'comment' | 'mention' | 'update';
title: string;
description: string;
time: string;
read: boolean;
drawingId?: string;
}
export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) => { export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,9 +27,37 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const [showNameModal, setShowNameModal] = useState(false);
const [newDrawingName, setNewDrawingName] = useState('');
const [showNotifications, setShowNotifications] = useState(false);
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const searchRef = useRef<HTMLDivElement>(null); const searchRef = useRef<HTMLDivElement>(null);
const notifRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined); const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const unreadCount = notifications.filter((n) => !n.read).length;
// Fetch notifications on mount
useEffect(() => {
const fetchNotifications = async () => {
try {
const res = await api.notifications.list();
setNotifications(res as unknown as AppNotification[]);
} catch {
setNotifications([]);
}
};
fetchNotifications();
}, []);
const markAllRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
};
const dismissNotification = (id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
};
const performSearch = useCallback(async (q: string) => { const performSearch = useCallback(async (q: string) => {
if (!q.trim()) { if (!q.trim()) {
setResults([]); setResults([]);
@@ -56,11 +94,18 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
} }
}; };
const handleCreateDrawing = async () => { const handleCreateDrawing = () => {
setNewDrawingName('');
setShowNameModal(true);
};
const confirmCreateDrawing = async () => {
const title = newDrawingName.trim() || 'Untitled Drawing';
setIsCreating(true); setIsCreating(true);
setShowNameModal(false);
try { try {
const drawing = await api.drawings.create({ const drawing = await api.drawings.create({
title: 'Untitled Drawing', title,
visibility: 'team', visibility: 'team',
}); });
navigate(`/drawing/${drawing.id}`); navigate(`/drawing/${drawing.id}`);
@@ -76,6 +121,9 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
if (!searchRef.current?.contains(e.target as Node)) { if (!searchRef.current?.contains(e.target as Node)) {
setShowResults(false); setShowResults(false);
} }
if (!notifRef.current?.contains(e.target as Node)) {
setShowNotifications(false);
}
}; };
document.addEventListener('mousedown', onClick); document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick);
@@ -125,18 +173,69 @@ export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) =
)} )}
</div> </div>
<div className={styles.actions}> <div className={styles.actions} ref={notifRef}>
<button className={styles.iconButton} onClick={toggleTheme} title={t('userSettings.theme')} aria-label={t('userSettings.theme')}> <button className={styles.iconButton} onClick={toggleTheme} title={t('userSettings.theme')} aria-label={t('userSettings.theme')}>
{theme === 'light' ? <Sun size={20} aria-hidden="true" /> : <Moon size={20} aria-hidden="true" />} {theme === 'light' ? <Sun size={20} aria-hidden="true" /> : <Moon size={20} aria-hidden="true" />}
</button> </button>
<button className={styles.iconButton} aria-label="Notifications" title="Notifications"> <button className={styles.iconButton} aria-label="Notifications" title="Notifications" onClick={() => setShowNotifications((v) => !v)}>
<Bell size={20} aria-hidden="true" /> <Bell size={20} aria-hidden="true" />
{unreadCount > 0 && <span className={styles.notifBadge}>{unreadCount}</span>}
</button> </button>
{showNotifications && (
<div className={styles.notifDropdown}>
<div className={styles.notifHeader}>
<span className={styles.notifTitle}>Notifications</span>
<button className={styles.notifMarkAll} onClick={markAllRead} aria-label="Mark all as read">
<Check size={14} aria-hidden="true" /> All read
</button>
</div>
{notifications.length === 0 ? (
<div className={styles.notifEmpty}>No notifications yet</div>
) : (
notifications.map((n) => (
<div key={n.id} className={`${styles.notifItem} ${!n.read ? styles.notifUnread : ''}`}>
<div className={styles.notifContent}>
<div className={styles.notifItemTitle}>{n.title}</div>
<div className={styles.notifItemDesc}>{n.description}</div>
<div className={styles.notifItemTime}>{n.time}</div>
</div>
<button className={styles.notifDismiss} onClick={() => dismissNotification(n.id)} aria-label="Dismiss notification">
<X size={14} aria-hidden="true" />
</button>
</div>
))
)}
</div>
)}
<Button onClick={handleCreateDrawing} loading={isCreating}> <Button onClick={handleCreateDrawing} loading={isCreating}>
<Plus size={18} /> <Plus size={18} />
{t('dashboard.newDrawing')} {t('dashboard.newDrawing')}
</Button> </Button>
</div> </div>
{showNameModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" onClick={(e) => { if (e.target === e.currentTarget) setShowNameModal(false); }}>
<div className={styles.nameModal}>
<h3>New Drawing</h3>
<input
autoFocus
type="text"
placeholder="Drawing name..."
value={newDrawingName}
onChange={(e) => setNewDrawingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') confirmCreateDrawing();
if (e.key === 'Escape') setShowNameModal(false);
}}
className={styles.nameInput}
/>
<div className={styles.nameModalActions}>
<button className={styles.nameModalCancel} onClick={() => setShowNameModal(false)}>Cancel</button>
<button className={styles.nameModalConfirm} onClick={confirmCreateDrawing}>Create</button>
</div>
</div>
</div>
)}
</header> </header>
); );
}; };
@@ -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: 1px solid var(--color-gray-20); border-right: 2px solid var(--color-gray-85);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--space-4); padding: var(--space-4);
@@ -18,6 +18,15 @@
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%);
@@ -71,6 +80,13 @@
min-width: 0; min-width: 0;
} }
.logoImg {
width: 28px;
height: 28px;
flex-shrink: 0;
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.1));
}
.logoMark { .logoMark {
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -125,18 +141,25 @@
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-radius: var(--border-radius-md); border: 2px solid transparent;
border-radius: 2px;
transition: all var(--duration-fast) var(--ease-out); transition: all var(--duration-fast) var(--ease-out);
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);
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: 500; font-weight: 600;
border-color: var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-0.3deg);
} }
} }
@@ -213,7 +236,8 @@
.header { .header {
height: var(--header-height); height: var(--header-height);
background: var(--island-bg-color); background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20); border-bottom: 2px solid var(--color-gray-85);
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;
@@ -267,23 +291,28 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
position: relative;
} }
.iconButton { .iconButton {
position: relative;
background: none; background: none;
border: none; border: 2px 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: var(--border-radius-md); border-radius: 2px;
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all var(--duration-fast) var(--ease-out); transition: all var(--duration-fast) var(--ease-out);
&:hover { &:hover {
background: var(--color-surface-low);
color: var(--color-on-surface); color: var(--color-on-surface);
background: var(--color-surface-low);
border-color: var(--color-gray-30);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-1deg);
} }
} }
@@ -354,3 +383,206 @@
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--color-gray-50); color: var(--color-gray-50);
} }
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.nameModal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: var(--modal-shadow);
padding: var(--space-5);
width: 360px;
max-width: 90vw;
h3 {
margin: 0 0 var(--space-4);
font-size: var(--text-lg);
color: var(--color-gray-85);
}
}
.nameInput {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
background: var(--input-bg-color);
color: var(--color-on-surface);
font-size: var(--text-sm);
outline: none;
margin-bottom: var(--space-4);
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.nameModalActions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.nameModalCancel {
padding: var(--space-2) var(--space-4);
border-radius: var(--border-radius-md);
border: 1px solid var(--color-gray-20);
background: transparent;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
&:hover {
background: var(--color-surface-low);
}
}
.nameModalConfirm {
padding: var(--space-2) var(--space-4);
border-radius: var(--border-radius-md);
border: none;
background: var(--color-primary);
color: white;
font-size: var(--text-sm);
cursor: pointer;
&:hover {
background: var(--color-primary-darker);
}
}
// Notification dropdown styles
.notifBadge {
position: absolute;
top: 4px;
right: 4px;
background: #e03131;
color: white;
font-size: 10px;
font-weight: 700;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.notifDropdown {
position: absolute;
top: calc(100% + var(--space-2));
right: 100px;
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 5px 5px 0 var(--color-gray-85);
width: 320px;
max-height: 400px;
overflow-y: auto;
z-index: 100;
transform: rotate(-0.2deg);
}
.notifHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 2px solid var(--color-gray-85);
}
.notifTitle {
font-weight: 600;
font-size: var(--text-sm);
color: var(--color-gray-85);
font-family: 'Georgia', serif;
}
.notifMarkAll {
background: none;
border: none;
font-size: var(--text-xs);
color: var(--color-primary);
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
&:hover {
text-decoration: underline;
}
}
.notifEmpty {
padding: var(--space-6);
text-align: center;
color: var(--color-muted);
font-size: var(--text-sm);
}
.notifItem {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
transition: background 0.15s ease;
&:hover {
background: var(--color-surface-low);
}
&:last-child {
border-bottom: none;
}
}
.notifUnread {
border-left: 3px solid var(--color-primary);
}
.notifContent {
flex: 1;
}
.notifItemTitle {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-85);
}
.notifItemDesc {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: 2px;
}
.notifItemTime {
font-size: var(--text-xs);
color: var(--color-muted);
opacity: 0.7;
margin-top: 2px;
}
.notifDismiss {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: 2px;
flex-shrink: 0;
&:hover {
color: #e03131;
}
}
+7 -1
View File
@@ -37,7 +37,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ open, onClose }) => {
> >
<div className={styles.sidebarHeader}> <div className={styles.sidebarHeader}>
<div className={styles.logo}> <div className={styles.logo}>
<span className={styles.logoMark} aria-hidden="true">E</span> <img
src="https://plus.excalidraw.com/images/logo.svg"
alt="Excalidraw"
className={styles.logoImg}
width={28}
height={28}
/>
<span className={styles.logoText}>Excalidraw</span> <span className={styles.logoText}>Excalidraw</span>
</div> </div>
{onClose && ( {onClose && (
@@ -13,13 +13,15 @@
.modal { .modal {
background: var(--island-bg-color); background: var(--island-bg-color);
border-radius: var(--border-radius-xl); border: 2px solid var(--color-gray-85);
box-shadow: var(--modal-shadow); border-radius: 2px;
box-shadow: 8px 8px 0 var(--color-gray-85);
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 {
@@ -40,15 +42,17 @@
.closeBtn { .closeBtn {
background: none; background: none;
border: none; border: 2px 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: var(--border-radius-md); border-radius: 2px;
&:hover { &:hover {
background: var(--color-gray-10); border-color: var(--color-gray-85);
color: var(--color-gray-90); color: var(--color-gray-90);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-1deg);
} }
} }
@@ -65,12 +69,21 @@
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 transparent; border: 2px solid var(--color-gray-30);
border-radius: 2px;
background: var(--island-bg-color);
box-shadow: 2px 2px 0 var(--color-gray-85);
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); transform: translateY(-2px) rotate(-0.3deg);
box-shadow: 4px 4px 0 var(--color-gray-85);
}
&:active {
transform: translateY(0) rotate(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 } from 'lucide-react'; import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool, KanbanSquare, MessageSquare, PanelsTopLeft, GitFork, Lightbulb, RotateCcw, Shield, Map, Timer, Layers } 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'; export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow' | 'kanban' | 'meeting' | 'wireframe' | 'mindmap' | 'brainstorm' | 'retrospective' | 'swot' | 'storymap' | 'timeline' | 'architecture';
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) { function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string, groupId?: string) {
return { return {
id: `el-${Math.random().toString(36).slice(2)}`, id: `el-${Math.random().toString(36).slice(2)}`,
type: 'rectangle', type: 'rectangle',
@@ -34,7 +34,7 @@ function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: st
strokeStyle: 'solid', strokeStyle: 'solid',
roughness: 1, roughness: 1,
opacity: 100, opacity: 100,
groupIds: [], groupIds: groupId ? [groupId] : [],
frameId: null, frameId: null,
roundness: { type: 3, value: 32 }, roundness: { type: 3, value: 32 },
seed: Math.floor(Math.random() * 10000), seed: Math.floor(Math.random() * 10000),
@@ -88,6 +88,7 @@ function makeCheckbox(x: number, y: number, checked = false) {
const box = makeHandDrawnRect(x, y, 20, 20); const box = makeHandDrawnRect(x, y, 20, 20);
return Object.assign(box, { return Object.assign(box, {
backgroundColor: checked ? '#a5eba8' : 'transparent', backgroundColor: checked ? '#a5eba8' : 'transparent',
fillStyle: checked ? 'solid' : 'hachure',
customData: { customData: {
templateRole: 'checkbox', templateRole: 'checkbox',
checked, checked,
@@ -95,6 +96,56 @@ function makeCheckbox(x: number, y: number, checked = false) {
}); });
} }
function makeAddButton(x: number, y: number, label: string, templateRole: string) {
const btn = makeHandDrawnRect(x, y, 24, 24);
return Object.assign(btn, {
backgroundColor: '#d0ecff',
fillStyle: 'solid',
strokeColor: '#1971c2',
roundness: { type: 3, value: 12 },
customData: { templateRole, action: 'add', label },
});
}
function makeArrow(x1: number, y1: number, x2: number, y2: number) {
return {
id: `arrow-${Math.random().toString(36).slice(2)}`,
type: 'arrow',
x: x1,
y: y1,
width: x2 - x1,
height: y2 - y1,
angle: 0,
strokeColor: '#1e1e1e',
backgroundColor: 'transparent',
fillStyle: 'hachure',
strokeWidth: 1,
strokeStyle: 'solid',
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 2 },
seed: Math.floor(Math.random() * 10000),
version: 2,
versionNonce: Math.floor(Math.random() * 100000),
isDeleted: false,
boundElements: [],
updated: Date.now(),
link: null,
locked: false,
startBinding: null,
endBinding: null,
lastCommittedPoint: null,
startArrowhead: null,
endArrowhead: 'arrow',
points: [
[0, 0],
[x2 - x1, y2 - y1],
],
};
}
export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = { export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
blank: [], blank: [],
todo: [ todo: [
@@ -106,8 +157,10 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(90, 170, 'Second task'), makeText(90, 170, 'Second task'),
makeCheckbox(60, 210, false), makeCheckbox(60, 210, false),
makeText(90, 210, 'Third task'), makeText(90, 210, 'Third task'),
makeHandDrawnRect(50, 280, 500, 2), makeAddButton(60, 250, '+', 'todo-add'),
makeText(60, 300, 'Notes:', 18), makeText(92, 250, 'Add task...', 16),
makeHandDrawnRect(50, 290, 500, 2),
makeText(60, 310, 'Notes:', 18),
], ],
checklist: [ checklist: [
makeHandDrawnRect(50, 50, 500, 50), makeHandDrawnRect(50, 50, 500, 50),
@@ -118,28 +171,33 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(90, 170, 'Pending item', 18), makeText(90, 170, 'Pending item', 18),
makeCheckbox(60, 210, false), makeCheckbox(60, 210, false),
makeText(90, 210, 'Another task', 18), makeText(90, 210, 'Another task', 18),
makeHandDrawnRect(60, 250, 480, 1), makeAddButton(60, 250, '+', 'checklist-add'),
makeText(70, 265, 'Add more items below', 14), makeText(92, 250, 'Add item...', 16),
], ],
list: [ list: [
makeHandDrawnRect(50, 50, 500, 50), makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'Bullet List', 28), makeText(70, 65, 'Bullet List', 28),
makeText(60, 130, '- First bullet point'), makeText(60, 130, ' First bullet point'),
makeText(60, 170, '- Second bullet point'), makeText(60, 170, ' Second bullet point'),
makeText(60, 210, '- Third bullet point'), makeText(60, 210, ' Third bullet point'),
makeText(60, 250, '- Fourth item with details'), makeText(60, 250, ' Fourth item with details'),
makeHandDrawnRect(50, 300, 500, 2), makeAddButton(60, 290, '+', 'list-add'),
makeText(60, 320, 'Add your own items...', 14), makeText(92, 290, 'Add bullet...', 16),
], ],
flow: [ flow: [
makeHandDrawnRect(200, 50, 200, 60), makeHandDrawnRect(200, 50, 200, 60),
makeText(230, 70, 'Start', 20), makeText(230, 70, 'Start', 20),
makeArrow(300, 110, 300, 150),
makeHandDrawnRect(200, 150, 200, 60), makeHandDrawnRect(200, 150, 200, 60),
makeText(220, 170, 'Process A', 20), makeText(220, 170, 'Process A', 20),
makeArrow(300, 210, 300, 250),
makeHandDrawnRect(200, 250, 200, 60), makeHandDrawnRect(200, 250, 200, 60),
makeText(220, 270, 'Process B', 20), makeText(220, 270, 'Process B', 20),
makeArrow(300, 310, 300, 350),
makeHandDrawnRect(200, 350, 200, 60), makeHandDrawnRect(200, 350, 200, 60),
makeText(230, 370, 'End', 20), makeText(230, 370, 'End', 20),
makeAddButton(420, 180, '+', 'flow-add'),
makeText(452, 180, 'Add step', 14),
], ],
kanban: [ kanban: [
makeText(50, 40, 'Kanban Board', 30), makeText(50, 40, 'Kanban Board', 30),
@@ -149,12 +207,19 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(75, 120, 'Backlog', 20), makeText(75, 120, 'Backlog', 20),
makeText(285, 120, 'Doing', 20), makeText(285, 120, 'Doing', 20),
makeText(495, 120, 'Done', 20), makeText(495, 120, 'Done', 20),
makeHandDrawnRect(70, 170, 140, 70), // Card 1 - grouped
makeHandDrawnRect(70, 170, 140, 70, undefined, 'card1'),
makeText(85, 190, 'User research', 16), makeText(85, 190, 'User research', 16),
makeHandDrawnRect(280, 170, 140, 70), // Card 2 - grouped
makeHandDrawnRect(280, 170, 140, 70, undefined, 'card2'),
makeText(295, 190, 'Sketch flow', 16), makeText(295, 190, 'Sketch flow', 16),
makeHandDrawnRect(490, 170, 140, 70), // Card 3 - grouped
makeHandDrawnRect(490, 170, 140, 70, undefined, 'card3'),
makeText(505, 190, 'Project brief', 16), makeText(505, 190, 'Project brief', 16),
// Add card buttons per column
makeAddButton(110, 380, '+', 'kanban-add-backlog'),
makeAddButton(320, 380, '+', 'kanban-add-doing'),
makeAddButton(530, 380, '+', 'kanban-add-done'),
], ],
meeting: [ meeting: [
makeText(50, 40, 'Meeting Notes', 30), makeText(50, 40, 'Meeting Notes', 30),
@@ -168,6 +233,8 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(70, 350, 'Action Items', 20), makeText(70, 350, 'Action Items', 20),
makeCheckbox(70, 390, false), makeCheckbox(70, 390, false),
makeText(105, 390, 'Owner and next step', 18), makeText(105, 390, 'Owner and next step', 18),
makeAddButton(70, 430, '+', 'meeting-add-action'),
makeText(102, 430, 'Add action...', 14),
], ],
wireframe: [ wireframe: [
makeText(50, 35, 'Page Wireframe', 30), makeText(50, 35, 'Page Wireframe', 30),
@@ -180,31 +247,198 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(50, 380, 190, 110), makeHandDrawnRect(50, 380, 190, 110),
makeHandDrawnRect(265, 380, 190, 110), makeHandDrawnRect(265, 380, 190, 110),
makeHandDrawnRect(480, 380, 190, 110), makeHandDrawnRect(480, 380, 190, 110),
makeAddButton(480, 500, '+', 'wireframe-add-section'),
makeText(512, 500, 'Add section', 14),
], ],
mindmap: [ mindmap: [
makeHandDrawnRect(240, 200, 200, 70), makeHandDrawnRect(240, 200, 200, 70),
makeText(275, 220, 'Main idea', 22), makeText(275, 220, 'Main idea', 22),
makeArrow(240, 235, 200, 108),
makeHandDrawnRect(50, 80, 150, 55), makeHandDrawnRect(50, 80, 150, 55),
makeText(75, 96, 'Research', 18), makeText(75, 96, 'Research', 18),
makeAddButton(50, 150, '+', 'mindmap-add-research'),
makeArrow(440, 235, 490, 108),
makeHandDrawnRect(490, 80, 150, 55), makeHandDrawnRect(490, 80, 150, 55),
makeText(520, 96, 'Design', 18), makeText(520, 96, 'Design', 18),
makeAddButton(490, 150, '+', 'mindmap-add-design'),
makeArrow(240, 270, 200, 377),
makeHandDrawnRect(50, 350, 150, 55), makeHandDrawnRect(50, 350, 150, 55),
makeText(80, 366, 'Build', 18), makeText(80, 366, 'Build', 18),
makeAddButton(50, 420, '+', 'mindmap-add-build'),
makeArrow(440, 270, 490, 377),
makeHandDrawnRect(490, 350, 150, 55), makeHandDrawnRect(490, 350, 150, 55),
makeText(520, 366, 'Review', 18), makeText(520, 366, 'Review', 18),
makeAddButton(490, 420, '+', 'mindmap-add-review'),
],
brainstorm: [
makeText(50, 30, 'Brainstorm', 30),
makeHandDrawnRect(220, 80, 240, 60),
makeText(280, 100, 'Central Topic', 22),
// Idea bubbles around
makeHandDrawnRect(50, 180, 160, 50),
makeText(70, 196, 'Idea 1', 18),
makeArrow(210, 140, 130, 180),
makeHandDrawnRect(280, 180, 160, 50),
makeText(300, 196, 'Idea 2', 18),
makeArrow(340, 140, 360, 180),
makeHandDrawnRect(500, 180, 160, 50),
makeText(520, 196, 'Idea 3', 18),
makeArrow(460, 110, 580, 180),
makeAddButton(50, 240, '+', 'brainstorm-add'),
makeText(82, 240, 'Add idea...', 16),
// Notes area
makeHandDrawnRect(50, 280, 610, 100),
makeText(70, 300, 'Notes & connections:', 18),
makeText(70, 330, '- Write insights here', 16),
],
retrospective: [
makeText(50, 30, 'Retrospective', 30),
// Went Well
makeHandDrawnRect(50, 90, 200, 250),
makeText(70, 110, 'Went Well ✓', 18),
makeText(70, 150, '- Good thing 1', 16),
makeText(70, 180, '- Good thing 2', 16),
makeAddButton(70, 310, '+', 'retro-add-well'),
// Improve
makeHandDrawnRect(270, 90, 200, 250),
makeText(290, 110, 'Improve ⚡', 18),
makeText(290, 150, '- Issue 1', 16),
makeText(290, 180, '- Issue 2', 16),
makeAddButton(290, 310, '+', 'retro-add-improve'),
// Actions
makeHandDrawnRect(490, 90, 200, 250),
makeText(510, 110, 'Actions →', 18),
makeCheckbox(510, 150, false),
makeText(540, 150, 'Action item 1', 16),
makeCheckbox(510, 180, false),
makeText(540, 180, 'Action item 2', 16),
makeAddButton(510, 310, '+', 'retro-add-action'),
],
swot: [
makeText(50, 30, 'SWOT Analysis', 30),
// Strengths
makeHandDrawnRect(50, 90, 280, 180),
makeText(70, 110, 'Strengths 💪', 20),
makeText(70, 150, '- Advantage 1', 16),
makeText(70, 180, '- Advantage 2', 16),
makeAddButton(70, 240, '+', 'swot-add-strength'),
// Weaknesses
makeHandDrawnRect(350, 90, 280, 180),
makeText(370, 110, 'Weaknesses ⚠️', 20),
makeText(370, 150, '- Weakness 1', 16),
makeText(370, 180, '- Weakness 2', 16),
makeAddButton(370, 240, '+', 'swot-add-weakness'),
// Opportunities
makeHandDrawnRect(50, 290, 280, 180),
makeText(70, 310, 'Opportunities 🔭', 20),
makeText(70, 350, '- Opportunity 1', 16),
makeText(70, 380, '- Opportunity 2', 16),
makeAddButton(70, 440, '+', 'swot-add-opportunity'),
// Threats
makeHandDrawnRect(350, 290, 280, 180),
makeText(370, 310, 'Threats 🛡️', 20),
makeText(370, 350, '- Threat 1', 16),
makeText(370, 380, '- Threat 2', 16),
makeAddButton(370, 440, '+', 'swot-add-threat'),
],
storymap: [
makeText(50, 30, 'User Story Map', 30),
// Epic row
makeHandDrawnRect(50, 80, 600, 50),
makeText(70, 96, 'Epic: User Journey', 20),
// Steps row
makeText(50, 160, 'Steps →', 14),
makeHandDrawnRect(130, 150, 120, 40),
makeText(145, 164, 'Step 1', 16),
makeHandDrawnRect(270, 150, 120, 40),
makeText(285, 164, 'Step 2', 16),
makeHandDrawnRect(410, 150, 120, 40),
makeText(425, 164, 'Step 3', 16),
makeAddButton(550, 155, '+', 'storymap-add-step'),
// Stories
makeText(50, 220, 'Stories ↓', 14),
makeHandDrawnRect(130, 210, 120, 35),
makeText(140, 222, 'Story A', 14),
makeHandDrawnRect(270, 210, 120, 35),
makeText(280, 222, 'Story B', 14),
makeHandDrawnRect(410, 210, 120, 35),
makeText(420, 222, 'Story C', 14),
makeAddButton(130, 255, '+', 'storymap-add-story'),
// Priority labels
makeHandDrawnRect(50, 300, 600, 2),
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
makeAddButton(50, 350, '+', 'storymap-add-row'),
makeText(82, 350, 'Add row...', 14),
],
timeline: [
makeText(50, 30, 'Project Timeline', 30),
makeHandDrawnRect(50, 90, 600, 4),
// Milestones
makeHandDrawnRect(80, 70, 20, 44, undefined, 'milestone-1'),
makeText(60, 125, 'Q1 Kickoff', 14),
makeHandDrawnRect(220, 70, 20, 44, undefined, 'milestone-2'),
makeText(200, 125, 'Design', 14),
makeHandDrawnRect(360, 70, 20, 44, undefined, 'milestone-3'),
makeText(340, 125, 'Build', 14),
makeHandDrawnRect(500, 70, 20, 44, undefined, 'milestone-4'),
makeText(480, 125, 'Launch', 14),
// Tasks below timeline
makeHandDrawnRect(50, 170, 130, 50),
makeText(65, 185, 'Research', 14),
makeHandDrawnRect(200, 170, 130, 50),
makeText(215, 185, 'Prototype', 14),
makeHandDrawnRect(350, 170, 130, 50),
makeText(365, 185, 'Develop', 14),
makeHandDrawnRect(500, 170, 130, 50),
makeText(515, 185, 'Deploy', 14),
makeAddButton(80, 240, '+', 'timeline-add'),
makeText(112, 240, 'Add phase...', 14),
],
architecture: [
makeText(50, 30, 'System Architecture', 30),
// Client
makeHandDrawnRect(50, 90, 160, 70),
makeText(90, 110, 'Client', 18),
makeArrow(210, 125, 260, 125),
// API Gateway
makeHandDrawnRect(260, 90, 180, 70),
makeText(290, 110, 'API Gateway', 18),
makeArrow(440, 125, 490, 125),
// Services
makeHandDrawnRect(490, 90, 160, 70),
makeText(520, 110, 'Services', 18),
makeArrow(570, 160, 570, 200),
// Database
makeHandDrawnRect(490, 200, 160, 70),
makeText(520, 220, 'Database', 18),
// Cache
makeHandDrawnRect(260, 200, 180, 70),
makeText(300, 220, 'Cache', 18),
makeArrow(440, 235, 490, 235),
// CDN
makeHandDrawnRect(50, 200, 160, 70),
makeText(90, 220, 'CDN', 18),
makeAddButton(300, 290, '+', 'architecture-add'),
makeText(332, 290, 'Add component...', 14),
], ],
}; };
const OPTIONS: TemplateOption[] = [ const OPTIONS: TemplateOption[] = [
{ id: 'blank', label: 'Blank Canvas', description: 'Start with an empty canvas', icon: PenTool, elements: [] }, { id: 'blank', label: 'Blank Canvas', description: 'Start with an empty canvas', icon: PenTool, elements: [] },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks with a title', icon: ListTodo, elements: [] }, { id: 'todo', label: 'To-Do List', description: 'Checkbox tasks with add button', icon: ListTodo, elements: [] },
{ id: 'checklist', label: 'Checklist', description: 'Simple checklist with status', icon: CheckSquare, elements: [] }, { id: 'checklist', label: 'Checklist', description: 'Simple checklist with status', icon: CheckSquare, elements: [] },
{ id: 'list', label: 'Bullet List', description: 'Bulleted list with notes area', icon: List, elements: [] }, { id: 'list', label: 'Bullet List', description: 'Bulleted list with add button', icon: List, elements: [] },
{ id: 'flow', label: 'Flow Chart', description: 'Simple process flow diagram', icon: ArrowRight, elements: [] }, { id: 'flow', label: 'Flow Chart', description: 'Connected process with add step', icon: ArrowRight, elements: [] },
{ id: 'kanban', label: 'Kanban Board', description: 'Three editable work columns', icon: KanbanSquare, elements: [] }, { id: 'kanban', label: 'Kanban Board', description: 'Three columns with add cards', icon: KanbanSquare, elements: [] },
{ id: 'meeting', label: 'Meeting Notes', description: 'Agenda, decisions, actions', icon: MessageSquare, elements: [] }, { id: 'meeting', label: 'Meeting Notes', description: 'Agenda, decisions, actions', icon: MessageSquare, elements: [] },
{ 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: 'Branching idea map', 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: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: RotateCcw, elements: [] },
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opportunities, threats', icon: Shield, elements: [] },
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: Map, elements: [] },
{ id: 'timeline', label: 'Timeline', description: 'Project phases and milestones', icon: Timer, elements: [] },
{ id: 'architecture', label: 'Architecture', description: 'System components and connections', icon: Layers, elements: [] },
]; ];
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => { export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
@@ -13,15 +13,18 @@
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: 1px solid var(--color-gray-20); border: 2px solid var(--color-gray-85);
border-radius: var(--border-radius-lg); border-radius: 2px;
box-shadow: var(--shadow-island); box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(-0.3deg);
h1 { h1 {
font-size: var(--text-3xl); font-size: var(--text-3xl);
font-weight: 600; 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;
letter-spacing: -0.02em;
} }
} }
@@ -80,6 +83,24 @@
} }
} }
.statCardWrapper {
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(0.15deg);
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: rotate(0) translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--color-gray-85);
}
&: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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -89,23 +110,45 @@
min-height: 150px; min-height: 150px;
} }
.statTop {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: var(--space-3);
}
.statIcon { .statIcon {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: var(--border-radius-md); border-radius: 50%;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--color-primary-darkest);
background: var(--color-primary-light); background: var(--color-primary-light);
margin-bottom: var(--space-3); border: 2px solid var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-2deg);
}
.handChart {
width: 80px;
height: 40px;
flex-shrink: 0;
transform: rotate(1deg);
}
.sparkline {
width: 100%;
height: 28px;
margin-top: var(--space-2);
} }
.statValue { .statValue {
font-size: var(--text-3xl); font-size: var(--text-3xl);
font-weight: 700; font-weight: 700;
color: var(--color-gray-85);
line-height: 1; line-height: 1;
font-family: 'Georgia', serif;
} }
.statLabel { .statLabel {
@@ -185,21 +228,37 @@
display: flex; display: flex;
align-items: center; align-items: center;
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); margin-bottom: var(--space-2);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
cursor: pointer;
transition: all 0.15s ease;
box-shadow: 2px 2px 0 var(--color-gray-85);
background: var(--island-bg-color);
&:hover {
border-color: var(--color-primary);
background: var(--color-surface-low);
transform: translateX(2px) rotate(-0.3deg);
box-shadow: 3px 3px 0 var(--color-gray-85);
}
&:last-child { &:last-child {
border-bottom: none; border-bottom: 2px solid var(--color-gray-30);
margin-bottom: 0;
} }
} }
.drawingThumb { .drawingThumb {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: var(--border-radius-md); border-radius: 2px;
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);
box-shadow: 2px 2px 0 var(--color-gray-85);
img { img {
width: 100%; width: 100%;
@@ -300,7 +359,7 @@
.activityAvatar { .activityAvatar {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: var(--border-radius-full); border-radius: 2px;
background: var(--color-primary); background: var(--color-primary);
color: white; color: white;
display: flex; display: flex;
@@ -309,6 +368,8 @@
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);
box-shadow: 2px 2px 0 var(--color-gray-85);
} }
.activityInfo { .activityInfo {
@@ -325,3 +386,104 @@
color: var(--color-muted); color: var(--color-muted);
margin-top: var(--space-1); margin-top: var(--space-1);
} }
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 5px 5px 0 var(--color-gray-85);
width: 420px;
max-width: 90vw;
transform: rotate(-0.3deg);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 2px solid var(--color-gray-85);
h3 {
margin: 0;
font-size: var(--text-lg);
color: var(--color-gray-85);
font-family: 'Georgia', serif;
}
}
.modalClose {
background: none;
border: none;
font-size: 22px;
color: var(--color-gray-60);
cursor: pointer;
line-height: 1;
&:hover { color: var(--color-gray-85); }
}
.modalBody {
padding: var(--space-4) var(--space-5);
label {
display: block;
font-size: var(--text-sm);
color: var(--color-gray-70);
margin-bottom: var(--space-2);
}
}
.modalInput {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
background: var(--input-bg-color);
color: var(--color-on-surface);
font-size: var(--text-sm);
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 3px 3px 0 var(--color-gray-85);
}
}
.modalFooter {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-3) var(--space-5) var(--space-4);
}
.modalBtnSecondary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-30);
background: transparent;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover { background: var(--color-surface-low); transform: rotate(-0.5deg); }
}
.modalBtnPrimary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-85);
background: var(--color-primary);
color: white;
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover { background: var(--color-primary-darker); transform: rotate(-0.5deg); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
+142 -18
View File
@@ -9,13 +9,86 @@ import styles from './Dashboard.module.scss';
const ACTIVITY_LIMIT = 5; const ACTIVITY_LIMIT = 5;
const StatChart: React.FC<{ value: number; max: number }> = ({ value, max }) => { const HandDrawnChart: React.FC<{ value: number; max: number; color?: string }> = ({ value, max, color = '#6965db' }) => {
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 (
<div className={styles.chartBarWrap} aria-hidden="true"> <svg className={styles.handChart} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
<div className={styles.chartBarBg} /> <path
<div className={styles.chartBar} style={{ width: `${pct}%` }} /> 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`}
</div> fill="none"
stroke="var(--color-gray-40)"
strokeWidth="1"
strokeLinecap="round"
/>
{pct > 0 && (
<path
d={`M${pad + r()},${h - pad + r()} L${pad + r()},${pad + r()} L${pad + barW + r()},${pad + r()} L${pad + barW + r()},${h - pad + r()} Z`}
fill={color}
stroke={color}
strokeWidth="1"
opacity="0.35"
strokeLinecap="round"
/>
)}
<path
d={`M${pad + r()},${h - pad + r()} L${pad + barW + r()},${h - pad + r()}`}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
<path
d={`M${pad + r()},${pad + r()} L${pad + barW + r()},${pad + r()}`}
fill="none"
stroke={color}
strokeWidth="1"
strokeLinecap="round"
opacity="0.5"
/>
</svg>
);
};
const MiniSparkline: React.FC<{ data: number[]; color?: string }> = ({ data, color = '#6965db' }) => {
if (!data.length) return null;
const w = 140;
const h = 40;
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
const stepX = w / (data.length - 1 || 1);
const points = data.map((v, i) => {
const x = i * stepX;
const y = h - ((v - min) / range) * (h - 4) - 2;
return `${x + (Math.random() - 0.5) * 0.8},${y + (Math.random() - 0.5) * 0.8}`;
}).join(' ');
return (
<svg className={styles.sparkline} viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
<polyline
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
opacity="0.7"
/>
{data.map((v, i) => {
const x = i * stepX;
const y = h - ((v - min) / range) * (h - 4) - 2;
return <circle key={i} cx={x} cy={y} r="2" fill={color} opacity="0.5" />;
})}
</svg>
); );
}; };
@@ -25,6 +98,8 @@ 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,
@@ -55,11 +130,18 @@ export const Dashboard: React.FC = () => {
loadData(); loadData();
}, [setRecentDrawings, setActivity]); }, [setRecentDrawings, setActivity]);
const handleCreateDrawing = async () => { const handleCreateDrawing = () => {
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: 'Untitled Drawing', title,
visibility: 'team', visibility: 'team',
}); });
setRecentDrawings([newDrawing, ...recentDrawings]); setRecentDrawings([newDrawing, ...recentDrawings]);
@@ -82,12 +164,21 @@ export const Dashboard: React.FC = () => {
const maxStat = Math.max(statsData.drawings, statsData.projects + statsData.folders, statsData.teams, statsData.revisions, 1); const maxStat = Math.max(statsData.drawings, statsData.projects + statsData.folders, statsData.teams, statsData.revisions, 1);
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 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 }, { label: t('dashboard.stats.drawings'), value: statsData.drawings, chartValue: statsData.drawings, max: maxStat, icon: FileText, color: statColors[0] },
{ label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, chartValue: statsData.projects + statsData.folders, max: maxStat, icon: FolderPlus }, { label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, chartValue: statsData.projects + statsData.folders, max: maxStat, icon: FolderPlus, color: statColors[1] },
{ label: t('dashboard.stats.teams'), value: statsData.teams, chartValue: statsData.teams, max: maxStat, icon: Users }, { label: t('dashboard.stats.teams'), value: statsData.teams, chartValue: statsData.teams, max: maxStat, icon: Users, color: statColors[2] },
{ label: t('dashboard.stats.revisions'), value: statsData.revisions, chartValue: statsData.revisions, max: maxStat, icon: Clock }, { label: t('dashboard.stats.revisions'), value: statsData.revisions, chartValue: statsData.revisions, max: maxStat, icon: Clock, color: statColors[3] },
{ label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), chartValue: Number(statsData.storage_bytes), max: storageMax, icon: Database }, { label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), chartValue: Number(statsData.storage_bytes), max: storageMax, icon: Database, color: statColors[4] },
]; ];
const visibleActivity = activity const visibleActivity = activity
.filter((event) => event.event_type !== 'revision_created') .filter((event) => event.event_type !== 'revision_created')
@@ -133,15 +224,18 @@ export const Dashboard: React.FC = () => {
</div> </div>
<div className={styles.statsGrid}> <div className={styles.statsGrid}>
{stats.map((stat) => ( {stats.map((stat, idx) => (
<Card key={stat.label}> <Card key={stat.label} className={styles.statCardWrapper}>
<CardContent className={styles.statCard}> <CardContent className={styles.statCard}>
<div className={styles.statIcon}> <div className={styles.statTop}>
<stat.icon size={24} /> <div className={styles.statIcon} style={{ color: stat.color, borderColor: stat.color }}>
<stat.icon size={22} />
</div>
<HandDrawnChart value={stat.chartValue} max={stat.max} color={stat.color} />
</div> </div>
<div className={styles.statValue}>{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>
<StatChart value={stat.chartValue} max={stat.max} /> <MiniSparkline data={sparkData[idx]} color={stat.color} />
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@@ -247,6 +341,36 @@ export const Dashboard: React.FC = () => {
</Card> </Card>
</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">&times;</button>
</div>
<div className={styles.modalBody}>
<label htmlFor="drawing-name">Name</label>
<input
id="drawing-name"
type="text"
autoFocus
placeholder="Untitled Drawing"
value={newDrawingName}
onChange={(e) => setNewDrawingName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmCreateDrawing(); if (e.key === 'Escape') setShowNameModal(false); }}
className={styles.modalInput}
/>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setShowNameModal(false)}>Cancel</button>
<button className={styles.modalBtnPrimary} onClick={confirmCreateDrawing} disabled={isCreating}>
{isCreating ? <Loader2 size={16} className={styles.spinner} /> : 'Create'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
@@ -15,6 +15,15 @@
padding: 0 var(--space-4); padding: 0 var(--space-4);
background: var(--island-bg-color); background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20); border-bottom: 1px solid var(--color-gray-20);
transition: opacity var(--duration-fast) var(--ease-out);
}
.toolbarHidden {
height: 0;
overflow: hidden;
padding: 0;
border: none;
opacity: 0;
} }
.left { .left {
@@ -379,6 +388,136 @@
color: var(--color-danger); color: var(--color-danger);
} }
.presentationOverlay {
position: fixed;
top: 12px;
right: 12px;
z-index: 200;
pointer-events: auto;
animation: presentationFadeIn 0.3s var(--ease-out);
}
@keyframes presentationFadeIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.presentationToolbar {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
padding: var(--space-2) var(--space-4);
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(-0.3deg);
}
.presentationLabel {
font-size: var(--text-sm);
color: var(--color-gray-70);
font-weight: 500;
font-family: 'Georgia', serif;
}
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: var(--modal-shadow);
width: 420px;
max-width: 90vw;
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-lg);
color: var(--color-gray-85);
}
}
.modalClose {
background: none;
border: none;
font-size: 22px;
color: var(--color-gray-60);
cursor: pointer;
line-height: 1;
&:hover { color: var(--color-gray-85); }
}
.modalBody {
padding: var(--space-4) var(--space-5);
label {
display: block;
font-size: var(--text-sm);
color: var(--color-gray-70);
margin-bottom: var(--space-2);
}
}
.modalInput {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
background: var(--input-bg-color);
color: var(--color-on-surface);
font-size: var(--text-sm);
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.modalFooter {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-3) var(--space-5) var(--space-4);
}
.modalBtnSecondary {
padding: var(--space-2) var(--space-4);
border-radius: var(--border-radius-md);
border: 1px solid var(--color-gray-20);
background: transparent;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
&:hover { background: var(--color-surface-low); }
}
.modalBtnPrimary {
padding: var(--space-2) var(--space-4);
border-radius: var(--border-radius-md);
border: none;
background: var(--color-primary);
color: white;
font-size: var(--text-sm);
cursor: pointer;
&:hover { background: var(--color-primary-darker); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
@media (max-width: 768px) { @media (max-width: 768px) {
.toolbar { .toolbar {
height: auto; height: auto;
+366 -57
View File
@@ -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 } from 'lucide-react'; import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, StickyNote, LayoutTemplate, MonitorPlay, X, Plus } 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';
@@ -10,7 +10,15 @@ import type { Drawing, DrawingRevision } from '@/types';
import styles from './Editor.module.scss'; import styles from './Editor.module.scss';
// Dynamic import for Excalidraw to avoid SSR issues // Dynamic import for Excalidraw to avoid SSR issues
const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw }))); const ExcalidrawWithLibrary = React.lazy(() =>
import('@excalidraw/excalidraw').then((mod) => {
const { Excalidraw } = mod;
const ExcalidrawWrapper: React.FC<any> = (props) => {
return <Excalidraw {...props} />;
};
return { default: ExcalidrawWrapper };
})
);
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'; import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types'; import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types';
@@ -83,6 +91,11 @@ export const Editor: React.FC = () => {
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null); const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
const [showTemplates, setShowTemplates] = useState(false); const [showTemplates, setShowTemplates] = useState(false);
const [presentationMode, setPresentationMode] = useState(false);
const [showSaveTemplate, setShowSaveTemplate] = useState(false);
const [templateName, setTemplateName] = useState('');
const [templateDesc, setTemplateDesc] = useState('');
const [isSavingTemplate, setIsSavingTemplate] = useState(false);
// Load drawing data // Load drawing data
useEffect(() => { useEffect(() => {
@@ -90,22 +103,38 @@ export const Editor: React.FC = () => {
if (!id) return; if (!id) return;
try { try {
setIsLoading(true); setIsLoading(true);
const [drawingData, revisionsData] = await Promise.all([ const drawingData = await api.drawings.get(id);
api.drawings.get(id),
api.revisions.list(id),
]);
setDrawing(drawingData); setDrawing(drawingData);
setRevisions(revisionsData);
// Load revisions
let revisionsData: DrawingRevision[] = [];
try {
revisionsData = await api.revisions.list(id);
setRevisions(revisionsData);
} catch (revErr) {
console.warn('Failed to load revisions, starting with empty canvas:', revErr);
}
// Load latest revision data if available // Load latest revision data if available
if (revisionsData.length > 0 && revisionsData[0].snapshot) { if (revisionsData.length > 0 && revisionsData[0].snapshot) {
const snapshot = JSON.parse(String(revisionsData[0].snapshot)); try {
setInitialData({ const rawSnapshot = revisionsData[0].snapshot;
elements: snapshot.elements || [], const snapshot = typeof rawSnapshot === 'string' ? JSON.parse(rawSnapshot) : rawSnapshot;
appState: appStateWithoutGrid(snapshot.appState || {}), setInitialData({
files: snapshot.files || {}, elements: snapshot.elements || [],
}); appState: appStateWithoutGrid(snapshot.appState || {}),
lastSavedDataRef.current = JSON.stringify(snapshot); files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
} catch (parseErr) {
console.error('Failed to parse revision snapshot:', parseErr);
setInitialData({
elements: [],
appState: appStateWithoutGrid(),
files: {},
});
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
}
} else { } else {
// Check for pending template from dashboard // Check for pending template from dashboard
const pendingTemplate = localStorage.getItem(`template_${id}`); const pendingTemplate = localStorage.getItem(`template_${id}`);
@@ -129,8 +158,8 @@ export const Editor: React.FC = () => {
} }
} }
} catch (err) { } catch (err) {
console.error('Failed to load drawing:', err);
setError('Failed to load drawing'); setError('Failed to load drawing');
console.error(err);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -138,45 +167,207 @@ export const Editor: React.FC = () => {
loadDrawing(); loadDrawing();
}, [id]); }, [id]);
// Sync Excalidraw theme with global theme
useEffect(() => {
if (excalidrawAPI) {
excalidrawAPI.updateScene({ appState: { theme: appTheme === 'dark' ? 'dark' : 'light' } });
}
}, [appTheme, excalidrawAPI]);
// 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 }>) => {
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {}); const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
const selectedCheckbox = selectedIds.length === 1 const selectedEl = selectedIds.length === 1
? elements.find((el) => ( ? elements.find((el) => el.id === selectedIds[0] && !el.isDeleted)
el.id === selectedIds[0] &&
!el.isDeleted &&
(el.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox'
))
: null; : null;
if (!selectedCheckbox) { // Handle checkbox toggle
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox') {
if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedEl.id) {
lastToggledCheckboxRef.current = selectedEl.id;
const nextChecked = !((selectedEl.customData as Record<string, unknown> | undefined)?.checked as boolean);
const nextElements = elements.map((el) => (
el.id === selectedEl.id
? {
...el,
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
fillStyle: (nextChecked ? 'solid' : 'hachure') as 'solid' | 'hachure',
customData: {
...((el.customData as Record<string, unknown> | undefined) || {}),
checked: nextChecked,
},
version: el.version + 1,
versionNonce: Math.floor(Math.random() * 1000000),
updated: Date.now(),
}
: el
));
excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] });
currentStateRef.current = {
elements: nextElements,
appState: appStateWithoutGrid(appState),
files,
};
setSaveStatus('unsaved');
return;
}
} else {
lastToggledCheckboxRef.current = null; lastToggledCheckboxRef.current = null;
} else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) { }
lastToggledCheckboxRef.current = selectedCheckbox.id;
const nextChecked = !((selectedCheckbox.customData as Record<string, unknown> | undefined)?.checked as boolean); // Handle "+" add button click
const nextElements = elements.map((el) => ( if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.action === 'add' && excalidrawAPI) {
el.id === selectedCheckbox.id const customData = (selectedEl.customData as Record<string, unknown>) || {};
? { const role = customData.templateRole as string;
...el, const btnX = (selectedEl.x as number) || 0;
backgroundColor: nextChecked ? '#a5eba8' : 'transparent', const btnY = (selectedEl.y as number) || 0;
customData: { const newElements: LooseElement[] = [];
...((el.customData as Record<string, unknown> | undefined) || {}), const uid = () => `el-${Math.random().toString(36).slice(2)}`;
checked: nextChecked, const tid = () => `txt-${Math.random().toString(36).slice(2)}`;
},
version: el.version + 1, if (role.startsWith('todo-add') || role.startsWith('checklist-add')) {
versionNonce: Math.floor(Math.random() * 1000000), // Add a new checkbox + text row below the button
updated: Date.now(), const newY = btnY + 30;
} newElements.push({
: el id: uid(), type: 'rectangle', x: btnX, y: newY, width: 20, height: 20,
)); angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] }); strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
currentStateRef.current = { frameId: null, roundness: { type: 3, value: 32 }, seed: Math.floor(Math.random() * 10000),
elements: nextElements, version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
appState: appStateWithoutGrid(appState), boundElements: [], updated: Date.now(), link: null, locked: false,
files, customData: { templateRole: 'checkbox', checked: false },
}; });
setSaveStatus('unsaved'); newElements.push({
return; id: tid(), type: 'text', x: btnX + 30, y: newY + 2, width: 120, height: 24,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
frameId: null, 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,
text: 'New task', fontSize: 18, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 16, containerId: null, originalText: 'New task', lineHeight: 1.25,
});
// Move the add button down
const updated = elements.map((el) =>
el.id === selectedEl.id
? { ...el, y: newY + 40, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
setSaveStatus('unsaved');
return;
}
if (role.startsWith('kanban-add')) {
// Add a new card in the column
const cardW = 140, cardH = 60;
const newY = btnY - 70;
const colX = btnX - 20;
const gid = `card-${uid()}`;
newElements.push({
id: uid(), type: 'rectangle', x: colX, y: newY, width: cardW, height: cardH,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [gid],
frameId: null, roundness: { type: 3, value: 32 }, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
});
newElements.push({
id: tid(), type: 'text', x: colX + 15, y: newY + 20, width: 100, height: 22,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [gid],
frameId: null, 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,
text: 'New card', fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 14, containerId: null, originalText: 'New card', lineHeight: 1.25,
});
// Move the add button down
const updated = elements.map((el) =>
el.id === selectedEl.id
? { ...el, y: newY + cardH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
setSaveStatus('unsaved');
return;
}
if (role.startsWith('mindmap-add')) {
// Add a new branch node below the current one
const nodeW = 150, nodeH = 55;
const newY = btnY + 20;
const gid = `branch-${uid()}`;
newElements.push({
id: uid(), type: 'rectangle', x: btnX, y: newY, width: nodeW, height: nodeH,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [gid],
frameId: null, roundness: { type: 3, value: 32 }, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
});
newElements.push({
id: tid(), type: 'text', x: btnX + 25, y: newY + 16, width: 100, height: 22,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [gid],
frameId: null, 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,
text: 'New branch', fontSize: 18, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 16, containerId: null, originalText: 'New branch', lineHeight: 1.25,
});
// Add connecting arrow from parent to new node
const parentCenterX = btnX + nodeW / 2;
const parentBottomY = btnY - 20;
newElements.push({
id: `arrow-${Math.random().toString(36).slice(2)}`, type: 'arrow',
x: parentCenterX, y: parentBottomY, width: 0, height: newY - parentBottomY,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
frameId: null, roundness: { type: 2 }, seed: Math.floor(Math.random() * 10000),
version: 2, versionNonce: Math.floor(Math.random() * 100000), isDeleted: false,
boundElements: [], updated: Date.now(), link: null, locked: false,
startBinding: null, endBinding: null, lastCommittedPoint: null,
startArrowhead: null, endArrowhead: 'arrow',
points: [[0, 0], [0, newY - parentBottomY]],
});
// Move the add button down
const updated = elements.map((el) =>
el.id === selectedEl.id
? { ...el, y: newY + nodeH + 10, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
setSaveStatus('unsaved');
return;
}
// Generic add: add a text line below
if (role.startsWith('list-add') || role.startsWith('meeting-add') || role.startsWith('flow-add') ||
role.startsWith('brainstorm-add') || role.startsWith('retro-add') || role.startsWith('swot-add') ||
role.startsWith('storymap-add') || role.startsWith('wireframe-add') || role.startsWith('timeline-add') ||
role.startsWith('architecture-add')) {
const newY = btnY + 30;
newElements.push({
id: tid(), type: 'text', x: btnX + 30, y: newY, width: 150, height: 22,
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
frameId: null, 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,
text: role.startsWith('list-add') ? '• New item' : '- New item',
fontSize: 16, fontFamily: 1, textAlign: 'left', verticalAlign: 'top',
baseline: 14, containerId: null, originalText: role.startsWith('list-add') ? '• New item' : '- New item', lineHeight: 1.25,
});
const updated = elements.map((el) =>
el.id === selectedEl.id
? { ...el, y: newY + 30, version: el.version + 1, versionNonce: Math.floor(Math.random() * 1000000), updated: Date.now() }
: el
);
excalidrawAPI.updateScene({ elements: [...updated, ...newElements] as ExcalidrawElement[] });
setSaveStatus('unsaved');
return;
}
} }
currentStateRef.current = { currentStateRef.current = {
@@ -245,7 +436,7 @@ export const Editor: React.FC = () => {
const handleRestoreRevision = (revision: DrawingRevision) => { const handleRestoreRevision = (revision: DrawingRevision) => {
if (!revision.snapshot) return; if (!revision.snapshot) return;
try { try {
const snapshot = JSON.parse(String(revision.snapshot)); const snapshot = typeof revision.snapshot === 'string' ? JSON.parse(revision.snapshot) : revision.snapshot;
setInitialData({ setInitialData({
elements: snapshot.elements || [], elements: snapshot.elements || [],
appState: appStateWithoutGrid(snapshot.appState || {}), appState: appStateWithoutGrid(snapshot.appState || {}),
@@ -267,6 +458,23 @@ export const Editor: React.FC = () => {
await saveDrawing(); await saveDrawing();
}; };
// Ctrl+S keyboard shortcut
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
if (saveStatus !== 'saved' && !isSaving) {
saveDrawing();
}
}
if (e.key === 'Escape' && presentationMode) {
setPresentationMode(false);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [saveStatus, isSaving, saveDrawing, presentationMode]);
// Cleanup timeout on unmount // Cleanup timeout on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -295,14 +503,18 @@ export const Editor: React.FC = () => {
const templateOptions = [ const templateOptions = [
{ id: 'blank', label: 'Blank', description: 'Empty canvas start', icon: null }, { id: 'blank', label: 'Blank', description: 'Empty canvas start', icon: null },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks', icon: null }, { id: 'todo', label: 'To-Do List', description: 'Checkbox tasks with +', icon: null },
{ id: 'checklist', label: 'Checklist', description: 'Status checklist', icon: null }, { id: 'checklist', label: 'Checklist', description: 'Status checklist with +', icon: null },
{ id: 'list', label: 'Bullet List', description: 'Bulleted notes', icon: null }, { id: 'list', label: 'Bullet List', description: 'Bulleted notes with +', icon: null },
{ id: 'flow', label: 'Flow Chart', description: 'Process diagram', icon: null }, { id: 'flow', label: 'Flow Chart', description: 'Process diagram with +', icon: null },
{ id: 'kanban', label: 'Kanban Board', description: 'Backlog, doing, done columns', icon: null }, { id: 'kanban', label: 'Kanban Board', description: 'Backlog, doing, done with +', icon: null },
{ id: 'meeting', label: 'Meeting Notes', description: 'Agenda, decisions, actions', icon: null }, { id: 'meeting', label: 'Meeting Notes', description: 'Agenda, decisions, actions', icon: null },
{ 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: 'retrospective', label: 'Retrospective', description: 'Went well, improve, actions', icon: null },
{ id: 'swot', label: 'SWOT Analysis', description: 'Strengths, weaknesses, opps, threats', icon: null },
{ id: 'storymap', label: 'User Story Map', description: 'Epics, steps, and stories', icon: null },
]; ];
useEffect(() => { useEffect(() => {
@@ -341,7 +553,7 @@ export const Editor: React.FC = () => {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.toolbar}> <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={() => navigate(-1)}>
<ArrowLeft size={18} /> <ArrowLeft size={18} />
@@ -395,18 +607,40 @@ export const Editor: React.FC = () => {
> >
<LayoutTemplate size={16} /> <LayoutTemplate size={16} />
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={() => setPresentationMode(true)}
title="Presentation mode"
aria-label="Start presentation mode"
>
<MonitorPlay size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setTemplateName(drawing?.title || ''); setTemplateDesc(''); setShowSaveTemplate(true); }}
title="Save as template"
aria-label="Save current drawing as a custom template"
>
<Plus size={16} />
</Button>
</div> </div>
</div> </div>
<div className={styles.canvasWrapper}> <div className={styles.canvasWrapper}>
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates) ? styles.canvasNarrow : ''}`}> <div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates) ? styles.canvasNarrow : ''}`}>
{initialData && ( {initialData && (
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}> <React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
<Excalidraw <ExcalidrawWithLibrary
excalidrawAPI={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)} excalidrawAPI={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
initialData={initialData} initialData={initialData}
onChange={handleExcalidrawChange} onChange={handleExcalidrawChange}
theme={appTheme === 'dark' ? 'dark' : 'light'} theme={appTheme === 'dark' ? 'dark' : 'light'}
gridModeEnabled={false} gridModeEnabled={false}
viewModeEnabled={presentationMode}
zenModeEnabled={presentationMode}
validateEmbeddable={() => true}
validateLibraryUrl={() => true}
UIOptions={{ UIOptions={{
canvasActions: { canvasActions: {
saveToActiveFile: false, saveToActiveFile: false,
@@ -494,6 +728,81 @@ export const Editor: React.FC = () => {
</div> </div>
)} )}
{presentationMode && (
<div className={styles.presentationOverlay} role="presentation">
<div className={styles.presentationToolbar}>
<span className={styles.presentationLabel}>Presentation Mode Press Esc to exit</span>
<Button variant="ghost" size="sm" onClick={() => setPresentationMode(false)} aria-label="Exit presentation">
<X size={16} />
</Button>
</div>
</div>
)}
{showSaveTemplate && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="save-template-title" onClick={(e) => { if (e.target === e.currentTarget) setShowSaveTemplate(false); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="save-template-title">Save as Template</h3>
<button className={styles.modalClose} onClick={() => setShowSaveTemplate(false)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<label htmlFor="template-name">Template Name</label>
<input
id="template-name"
type="text"
autoFocus
placeholder="My Custom Template"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
className={styles.modalInput}
/>
<label htmlFor="template-desc" style={{ marginTop: 'var(--space-3)' }}>Description (optional)</label>
<input
id="template-desc"
type="text"
placeholder="Brief description..."
value={templateDesc}
onChange={(e) => setTemplateDesc(e.target.value)}
className={styles.modalInput}
/>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setShowSaveTemplate(false)}>Cancel</button>
<button
className={styles.modalBtnPrimary}
onClick={async () => {
if (!templateName.trim() || !excalidrawAPI) return;
setIsSavingTemplate(true);
try {
const elements = excalidrawAPI.getSceneElements();
const appState = excalidrawAPI.getAppState();
const files = excalidrawAPI.getFiles();
const snapshot = { type: 'excalidraw', version: 2, source: window.location.hostname, elements, appState, files };
await api.templates.create({
name: templateName.trim(),
description: templateDesc.trim(),
snapshot,
metadata: { category: 'custom' },
});
setShowSaveTemplate(false);
alert('Template saved successfully!');
} catch (err) {
console.error('Failed to save template:', err);
alert('Failed to save template. Please try again.');
} finally {
setIsSavingTemplate(false);
}
}}
disabled={isSavingTemplate || !templateName.trim()}
>
{isSavingTemplate ? <Loader2 size={16} className={styles.spinner} /> : 'Save Template'}
</button>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
); );
@@ -18,9 +18,10 @@
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: 1px solid var(--color-gray-20); border: 2px solid var(--color-gray-85);
border-radius: var(--border-radius-lg); border-radius: 2px;
box-shadow: var(--shadow-island); box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(0.2deg);
@media (max-width: 640px) { @media (max-width: 640px) {
flex-direction: column; flex-direction: column;
@@ -114,8 +115,9 @@
width: 240px; width: 240px;
flex-shrink: 0; flex-shrink: 0;
background: var(--island-bg-color); background: var(--island-bg-color);
border: 1px solid var(--color-gray-20); border: 2px solid var(--color-gray-85);
border-radius: var(--border-radius-lg); border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
padding: var(--space-3); padding: var(--space-3);
align-self: flex-start; align-self: flex-start;
@@ -138,12 +140,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: var(--border-radius-md); border-radius: 2px;
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: none; border: 2px solid transparent;
width: 100%; width: 100%;
text-align: left; text-align: left;
font-size: var(--text-sm); font-size: var(--text-sm);
@@ -151,12 +153,17 @@
&: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);
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: 500; font-weight: 600;
border-color: var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-0.2deg);
} }
svg { svg {
@@ -221,6 +228,16 @@
.drawingCard { .drawingCard {
position: relative; position: relative;
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(0.1deg);
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: rotate(0) translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--color-gray-85);
}
} }
.thumbnail { .thumbnail {
@@ -295,9 +312,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: 1px solid var(--default-border-color); border: 2px solid var(--color-gray-85);
border-radius: var(--border-radius-md); border-radius: 2px;
box-shadow: var(--shadow-island); box-shadow: 3px 3px 0 var(--color-gray-85);
min-width: 160px; min-width: 160px;
z-index: 10; z-index: 10;
display: flex; display: flex;
@@ -354,16 +371,17 @@
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: 1px solid var(--color-gray-20); border: 2px solid var(--color-gray-30);
border-radius: var(--border-radius-md); border-radius: 2px;
box-shadow: 2px 2px 0 var(--color-gray-85);
} }
.newProjectInput { .newProjectInput {
flex: 1; flex: 1;
min-width: 120px; min-width: 120px;
background: var(--input-bg-color); background: var(--input-bg-color);
border: 1px solid var(--input-border-color); border: 2px solid var(--color-gray-30);
border-radius: var(--border-radius-md); border-radius: 2px;
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); font-size: var(--text-sm);
@@ -371,36 +389,40 @@
&:focus { &:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light); box-shadow: 3px 3px 0 var(--color-gray-85);
} }
} }
.newProjectBtn { .newProjectBtn {
background: var(--color-primary); background: var(--color-primary);
color: #fff; color: #fff;
border: none; border: 2px solid var(--color-gray-85);
border-radius: var(--border-radius-md); border-radius: 2px;
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);
&:hover { &:hover {
background: var(--color-primary-darkest); background: var(--color-primary-darkest);
transform: rotate(-0.5deg);
} }
} }
.newProjectBtnCancel { .newProjectBtnCancel {
background: none; background: none;
border: 1px solid var(--default-border-color); border: 2px solid var(--color-gray-30);
border-radius: var(--border-radius-md); border-radius: 2px;
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);
&:hover { &:hover {
background: var(--color-surface-low); background: var(--color-surface-low);
transform: rotate(-0.5deg);
} }
} }
@@ -431,3 +453,110 @@
border-color: var(--color-primary); border-color: var(--color-primary);
} }
} }
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 5px 5px 0 var(--color-gray-85);
width: 420px;
max-width: 90vw;
transform: rotate(-0.3deg);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 2px solid var(--color-gray-85);
h3 {
margin: 0;
font-size: var(--text-lg);
color: var(--color-gray-85);
font-family: 'Georgia', serif;
}
}
.modalClose {
background: none;
border: none;
font-size: 22px;
color: var(--color-gray-60);
cursor: pointer;
line-height: 1;
&:hover { color: var(--color-gray-85); }
}
.modalBody {
padding: var(--space-4) var(--space-5);
label {
display: block;
font-size: var(--text-sm);
color: var(--color-gray-70);
margin-bottom: var(--space-2);
}
}
.modalInput {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
background: var(--input-bg-color);
color: var(--color-on-surface);
font-size: var(--text-sm);
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 3px 3px 0 var(--color-gray-85);
}
}
.modalFooter {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-3) var(--space-5) var(--space-4);
}
.modalBtnSecondary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-30);
background: transparent;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover { background: var(--color-surface-low); transform: rotate(-0.5deg); }
}
.modalBtnPrimary {
padding: var(--space-2) var(--space-4);
border-radius: 2px;
border: 2px solid var(--color-gray-85);
background: var(--color-primary);
color: white;
font-size: var(--text-sm);
cursor: pointer;
box-shadow: 2px 2px 0 var(--color-gray-85);
&:hover { background: var(--color-primary-darker); transform: rotate(-0.5deg); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
+45 -4
View File
@@ -12,7 +12,7 @@ export const FileBrowser: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const urlParams = useParams<{ folderId?: string }>(); const urlParams = useParams<{ folderId?: string }>();
const { drawings, folders, setDrawings, setFolders } = useDrawingStore(); const { drawings, folders, setDrawings, setFolders, removeDrawing } = useDrawingStore();
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortBy, setSortBy] = useState<'name' | 'updated' | 'created'>('updated'); const [sortBy, setSortBy] = useState<'name' | 'updated' | 'created'>('updated');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
@@ -37,6 +37,10 @@ export const FileBrowser: React.FC = () => {
// Move state // Move state
const [movingId, setMovingId] = useState<string | null>(null); const [movingId, setMovingId] = useState<string | null>(null);
// New drawing name modal state
const [showNameModal, setShowNameModal] = useState(false);
const [newDrawingName, setNewDrawingName] = useState('');
// Modal state // Modal state
const [modal, setModal] = useState<{ const [modal, setModal] = useState<{
open: boolean; open: boolean;
@@ -118,11 +122,18 @@ export const FileBrowser: React.FC = () => {
[navigate] [navigate]
); );
const handleCreateDrawing = async () => { const handleCreateDrawing = () => {
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: 'Untitled Drawing', title,
visibility: 'team', visibility: 'team',
folder_id: activeFolderId || null, folder_id: activeFolderId || null,
}); });
@@ -161,7 +172,7 @@ export const FileBrowser: React.FC = () => {
showModal('confirm', 'Delete Drawing', `Delete "${drawing.title}"? This cannot be undone.`, async () => { showModal('confirm', 'Delete Drawing', `Delete "${drawing.title}"? This cannot be undone.`, async () => {
try { try {
await api.drawings.delete(drawing.id); await api.drawings.delete(drawing.id);
setDrawings(drawings.filter(d => d.id !== drawing.id)); removeDrawing(drawing.id);
setActiveMenu(null); setActiveMenu(null);
setModal(m => ({ ...m, open: false })); setModal(m => ({ ...m, open: false }));
} catch (err) { } catch (err) {
@@ -489,6 +500,36 @@ export const FileBrowser: React.FC = () => {
)} )}
</main> </main>
</div> </div>
{showNameModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="new-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setShowNameModal(false); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="new-drawing-title">New Drawing</h3>
<button className={styles.modalClose} onClick={() => setShowNameModal(false)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<label htmlFor="drawing-name">Name</label>
<input
id="drawing-name"
type="text"
autoFocus
placeholder="Untitled Drawing"
value={newDrawingName}
onChange={(e) => setNewDrawingName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmCreateDrawing(); if (e.key === 'Escape') setShowNameModal(false); }}
className={styles.modalInput}
/>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setShowNameModal(false)}>Cancel</button>
<button className={styles.modalBtnPrimary} onClick={confirmCreateDrawing} disabled={isCreating}>
{isCreating ? <Loader2 size={16} className={styles.spinner} /> : 'Create'}
</button>
</div>
</div>
</div>
)}
</div> </div>
</> </>
); );
@@ -7,12 +7,19 @@
.header { .header {
margin-bottom: var(--space-8); margin-bottom: var(--space-8);
padding: var(--space-5);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(0.1deg);
h1 { h1 {
font-size: var(--text-3xl); font-size: var(--text-3xl);
font-weight: 600; 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;
} }
} }
@@ -38,10 +45,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: var(--border-radius-md); border-radius: 2px;
color: var(--color-gray-70); color: var(--color-gray-70);
background: none; background: none;
border: none; border: 2px 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);
@@ -50,12 +57,17 @@
&: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);
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: 500; font-weight: 600;
border-color: var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
transform: rotate(-0.1deg);
} }
} }
@@ -75,15 +87,17 @@
.avatar { .avatar {
width: 64px; width: 64px;
height: 64px; height: 64px;
border-radius: var(--border-radius-full); border-radius: 50%;
background: var(--color-primary); background: var(--color-primary);
color: white; color: white;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: var(--text-2xl); font-size: var(--text-2xl);
font-weight: 600; font-weight: 700;
overflow: hidden; overflow: hidden;
border: 2px solid var(--color-gray-85);
box-shadow: 3px 3px 0 var(--color-gray-85);
img { img {
width: 100%; width: 100%;
@@ -137,22 +151,26 @@
.themeOption { .themeOption {
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gray-30); border: 2px solid var(--color-gray-30);
border-radius: var(--border-radius-md); border-radius: 2px;
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);
&: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: 3px 3px 0 var(--color-primary);
} }
&.active { &.active {
background: var(--color-primary); background: var(--color-primary);
border-color: var(--color-primary); border-color: var(--color-gray-85);
color: white; color: white;
box-shadow: 2px 2px 0 var(--color-gray-85);
} }
} }
+39 -14
View File
@@ -7,12 +7,19 @@
.header { .header {
margin-bottom: var(--space-8); margin-bottom: var(--space-8);
padding: var(--space-5);
background: var(--island-bg-color);
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 4px 4px 0 var(--color-gray-85);
transform: rotate(-0.2deg);
h1 { h1 {
font-size: var(--text-3xl); font-size: var(--text-3xl);
font-weight: 600; 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;
} }
} }
@@ -59,25 +66,40 @@
display: flex; display: flex;
align-items: center; align-items: center;
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); margin-bottom: var(--space-2);
border: 2px solid var(--color-gray-30);
border-radius: 2px;
transition: all 0.15s ease;
box-shadow: 2px 2px 0 var(--color-gray-85);
background: var(--island-bg-color);
&:hover {
border-color: var(--color-primary);
background: var(--color-surface-low);
transform: translateX(2px) rotate(-0.2deg);
box-shadow: 3px 3px 0 var(--color-gray-85);
}
&:last-child { &:last-child {
border-bottom: none; border-bottom: 2px solid var(--color-gray-30);
margin-bottom: 0;
} }
} }
.memberAvatar { .memberAvatar {
width: 40px; width: 44px;
height: 40px; height: 44px;
border-radius: var(--border-radius-full); border-radius: 50%;
background: var(--color-primary); background: var(--color-primary);
color: white; color: white;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 600; font-weight: 700;
flex-shrink: 0; flex-shrink: 0;
border: 2px solid var(--color-gray-85);
box-shadow: 2px 2px 0 var(--color-gray-85);
} }
.memberInfo { .memberInfo {
@@ -100,11 +122,13 @@
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-radius: var(--border-radius-full); border: 2px solid var(--color-gray-30);
border-radius: 2px;
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 {
@@ -115,15 +139,15 @@
.inviteInput { .inviteInput {
padding: var(--space-3); padding: var(--space-3);
border: 1px solid var(--input-border-color); border: 2px solid var(--color-gray-30);
border-radius: var(--border-radius-md); border-radius: 2px;
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: 0 0 0 3px var(--color-primary-light); box-shadow: 3px 3px 0 var(--color-gray-85);
} }
} }
@@ -163,11 +187,12 @@
.roleSelect { .roleSelect {
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
border: 1px solid var(--input-border-color); border: 2px solid var(--color-gray-30);
border-radius: var(--border-radius-md); border-radius: 2px;
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,6 +78,16 @@
.templateCard { .templateCard {
overflow: hidden; overflow: hidden;
border: 2px solid var(--color-gray-85);
border-radius: 2px;
box-shadow: 3px 3px 0 var(--color-gray-85);
transform: rotate(0.1deg);
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: rotate(0) translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--color-gray-85);
}
} }
.preview { .preview {
@@ -143,8 +153,14 @@
} }
.useBtn { .useBtn {
flex: 1;
}
.actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-3); margin-top: var(--space-3);
width: 100%; align-items: center;
} }
.modalOverlay { .modalOverlay {
+57 -41
View File
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Plus, Sparkles, X, Loader2, FilePlus } from 'lucide-react'; import { Sparkles, FilePlus, Trash2 } from 'lucide-react';
import { Card, CardContent, Button, Input } from '@/components'; import { Card, CardContent, Button } from '@/components';
import { useDrawingStore } from '@/stores'; import { useDrawingStore, useAuthStore } from '@/stores';
import { api } from '@/services'; import { api } from '@/services';
import type { Template, TemplateScope } from '@/types'; import type { Template, TemplateScope } from '@/types';
import styles from './Templates.module.scss'; import styles from './Templates.module.scss';
@@ -17,12 +17,10 @@ const categories: { id: TemplateScope | 'all'; label: string }[] = [
export const Templates: React.FC = () => { export const Templates: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { templates, setTemplates, addDrawing } = useDrawingStore(); const { templates, setTemplates, addDrawing } = useDrawingStore();
const { user } = useAuthStore();
const [active, setActive] = useState<TemplateScope | 'all'>('all'); const [active, setActive] = useState<TemplateScope | 'all'>('all');
const [showModal, setShowModal] = useState(false);
const [creating, setCreating] = useState(false);
const [applyingId, setApplyingId] = useState<string | null>(null); const [applyingId, setApplyingId] = useState<string | null>(null);
const [name, setName] = useState(''); const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
api.templates.list().then(setTemplates).catch(console.error); api.templates.list().then(setTemplates).catch(console.error);
@@ -30,16 +28,6 @@ export const Templates: React.FC = () => {
const filtered = active === 'all' ? templates : templates.filter((t) => t.scope === active); const filtered = active === 'all' ? templates : templates.filter((t) => t.scope === active);
const handleCreate = async () => {
if (!name.trim()) { setError('Name required'); return; }
setCreating(true); setError('');
try {
const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' });
setTemplates([t, ...templates]); setShowModal(false); setName('');
} catch { setError('Create failed'); }
finally { setCreating(false); }
};
const handleUseTemplate = async (template: Template) => { const handleUseTemplate = async (template: Template) => {
setApplyingId(template.id); setApplyingId(template.id);
try { try {
@@ -47,6 +35,11 @@ export const Templates: React.FC = () => {
title: template.name, title: template.name,
visibility: 'team', visibility: 'team',
}); });
// Apply template snapshot if available - store in localStorage for editor to pick up
if (template.snapshot_path) {
// The template data would need to be fetched separately
// For now, just navigate to the new drawing
}
addDrawing(drawing); addDrawing(drawing);
navigate(`/drawing/${drawing.id}`); navigate(`/drawing/${drawing.id}`);
} catch (err) { } catch (err) {
@@ -56,11 +49,31 @@ export const Templates: React.FC = () => {
} }
}; };
const handleDeleteTemplate = async (template: Template) => {
if (!confirm(`Delete template "${template.name}"? This cannot be undone.`)) return;
setDeletingId(template.id);
try {
await api.templates.delete(template.id);
setTemplates(templates.filter((t) => t.id !== template.id));
} catch (err) {
console.error('Failed to delete template:', err);
alert('Failed to delete template. You may not have permission.');
} finally {
setDeletingId(null);
}
};
const canDelete = (template: Template) => {
return template.scope !== 'system' && template.created_by === user?.id;
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.header}> <div className={styles.header}>
<div><h1>Templates</h1><p className={styles.subtitle}>Start from a template or create your own</p></div> <div>
<Button onClick={() => setShowModal(true)}><Plus size={18} />Create</Button> <h1>Templates</h1>
<p className={styles.subtitle}>Start from a template. Create custom templates from any drawing using the "Save as Template" button in the editor.</p>
</div>
</div> </div>
<div className={styles.categories} role="tablist"> <div className={styles.categories} role="tablist">
{categories.map((c) => ( {categories.map((c) => (
@@ -71,7 +84,7 @@ export const Templates: React.FC = () => {
<div className={styles.grid} role="tabpanel"> <div className={styles.grid} role="tabpanel">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className={styles.empty} role="status"><Sparkles size={48} aria-hidden="true" /> <div className={styles.empty} role="status"><Sparkles size={48} aria-hidden="true" />
<p>No templates</p><p className={styles.emptySub}>Create your first template</p></div> <p>No templates</p><p className={styles.emptySub}>Create your first template from any drawing</p></div>
) : filtered.map((t) => ( ) : filtered.map((t) => (
<Card key={t.id} className={styles.templateCard} hover> <Card key={t.id} className={styles.templateCard} hover>
<div className={styles.preview}> <div className={styles.preview}>
@@ -84,31 +97,34 @@ export const Templates: React.FC = () => {
<span className={styles.scope}>{t.scope}</span> <span className={styles.scope}>{t.scope}</span>
<span className={styles.type}>{t.type}</span> <span className={styles.type}>{t.type}</span>
</div> </div>
<Button <div className={styles.actions}>
size="sm" <Button
className={styles.useBtn} size="sm"
onClick={() => handleUseTemplate(t)} className={styles.useBtn}
loading={applyingId === t.id} onClick={() => handleUseTemplate(t)}
aria-label={`Use template ${t.name}`} loading={applyingId === t.id}
> aria-label={`Use template ${t.name}`}
<FilePlus size={14} /> >
Use Template <FilePlus size={14} />
</Button> Use Template
</Button>
{canDelete(t) && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTemplate(t)}
loading={deletingId === t.id}
aria-label={`Delete template ${t.name}`}
title="Delete template"
>
<Trash2 size={14} />
</Button>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
{showModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="tm-title" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className={styles.modal}>
<div className={styles.modalHeader}><h2 id="tm-title">Create Template</h2><button onClick={() => setShowModal(false)} aria-label="Close"><X size={18} /></button></div>
<div className={styles.modalBody}>
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} error={error} />
{creating ? <Loader2 className={styles.spinner} size={20} /> : <Button onClick={handleCreate}>Create</Button>}
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
+14 -3
View File
@@ -9,10 +9,14 @@ class ApiError extends Error {
} }
async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> { async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const headers: Record<string, string> = {};
if (options?.body) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', ...headers,
...options?.headers, ...options?.headers,
}, },
}); });
@@ -38,7 +42,7 @@ export const api = {
fetchApi('/drawings', { method: 'POST', body: JSON.stringify(data) }), fetchApi('/drawings', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: object): Promise<Drawing> => update: (id: string, data: object): Promise<Drawing> =>
fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string): Promise<void> => delete: (id: string): Promise<{ ok: boolean }> =>
fetchApi(`/drawings/${id}`, { method: 'DELETE' }), fetchApi(`/drawings/${id}`, { method: 'DELETE' }),
}, },
revisions: { revisions: {
@@ -66,8 +70,10 @@ export const api = {
}, },
templates: { templates: {
list: (): Promise<Template[]> => fetchApi('/templates'), list: (): Promise<Template[]> => fetchApi('/templates'),
create: (data: { name: string; type: string; scope: string }): Promise<Template> => create: (data: { name: string; description?: string; team_id?: string; snapshot: object; metadata?: Record<string, unknown> }): Promise<Template> =>
fetchApi('/templates', { method: 'POST', body: JSON.stringify(data) }), fetchApi('/templates', { method: 'POST', body: JSON.stringify(data) }),
delete: (id: string): Promise<{ ok: boolean }> =>
fetchApi(`/templates/${id}`, { method: 'DELETE' }),
}, },
stats: { stats: {
get: (teamId?: string): Promise<{ get: (teamId?: string): Promise<{
@@ -88,4 +94,9 @@ export const api = {
search: { search: {
get: (q: string): Promise<Drawing[]> => fetchApi(`/search?q=${encodeURIComponent(q)}`), get: (q: string): Promise<Drawing[]> => fetchApi(`/search?q=${encodeURIComponent(q)}`),
}, },
notifications: {
list: (): Promise<Notification[]> => fetchApi('/notifications'),
markRead: (id: string): Promise<{ ok: boolean }> => fetchApi(`/notifications/${id}/read`, { method: 'POST' }),
markAllRead: (): Promise<{ ok: boolean }> => fetchApi('/notifications/read-all', { method: 'POST' }),
},
}; };
+20
View File
@@ -116,3 +116,23 @@ a {
background: var(--color-primary-light); background: var(--color-primary-light);
color: var(--color-primary-darkest); color: var(--color-primary-darkest);
} }
// ============================================
// Excalidraw UI Overrides (hand-drawn aesthetic)
// ============================================
.excalidraw {
--border-radius-md: 2px;
.context-menu {
border: 2px solid var(--color-gray-85) !important;
border-radius: 2px !important;
box-shadow: 3px 3px 0 var(--color-gray-85) !important;
}
.library-menu-items-container {
border: 2px solid var(--color-gray-85) !important;
border-radius: 2px !important;
box-shadow: 3px 3px 0 var(--color-gray-85) !important;
}
}
-53
View File
@@ -189,59 +189,6 @@ $color-danger-icon-color: #700000;
--scrollbar-thumb-hover: var(--color-gray-40); --scrollbar-thumb-hover: var(--color-gray-40);
} }
// Dark Mode
@media (prefers-color-scheme: dark) {
:root {
// Grays (inverted)
--color-gray-10: #1a1a1a;
--color-gray-20: #2d2d2d;
--color-gray-30: #3d3d3d;
--color-gray-40: #5c5c5c;
--color-gray-50: #7a7a7a;
--color-gray-60: #999999;
--color-gray-70: #b8b8b8;
--color-gray-80: #d6d6d6;
--color-gray-85: #ebebeb;
--color-gray-90: #f5f5f5;
--color-gray-100: #ffffff;
--island-bg-color: #252525;
--island-bg-color-alt: #2d2d2d;
--color-surface-lowest: #121212;
--color-surface-low: #1a1a1a;
--color-surface-high: #2d2d2d;
--color-on-surface: #f5f5f5;
--color-on-primary-container: #e3e2fe;
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--input-bg-color: #2d2d2d;
--input-border-color: #3d3d3d;
--input-hover-bg-color: #363636;
--input-label-color: var(--color-gray-70);
--default-border-color: #3d3d3d;
--dialog-border-color: #2d2d2d;
--text-primary-color: #f5f5f5;
--link-color: #74c0fc;
--link-color-hover: #a5d8ff;
--scrollbar-thumb: var(--color-gray-40);
--scrollbar-thumb-hover: var(--color-gray-60);
// Shadows need less opacity in dark mode
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.15);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.35);
}
}
// Manual dark mode override // Manual dark mode override
[data-theme="dark"] { [data-theme="dark"] {
// Grays (inverted) // Grays (inverted)
+3
View File
@@ -165,6 +165,9 @@ export type TemplateType =
| 'architecture' | 'architecture'
| 'mindmap' | 'mindmap'
| 'wireframe' | 'wireframe'
| 'retrospective'
| 'swot'
| 'storymap'
| 'empty'; | 'empty';
export interface Template { export interface Template {
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS workspace_notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES workspace_users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
read BOOLEAN NOT NULL DEFAULT FALSE,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_notifications_user ON workspace_notifications(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_notifications_unread ON workspace_notifications(user_id, read);
-- +goose Down
DROP TABLE IF EXISTS workspace_notifications;
+99 -6
View File
@@ -72,12 +72,17 @@ func (a *API) Routes() chi.Router {
r.Post("/drawings/{drawingID}/links", a.handleCreateLink) r.Post("/drawings/{drawingID}/links", a.handleCreateLink)
r.Get("/drawings/{drawingID}/thumbnail", a.handleThumbnail) r.Get("/drawings/{drawingID}/thumbnail", a.handleThumbnail)
r.Get("/templates", a.handleListTemplates) r.Get("/templates", a.handleListTemplates)
r.Post("/templates", a.handleCreateTemplate)
r.Delete("/templates/{templateID}", a.handleDeleteTemplate)
r.Get("/activity", a.handleListActivity) r.Get("/activity", a.handleListActivity)
r.Get("/stats", a.handleStats) r.Get("/stats", a.handleStats)
r.Get("/folders", a.handleListFolders) r.Get("/folders", a.handleListFolders)
r.Post("/folders", a.handleCreateFolder) r.Post("/folders", a.handleCreateFolder)
r.Get("/projects", a.handleListProjects) r.Get("/projects", a.handleListProjects)
r.Post("/projects", a.handleCreateProject) r.Post("/projects", a.handleCreateProject)
r.Get("/notifications", a.handleListNotifications)
r.Post("/notifications/{notificationID}/read", a.handleMarkNotificationRead)
r.Post("/notifications/read-all", a.handleMarkAllNotificationsRead)
}) })
return r return r
@@ -134,11 +139,26 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
expectedHTTP := "http://" + r.Host host := r.Host
expectedHTTPS := "https://" + r.Host if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" {
if origin != expectedHTTP && origin != expectedHTTPS { host = fwd
writeError(w, http.StatusForbidden, "Cross-origin mutation denied") }
return proto := "http"
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
proto = fwd
} else if r.TLS != nil {
proto = "https"
}
expected := proto + "://" + host
if origin != expected {
// also allow without port in case proxy strips it
expectedNoPort := proto + "://" + strings.SplitN(host, ":", 2)[0]
originNoPort := strings.SplitN(origin, "://", 2)[1]
originNoPort = strings.SplitN(originNoPort, ":", 2)[0]
if originNoPort != expectedNoPort {
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
return
}
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
@@ -342,7 +362,7 @@ func (a *API) handleArchiveDrawing(w http.ResponseWriter, r *http.Request) {
writeLookupError(w, err) writeLookupError(w, err)
return return
} }
w.WriteHeader(http.StatusNoContent) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
func (a *API) handleListRevisions(w http.ResponseWriter, r *http.Request) { func (a *API) handleListRevisions(w http.ResponseWriter, r *http.Request) {
@@ -493,6 +513,30 @@ func (a *API) handleListTemplates(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, templates) writeJSON(w, http.StatusOK, templates)
} }
func (a *API) handleCreateTemplate(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateTemplateRequest
if !decodeJSON(w, r, &req, 5<<20) {
return
}
template, err := a.store.CreateTemplate(r.Context(), user.ID, req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, template)
}
func (a *API) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
templateID := chi.URLParam(r, "templateID")
if err := a.store.DeleteTemplate(r.Context(), user.ID, templateID); err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *API) handleListActivity(w http.ResponseWriter, r *http.Request) { func (a *API) handleListActivity(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r) user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id")) teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
@@ -644,6 +688,55 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
}) })
} }
// Notification handlers
func (a *API) handleListNotifications(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
if user == nil {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
return
}
ctx := r.Context()
notifications, err := a.store.ListNotifications(ctx, user.ID, 50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, notifications)
}
func (a *API) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
if user == nil {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
return
}
ctx := r.Context()
notificationID := chi.URLParam(r, "notificationID")
if err := a.store.MarkNotificationRead(ctx, user.ID, notificationID); err != nil {
if errors.Is(err, ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *API) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
if user == nil {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
return
}
ctx := r.Context()
if err := a.store.MarkAllNotificationsRead(ctx, user.ID); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func isSecureRequest(r *http.Request) bool { func isSecureRequest(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
} }
+21
View File
@@ -143,6 +143,14 @@ type Template struct {
PreviewURL *string `json:"preview_url,omitempty"` PreviewURL *string `json:"preview_url,omitempty"`
} }
type CreateTemplateRequest struct {
TeamID string `json:"team_id"`
Name string `json:"name"`
Description string `json:"description"`
Snapshot json.RawMessage `json:"snapshot"`
Metadata map[string]any `json:"metadata"`
}
type ActivityEvent struct { type ActivityEvent struct {
ID string `json:"id"` ID string `json:"id"`
ActorUserID *string `json:"actor_user_id"` ActorUserID *string `json:"actor_user_id"`
@@ -204,6 +212,19 @@ type LinkReference struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type Notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
ResourceType string `json:"resource_type,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
Read bool `json:"read"`
MetadataJSON map[string]any `json:"metadata_json"`
CreatedAt time.Time `json:"created_at"`
}
type WorkspaceStats struct { type WorkspaceStats struct {
Teams int `json:"teams"` Teams int `json:"teams"`
Members int `json:"members"` Members int `json:"members"`
+157 -1
View File
@@ -586,7 +586,7 @@ func (s *Store) GetDrawing(ctx context.Context, userID, drawingID string) (*Draw
u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at
FROM workspace_drawings d FROM workspace_drawings d
JOIN workspace_users u ON u.id = d.owner_user_id JOIN workspace_users u ON u.id = d.owner_user_id
WHERE d.id = ? AND d.deleted_at IS NULL`, drawingID) WHERE d.id = ?`, drawingID)
return scanDrawing(row) return scanDrawing(row)
} }
@@ -733,6 +733,82 @@ func (s *Store) ListTemplates(ctx context.Context, userID, teamID string) ([]Tem
return templates, rows.Err() return templates, rows.Err()
} }
func (s *Store) CreateTemplate(ctx context.Context, userID string, req CreateTemplateRequest) (*Template, error) {
teamID := strings.TrimSpace(req.TeamID)
if teamID == "" {
teamID, _ = s.defaultTeamID(ctx, userID)
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return nil, ErrForbidden
}
name := strings.TrimSpace(req.Name)
if name == "" || len(name) > 120 {
return nil, fmt.Errorf("template name must be between 1 and 120 characters")
}
if len(req.Snapshot) == 0 || !json.Valid(req.Snapshot) {
return nil, fmt.Errorf("snapshot must be valid JSON")
}
now := time.Now().UTC()
template := &Template{
ID: newID(),
TeamID: &teamID,
Scope: "team",
Type: "custom",
Name: name,
Description: ptr(strings.TrimSpace(req.Description)),
SnapshotPath: fmt.Sprintf("teams/%s/templates/%s.json", teamID, newID()),
MetadataJSON: req.Metadata,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
metadata, _ := json.Marshal(template.MetadataJSON)
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_templates
(id, team_id, scope, type, name, description, snapshot_path, metadata_json, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
template.ID, template.TeamID, template.Scope, template.Type, template.Name, template.Description,
template.SnapshotPath, string(metadata), template.CreatedBy, template.CreatedAt, template.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = s.insertActivity(ctx, &userID, &teamID, "template", template.ID, "template_created", map[string]any{"name": template.Name})
return template, nil
}
func (s *Store) DeleteTemplate(ctx context.Context, userID, templateID string) error {
var teamID, createdBy string
err := s.db.QueryRowContext(ctx, `SELECT team_id, created_by FROM workspace_templates WHERE id = ?`, templateID).Scan(&teamID, &createdBy)
if err != nil {
if err == sql.ErrNoRows {
return ErrNotFound
}
return err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return ErrForbidden
}
// Only creator or team admin can delete
if createdBy != userID {
// Check if user is admin
var role string
err := s.db.QueryRowContext(ctx, `SELECT role FROM workspace_team_memberships WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role)
if err != nil || (role != "admin" && role != "owner") {
return ErrForbidden
}
}
_, err = s.db.ExecContext(ctx, `DELETE FROM workspace_templates WHERE id = ?`, templateID)
return err
}
func (s *Store) ListActivity(ctx context.Context, userID, teamID string, limit int) ([]ActivityEvent, error) { func (s *Store) ListActivity(ctx context.Context, userID, teamID string, limit int) ([]ActivityEvent, error) {
if limit <= 0 || limit > 100 { if limit <= 0 || limit > 100 {
limit = 50 limit = 50
@@ -1162,6 +1238,86 @@ func uniqueTeamSlug(ctx context.Context, tx *dbpostgres.Tx, base string) string
var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`) var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`)
// Notifications
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := s.db.QueryContext(ctx, `SELECT id, user_id, type, title, description, resource_type, resource_id, read, metadata_json, created_at
FROM workspace_notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
notifications := []Notification{}
for rows.Next() {
var n Notification
var metadata string
var resourceType sql.NullString
var resourceID sql.NullString
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &n.Title, &n.Description, &resourceType, &resourceID, &n.Read, &metadata, &n.CreatedAt); err != nil {
return nil, err
}
if resourceType.Valid {
n.ResourceType = resourceType.String
}
if resourceID.Valid {
n.ResourceID = resourceID.String
}
_ = json.Unmarshal([]byte(metadata), &n.MetadataJSON)
if n.MetadataJSON == nil {
n.MetadataJSON = map[string]any{}
}
notifications = append(notifications, n)
}
return notifications, rows.Err()
}
func (s *Store) MarkNotificationRead(ctx context.Context, userID, notificationID string) error {
res, err := s.db.ExecContext(ctx, `UPDATE workspace_notifications SET read = TRUE WHERE id = ? AND user_id = ?`, notificationID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
_, err := s.db.ExecContext(ctx, `UPDATE workspace_notifications SET read = TRUE WHERE user_id = ? AND read = FALSE`, userID)
return err
}
func (s *Store) CreateNotification(ctx context.Context, userID, nType, title, description, resourceType, resourceID string, metadata map[string]any) (*Notification, error) {
metadataJSON := []byte("{}")
if metadata != nil {
b, _ := json.Marshal(metadata)
metadataJSON = b
}
now := time.Now().UTC()
n := &Notification{
ID: newID(),
UserID: userID,
Type: nType,
Title: title,
Description: description,
ResourceType: resourceType,
ResourceID: resourceID,
Read: false,
MetadataJSON: map[string]any{},
CreatedAt: now,
}
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_notifications (id, user_id, type, title, description, resource_type, resource_id, read, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.UserID, n.Type, n.Title, n.Description, resourceType, resourceID, false, string(metadataJSON), now)
if err != nil {
return nil, err
}
_ = json.Unmarshal(metadataJSON, &n.MetadataJSON)
return n, nil
}
func slugify(value string) string { func slugify(value string) string {
value = strings.ToLower(strings.TrimSpace(value)) value = strings.ToLower(strings.TrimSpace(value))
value = nonSlugChars.ReplaceAllString(value, "-") value = nonSlugChars.ReplaceAllString(value, "-")