mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 13:52:56 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
</div>
|
||||||
<div className={styles.statValue}>{stat.value}</div>
|
<HandDrawnChart value={stat.chartValue} max={stat.max} color={stat.color} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.statValue} style={{ color: stat.color }}>{stat.value}</div>
|
||||||
<div className={styles.statLabel}>{stat.label}</div>
|
<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">×</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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
// Load revisions
|
||||||
|
let revisionsData: DrawingRevision[] = [];
|
||||||
|
try {
|
||||||
|
revisionsData = await api.revisions.list(id);
|
||||||
setRevisions(revisionsData);
|
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 {
|
||||||
|
const rawSnapshot = revisionsData[0].snapshot;
|
||||||
|
const snapshot = typeof rawSnapshot === 'string' ? JSON.parse(rawSnapshot) : rawSnapshot;
|
||||||
setInitialData({
|
setInitialData({
|
||||||
elements: snapshot.elements || [],
|
elements: snapshot.elements || [],
|
||||||
appState: appStateWithoutGrid(snapshot.appState || {}),
|
appState: appStateWithoutGrid(snapshot.appState || {}),
|
||||||
files: snapshot.files || {},
|
files: snapshot.files || {},
|
||||||
});
|
});
|
||||||
lastSavedDataRef.current = JSON.stringify(snapshot);
|
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,27 +167,31 @@ 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
|
||||||
lastToggledCheckboxRef.current = null;
|
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox') {
|
||||||
} else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) {
|
if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedEl.id) {
|
||||||
lastToggledCheckboxRef.current = selectedCheckbox.id;
|
lastToggledCheckboxRef.current = selectedEl.id;
|
||||||
const nextChecked = !((selectedCheckbox.customData as Record<string, unknown> | undefined)?.checked as boolean);
|
const nextChecked = !((selectedEl.customData as Record<string, unknown> | undefined)?.checked as boolean);
|
||||||
const nextElements = elements.map((el) => (
|
const nextElements = elements.map((el) => (
|
||||||
el.id === selectedCheckbox.id
|
el.id === selectedEl.id
|
||||||
? {
|
? {
|
||||||
...el,
|
...el,
|
||||||
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
|
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
|
||||||
|
fillStyle: (nextChecked ? 'solid' : 'hachure') as 'solid' | 'hachure',
|
||||||
customData: {
|
customData: {
|
||||||
...((el.customData as Record<string, unknown> | undefined) || {}),
|
...((el.customData as Record<string, unknown> | undefined) || {}),
|
||||||
checked: nextChecked,
|
checked: nextChecked,
|
||||||
@@ -178,6 +211,164 @@ export const Editor: React.FC = () => {
|
|||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
lastToggledCheckboxRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "+" add button click
|
||||||
|
if (selectedEl && (selectedEl.customData as Record<string, unknown> | undefined)?.action === 'add' && excalidrawAPI) {
|
||||||
|
const customData = (selectedEl.customData as Record<string, unknown>) || {};
|
||||||
|
const role = customData.templateRole as string;
|
||||||
|
const btnX = (selectedEl.x as number) || 0;
|
||||||
|
const btnY = (selectedEl.y as number) || 0;
|
||||||
|
const newElements: LooseElement[] = [];
|
||||||
|
const uid = () => `el-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const tid = () => `txt-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
if (role.startsWith('todo-add') || role.startsWith('checklist-add')) {
|
||||||
|
// Add a new checkbox + text row below the button
|
||||||
|
const newY = btnY + 30;
|
||||||
|
newElements.push({
|
||||||
|
id: uid(), type: 'rectangle', x: btnX, y: newY, width: 20, height: 20,
|
||||||
|
angle: 0, strokeColor: '#1e1e1e', backgroundColor: 'transparent', fillStyle: 'hachure',
|
||||||
|
strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, groupIds: [],
|
||||||
|
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,
|
||||||
|
customData: { templateRole: 'checkbox', checked: false },
|
||||||
|
});
|
||||||
|
newElements.push({
|
||||||
|
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 = {
|
||||||
elements: elements as unknown as ExcalidrawElement[],
|
elements: elements as unknown as ExcalidrawElement[],
|
||||||
@@ -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">×</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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">×</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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,6 +97,7 @@ 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>
|
||||||
|
<div className={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className={styles.useBtn}
|
className={styles.useBtn}
|
||||||
@@ -94,21 +108,23 @@ export const Templates: React.FC = () => {
|
|||||||
<FilePlus size={14} />
|
<FilePlus size={14} />
|
||||||
Use Template
|
Use Template
|
||||||
</Button>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
+24207
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;
|
||||||
+97
-4
@@ -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,12 +139,27 @@ 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
|
||||||
|
}
|
||||||
|
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")
|
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
|
||||||
return
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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, "-")
|
||||||
|
|||||||
Reference in New Issue
Block a user