MVP
This commit is contained in:
@@ -1,73 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
+14
-11
@@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ExcaliDash</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Generated
+3806
-65
File diff suppressed because it is too large
Load Diff
+23
-8
@@ -10,21 +10,36 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"vite": "^7.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
+17
-30
@@ -1,35 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Editor } from './pages/Editor';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
<ThemeProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/collections" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/editor/:id" element={<Editor />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import axios from 'axios';
|
||||
import type { Drawing, Collection } from '../types';
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
});
|
||||
|
||||
export const getDrawings = async (search?: string, collectionId?: string | null) => {
|
||||
const params: any = {};
|
||||
if (search) params.search = search;
|
||||
if (collectionId !== undefined) params.collectionId = collectionId === null ? 'null' : collectionId;
|
||||
const response = await api.get<Drawing[]>('/drawings', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getDrawing = async (id: string) => {
|
||||
const response = await api.get<Drawing>(`/drawings/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createDrawing = async (name?: string, collectionId?: string | null) => {
|
||||
const response = await api.post<{ id: string }>('/drawings', { name, collectionId });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateDrawing = async (id: string, data: Partial<Drawing>) => {
|
||||
const response = await api.put<{ success: true }>(`/drawings/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteDrawing = async (id: string) => {
|
||||
const response = await api.delete<{ success: true }>(`/drawings/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const duplicateDrawing = async (id: string) => {
|
||||
const response = await api.post<{ id: string }>(`/drawings/${id}/duplicate`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getCollections = async () => {
|
||||
const response = await api.get<Collection[]>('/collections');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createCollection = async (name: string) => {
|
||||
const response = await api.post<Collection>('/collections', { name });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateCollection = async (id: string, name: string) => {
|
||||
const response = await api.put<{ success: true }>(`/collections/${id}`, { name });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteCollection = async (id: string) => {
|
||||
const response = await api.delete<{ success: true }>(`/collections/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
Binary file not shown.
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isDangerous?: boolean; // Makes confirm button red
|
||||
showCancel?: boolean;
|
||||
}
|
||||
|
||||
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Delete",
|
||||
cancelText = "Cancel",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isDangerous = true,
|
||||
showCancel = true
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-neutral-900/20 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md bg-white rounded-2xl border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] p-6 animate-in fade-in zoom-in-95 duration-200">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="absolute right-4 top-4 text-neutral-400 hover:text-neutral-900 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-rose-100 flex items-center justify-center text-rose-600 border-2 border-rose-200">
|
||||
<AlertTriangle size={24} strokeWidth={2.5} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-bold text-neutral-900 tracking-tight">{title}</h3>
|
||||
<p className="text-sm font-medium text-neutral-500 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full mt-2">
|
||||
{/* Green for Cancel/No */}
|
||||
{showCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2.5 bg-emerald-50 text-emerald-700 font-bold rounded-xl border-2 border-emerald-200 hover:bg-emerald-100 hover:border-emerald-300 hover:-translate-y-0.5 transition-all duration-200"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Red for Confirm/Action */}
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 px-4 py-2.5 font-bold rounded-xl border-2 border-black shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-0.5 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-all duration-200 ${isDangerous
|
||||
? 'bg-rose-600 text-white'
|
||||
: 'bg-indigo-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy } from 'lucide-react';
|
||||
import type { Drawing, Collection } from '../types';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
import { exportToSvg } from "@excalidraw/excalidraw";
|
||||
|
||||
import * as api from '../api';
|
||||
|
||||
interface DrawingCardProps {
|
||||
drawing: Drawing;
|
||||
collections: Collection[];
|
||||
isSelected: boolean;
|
||||
isTrash?: boolean;
|
||||
onToggleSelection: (e: React.MouseEvent) => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onMoveToCollection: (id: string, collectionId: string | null) => void;
|
||||
onDuplicate: (id: string) => void;
|
||||
onClick: (id: string, e: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.DragEvent, id: string) => void;
|
||||
onMouseDown?: (e: React.MouseEvent, id: string) => void;
|
||||
onPreviewGenerated?: (id: string, preview: string) => void;
|
||||
}
|
||||
|
||||
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return createPortal(children, document.body);
|
||||
};
|
||||
|
||||
export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
drawing,
|
||||
collections,
|
||||
isSelected,
|
||||
isTrash = false,
|
||||
onToggleSelection,
|
||||
onRename,
|
||||
onDelete,
|
||||
onMoveToCollection,
|
||||
onDuplicate,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onMouseDown,
|
||||
onPreviewGenerated,
|
||||
}) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
|
||||
const [showCollectionDropdown, setShowCollectionDropdown] = useState(false);
|
||||
const [newName, setNewName] = useState(drawing.name);
|
||||
const [previewSvg, setPreviewSvg] = useState<string | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (drawing.preview) {
|
||||
setPreviewSvg(drawing.preview);
|
||||
return;
|
||||
}
|
||||
|
||||
const generatePreview = async () => {
|
||||
// Ensure elements and appState exist before trying to generate
|
||||
if (!drawing.elements || !drawing.appState) return;
|
||||
|
||||
try {
|
||||
const svg = await exportToSvg({
|
||||
elements: drawing.elements,
|
||||
appState: {
|
||||
...drawing.appState,
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: drawing.appState.viewBackgroundColor || "#ffffff"
|
||||
},
|
||||
files: null,
|
||||
exportPadding: 10
|
||||
});
|
||||
const previewHtml = svg.outerHTML;
|
||||
setPreviewSvg(previewHtml);
|
||||
|
||||
// Save to backend and notify parent
|
||||
api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error);
|
||||
onPreviewGenerated?.(drawing.id, previewHtml);
|
||||
} catch (e) {
|
||||
console.error("Failed to generate preview", e);
|
||||
}
|
||||
};
|
||||
generatePreview();
|
||||
}, [drawing, onPreviewGenerated]);
|
||||
|
||||
// Close context menu on click outside
|
||||
useEffect(() => {
|
||||
const handleClick = () => setContextMenu(null);
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
const handleRenameSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newName.trim()) {
|
||||
onRename(drawing.id, newName);
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
setShowMoveSubmenu(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id={`drawing-card-${drawing.id}`}
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable={!isRenaming}
|
||||
onDragStart={(e) => {
|
||||
if (isRenaming) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.dataTransfer.setData('drawingId', drawing.id);
|
||||
onDragStart?.(e, drawing.id);
|
||||
}}
|
||||
onMouseDown={(e) => onMouseDown?.(e, drawing.id)}
|
||||
className={clsx(
|
||||
"drawing-card group relative flex flex-col bg-white dark:bg-neutral-900 rounded-2xl border-2 transition-all duration-200 ease-out",
|
||||
!isTrash && "hover:-translate-y-1 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)]",
|
||||
isTrash && "shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] opacity-80 grayscale-[0.5]",
|
||||
// "always show the border for trash" -> It already has a border. Maybe "always show shadow"?
|
||||
// I added default shadow for trash and reduced opacity to indicate trash state.
|
||||
isSelected ? "border-neutral-500 dark:border-neutral-500 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)]" : "border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
|
||||
)}
|
||||
>
|
||||
{/* Selection Toggle */}
|
||||
<div className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-200" style={{ opacity: isSelected ? 1 : undefined }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSelection(e); }}
|
||||
className={clsx(
|
||||
"w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all duration-200 shadow-sm",
|
||||
isSelected ? "bg-neutral-600 dark:bg-neutral-500 border-neutral-600 dark:border-neutral-500 text-white" : "bg-white dark:bg-neutral-800 border-slate-300 dark:border-neutral-600 hover:border-neutral-500 dark:hover:border-neutral-400"
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check size={14} strokeWidth={3} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview Area */}
|
||||
<div
|
||||
onClick={(e) => !isTrash && onClick(drawing.id, e)}
|
||||
className={clsx(
|
||||
"aspect-[16/10] bg-slate-50 dark:bg-neutral-800/50 relative overflow-hidden flex items-center justify-center border-b-2 border-black dark:border-neutral-700 rounded-t-xl transition-colors",
|
||||
!isTrash && "cursor-pointer group-hover:bg-neutral-100/30 dark:group-hover:bg-neutral-800",
|
||||
isTrash && "cursor-default"
|
||||
)}
|
||||
>
|
||||
{/* Placeholder Grid Pattern */}
|
||||
<div className="absolute inset-0 opacity-[0.3] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] [background-size:24px_24px]"></div>
|
||||
|
||||
{previewSvg ? (
|
||||
<div
|
||||
className="w-full h-full p-6 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:object-contain [&>svg]:drop-shadow-sm dark:[&>svg]:invert dark:[&>svg_rect[fill='white']]:opacity-0 dark:[&>svg_rect[fill='#ffffff']]:opacity-0 transition-transform duration-500 group-hover:scale-105"
|
||||
dangerouslySetInnerHTML={{ __html: previewSvg }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 bg-white dark:bg-neutral-900 rounded-2xl shadow-sm flex items-center justify-center text-neutral-300 dark:text-neutral-400 border border-slate-100 dark:border-neutral-700 transform group-hover:scale-110 group-hover:rotate-3 transition-all duration-500">
|
||||
<PenTool size={40} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-5 bg-white dark:bg-neutral-900 rounded-b-2xl relative z-10">
|
||||
{isRenaming ? (
|
||||
<form
|
||||
onSubmit={handleRenameSubmit}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onBlur={() => setIsRenaming(false)}
|
||||
onDragStart={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-full px-2 py-1 -ml-2 text-base font-bold text-slate-900 dark:text-white border-2 border-black dark:border-neutral-600 rounded-lg focus:outline-none shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] bg-white dark:bg-neutral-800"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<h3
|
||||
className="text-base font-bold text-slate-800 dark:text-neutral-100 truncate cursor-text select-none group-hover:text-neutral-900 dark:group-hover:text-white transition-colors"
|
||||
title={drawing.name}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
}}
|
||||
>
|
||||
{drawing.name}
|
||||
</h3>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-3 relative">
|
||||
<p className="text-[11px] font-medium text-slate-400 dark:text-neutral-500 flex items-center gap-1.5">
|
||||
<Clock size={11} />
|
||||
{formatDistanceToNow(drawing.updatedAt)} ago
|
||||
</p>
|
||||
|
||||
<div className="relative" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setShowCollectionDropdown(!showCollectionDropdown)}
|
||||
className="px-2 py-1 rounded-md bg-slate-50 dark:bg-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-200 text-slate-500 dark:text-neutral-400 text-[10px] font-bold uppercase tracking-wide max-w-[120px] truncate transition-all cursor-pointer border border-slate-100 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600"
|
||||
>
|
||||
{drawing.collectionId ? (collections.find(c => c.id === drawing.collectionId)?.name || 'Collection') : 'Unorganized'}
|
||||
</button>
|
||||
|
||||
{showCollectionDropdown && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowCollectionDropdown(false)} />
|
||||
<div className="absolute right-0 bottom-8 w-48 bg-white dark:bg-neutral-900 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] z-20 py-1 max-h-56 overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-100">
|
||||
<button
|
||||
onClick={() => { onMoveToCollection(drawing.id, null); setShowCollectionDropdown(false); }}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors",
|
||||
drawing.collectionId === null ? "text-neutral-900 dark:text-white font-bold bg-neutral-100 dark:bg-neutral-800" : "text-slate-600 dark:text-neutral-400"
|
||||
)}
|
||||
>
|
||||
Unorganized
|
||||
{drawing.collectionId === null && <Check size={12} />}
|
||||
</button>
|
||||
{collections.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => { onMoveToCollection(drawing.id, c.id); setShowCollectionDropdown(false); }}
|
||||
className={clsx(
|
||||
"w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors truncate",
|
||||
drawing.collectionId === c.id ? "text-neutral-900 dark:text-white font-bold bg-neutral-100 dark:bg-neutral-800" : "text-slate-600 dark:text-neutral-400"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{c.name}</span>
|
||||
{drawing.collectionId === c.id && <Check size={12} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Menu Portal */}
|
||||
{contextMenu && (
|
||||
<ContextMenuPortal>
|
||||
<div
|
||||
className="fixed inset-0 z-50"
|
||||
onClick={() => setContextMenu(null)}
|
||||
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-white dark:bg-neutral-900 rounded-lg border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] py-1 min-w-[160px] animate-in fade-in zoom-in-95 duration-100"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRenaming(true);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
|
||||
>
|
||||
<PenTool size={14} /> Rename
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="relative group/move"
|
||||
onMouseEnter={() => setShowMoveSubmenu(true)}
|
||||
onMouseLeave={() => setShowMoveSubmenu(false)}
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2"><FolderInput size={14} /> Move to...</span>
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
|
||||
{showMoveSubmenu && (
|
||||
<div className="absolute left-full top-0 ml-1 w-40 bg-white dark:bg-neutral-900 rounded-lg border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] py-1 max-h-64 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => { onMoveToCollection(drawing.id, null); setContextMenu(null); }}
|
||||
className={clsx(
|
||||
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800",
|
||||
drawing.collectionId === null ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
|
||||
)}
|
||||
>
|
||||
Unorganized
|
||||
{drawing.collectionId === null && <Check size={10} />}
|
||||
</button>
|
||||
{collections.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => { onMoveToCollection(drawing.id, c.id); setContextMenu(null); }}
|
||||
className={clsx(
|
||||
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 truncate",
|
||||
drawing.collectionId === c.id ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{c.name}</span>
|
||||
{drawing.collectionId === c.id && <Check size={10} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onDuplicate(drawing.id);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
|
||||
>
|
||||
<Copy size={14} /> Duplicate
|
||||
</button>
|
||||
|
||||
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onDelete(drawing.id);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import type { Collection } from '../types';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
collections: Collection[];
|
||||
selectedCollectionId: string | null | undefined;
|
||||
onSelectCollection: (id: string | null | undefined) => void;
|
||||
onCreateCollection: (name: string) => void;
|
||||
onEditCollection: (id: string, name: string) => void;
|
||||
onDeleteCollection: (id: string) => void;
|
||||
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
|
||||
}
|
||||
|
||||
export const Layout: React.FC<LayoutProps> = ({
|
||||
children,
|
||||
collections,
|
||||
selectedCollectionId,
|
||||
onSelectCollection,
|
||||
onCreateCollection,
|
||||
onEditCollection,
|
||||
onDeleteCollection,
|
||||
onDrop
|
||||
}) => {
|
||||
return (
|
||||
<div className="h-screen w-full bg-[#F3F4F6] dark:bg-neutral-950 p-4 transition-colors duration-200 overflow-hidden">
|
||||
<div className="flex gap-4 items-start h-full">
|
||||
<aside className="flex-shrink-0 w-[260px] h-full bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] overflow-hidden z-20 transition-colors duration-200">
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
selectedCollectionId={selectedCollectionId}
|
||||
onSelectCollection={onSelectCollection}
|
||||
onCreateCollection={onCreateCollection}
|
||||
onEditCollection={onEditCollection}
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
</aside>
|
||||
<main className="flex-1 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm h-full transition-colors duration-200 overflow-y-auto">
|
||||
<div className="max-w-[1600px] mx-auto p-6 lg:p-8 min-h-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,407 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon } from 'lucide-react';
|
||||
import type { Collection } from '../types';
|
||||
import clsx from 'clsx';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
interface SidebarProps {
|
||||
collections: Collection[];
|
||||
selectedCollectionId: string | null | undefined;
|
||||
onSelectCollection: (id: string | null | undefined) => void;
|
||||
onCreateCollection: (name: string) => void;
|
||||
onEditCollection: (id: string, name: string) => void;
|
||||
onDeleteCollection: (id: string) => void;
|
||||
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
|
||||
}
|
||||
|
||||
interface SidebarItemProps {
|
||||
id: string | null; // null for Unorganized
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
extraAction?: React.ReactNode;
|
||||
isEditing?: boolean;
|
||||
editValue?: string;
|
||||
onEditChange?: (val: string) => void;
|
||||
onEditSubmit?: (e: React.FormEvent) => void;
|
||||
onEditBlur?: () => void;
|
||||
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
|
||||
}
|
||||
|
||||
const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
id,
|
||||
icon,
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
extraAction,
|
||||
isEditing,
|
||||
editValue,
|
||||
onEditChange,
|
||||
onEditSubmit,
|
||||
onEditBlur,
|
||||
onDrop
|
||||
}) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative group/item px-3">
|
||||
{isEditing ? (
|
||||
<form onSubmit={onEditSubmit} className="py-1">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => onEditChange?.(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] outline-none font-bold text-slate-900 dark:text-white"
|
||||
onBlur={onEditBlur}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
onDrop?.(e, id);
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2 group cursor-pointer outline-none focus:ring-2 focus:ring-indigo-500",
|
||||
isActive || isDragOver
|
||||
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5"
|
||||
: "text-slate-600 dark:text-neutral-400 border-transparent hover:bg-slate-50 dark:hover:bg-neutral-800 hover:border-black dark:hover:border-neutral-700 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<span className={clsx("transition-colors duration-200", isActive || isDragOver ? "text-indigo-900 dark:text-neutral-200" : "text-slate-400 dark:text-neutral-500 group-hover:text-slate-900 dark:group-hover:text-neutral-200")}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left font-bold">{label}</span>
|
||||
{extraAction && (
|
||||
<div className="opacity-0 group-hover/item:opacity-100 transition-all duration-200 flex items-center gap-1 translate-x-2 group-hover/item:translate-x-0">
|
||||
{extraAction}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
collections,
|
||||
selectedCollectionId,
|
||||
onSelectCollection,
|
||||
onCreateCollection,
|
||||
onEditCollection,
|
||||
onDeleteCollection,
|
||||
onDrop
|
||||
}) => {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'item' | 'background'; id?: string } | null>(null);
|
||||
const [collectionToDelete, setCollectionToDelete] = useState<string | null>(null);
|
||||
const [isTrashDragOver, setIsTrashDragOver] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setContextMenu(null);
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
|
||||
const handleCreateSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newCollectionName.trim()) {
|
||||
onCreateCollection(newCollectionName);
|
||||
setNewCollectionName('');
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingId && editName.trim()) {
|
||||
onEditCollection(editingId, editName);
|
||||
setEditingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemContextMenu = (e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, type: 'item', id });
|
||||
};
|
||||
|
||||
const handleBackgroundContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, type: 'background' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-[260px] flex flex-col h-full bg-transparent">
|
||||
<div className="p-5 pb-2">
|
||||
<h1 className="text-2xl text-slate-900 dark:text-white flex items-center gap-3 tracking-tight" style={{ fontFamily: 'Excalifont' }}>
|
||||
<div className="w-8 h-8 bg-indigo-600 dark:bg-neutral-800 rounded-lg flex items-center justify-center text-white border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]">
|
||||
<LayoutGrid size={18} strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="mt-1">ExcaliDash</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="flex-1 overflow-y-auto py-4 space-y-8 custom-scrollbar"
|
||||
onContextMenu={handleBackgroundContextMenu}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="px-6 pb-2 text-[11px] font-bold text-slate-400 dark:text-neutral-500 uppercase tracking-wider">
|
||||
Library
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<button
|
||||
onClick={() => onSelectCollection(undefined)}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2",
|
||||
selectedCollectionId === undefined
|
||||
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5"
|
||||
: "text-slate-600 dark:text-neutral-400 border-transparent hover:bg-slate-50 dark:hover:bg-neutral-800 hover:border-black dark:hover:border-neutral-700 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<LayoutGrid size={18} className={clsx(selectedCollectionId === undefined ? "text-indigo-900 dark:text-neutral-200" : "text-slate-400 dark:text-neutral-500")} />
|
||||
All Drawings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SidebarItem
|
||||
id={null}
|
||||
icon={<Archive size={18} />}
|
||||
label="Unorganized"
|
||||
isActive={selectedCollectionId === null}
|
||||
onClick={() => onSelectCollection(null)}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between px-6 pb-2 group/header">
|
||||
<span className="text-[11px] font-bold text-slate-400 dark:text-neutral-500 uppercase tracking-wider">Collections</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setIsCreating(true); }}
|
||||
className="p-1 text-slate-400 dark:text-neutral-500 hover:text-indigo-600 dark:hover:text-neutral-200 hover:bg-indigo-50 dark:hover:bg-neutral-800 rounded-md transition-all opacity-0 group-hover/header:opacity-100"
|
||||
title="New Collection"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCreating && (
|
||||
<form onSubmit={handleCreateSubmit} className="mb-2 px-4" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
placeholder="New Collection..."
|
||||
className="w-full px-3 py-2 text-sm bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] outline-none placeholder:text-slate-400 dark:placeholder:text-neutral-500 font-bold text-slate-900 dark:text-white"
|
||||
onBlur={() => !newCollectionName && setIsCreating(false)}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{collections.filter(c => c.name !== 'Trash').map((collection) => (
|
||||
<SidebarItem
|
||||
key={collection.id}
|
||||
id={collection.id}
|
||||
icon={selectedCollectionId === collection.id ? <FolderOpen size={18} /> : <Folder size={18} />}
|
||||
label={collection.name}
|
||||
isActive={selectedCollectionId === collection.id}
|
||||
onClick={() => onSelectCollection(collection.id)}
|
||||
onDoubleClick={() => {
|
||||
setEditingId(collection.id);
|
||||
setEditName(collection.name);
|
||||
}}
|
||||
onContextMenu={(e) => handleItemContextMenu(e, collection.id)}
|
||||
isEditing={editingId === collection.id}
|
||||
editValue={editName}
|
||||
onEditChange={setEditName}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onEditBlur={() => setEditingId(null)}
|
||||
onDrop={onDrop}
|
||||
extraAction={
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingId(collection.id);
|
||||
setEditName(collection.name);
|
||||
}}
|
||||
className="p-1.5 text-slate-400 dark:text-neutral-500 hover:text-indigo-600 dark:hover:text-neutral-200 hover:bg-indigo-50 dark:hover:bg-neutral-800 rounded-md transition-colors"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCollectionToDelete(collection.id);
|
||||
}}
|
||||
className="p-1.5 text-slate-400 dark:text-neutral-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-slate-200/50 dark:border-slate-700/50 space-y-2">
|
||||
<button
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsTrashDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsTrashDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsTrashDragOver(false);
|
||||
const trashId = collections.find(c => c.name === 'Trash')?.id;
|
||||
if (trashId) {
|
||||
onDrop?.(e, trashId);
|
||||
} else {
|
||||
onDrop?.(e, 'TRASH');
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
const trashCollection = collections.find(c => c.name === 'Trash');
|
||||
if (trashCollection) {
|
||||
navigate(`/collections?id=${trashCollection.id}`);
|
||||
} else {
|
||||
onCreateCollection('Trash');
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] mb-4",
|
||||
collections.find(c => c.name === 'Trash')?.id === selectedCollectionId || isTrashDragOver
|
||||
? "bg-rose-50 dark:bg-rose-900/30 text-rose-900 dark:text-rose-300 -translate-y-0.5"
|
||||
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-rose-50 dark:hover:bg-rose-900/30 hover:text-rose-900 dark:hover:text-rose-300 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Trash
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
|
||||
selectedCollectionId === 'SETTINGS'
|
||||
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
|
||||
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<SettingsIcon size={18} />
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed inset-0 z-50"
|
||||
onClick={() => setContextMenu(null)}
|
||||
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-white dark:bg-neutral-800 rounded-lg border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] py-1 min-w-[160px] animate-in fade-in zoom-in-95 duration-100"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{contextMenu.type === 'item' && contextMenu.id ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
const collection = collections.find(c => c.id === contextMenu.id);
|
||||
if (collection) {
|
||||
setEditingId(collection.id);
|
||||
setEditName(collection.name);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-indigo-600 dark:hover:text-indigo-400 flex items-center gap-2"
|
||||
>
|
||||
<Edit2 size={14} /> Rename Collection
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setCollectionToDelete(contextMenu.id!);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={14} /> Delete Collection
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsCreating(true);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-indigo-600 dark:hover:text-indigo-400 flex items-center gap-2"
|
||||
>
|
||||
<Plus size={14} /> New Collection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!collectionToDelete}
|
||||
title="Delete Collection"
|
||||
message="Are you sure you want to delete this collection? All drawings inside will be moved to Unorganized."
|
||||
confirmText="Delete Collection"
|
||||
onConfirm={() => {
|
||||
if (collectionToDelete) {
|
||||
onDeleteCollection(collectionToDelete);
|
||||
setCollectionToDelete(null);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setCollectionToDelete(null)}
|
||||
/>
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
return (savedTheme as Theme) || 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Theme changed to:', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
console.log('Added dark class, classList:', document.documentElement.classList.toString());
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
console.log('Toggling theme');
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
+22
-58
@@ -1,68 +1,32 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
html.dark body {
|
||||
/* Debug: If this turns red/dark, the class is present */
|
||||
background-color: #0a0a0a !important;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
@apply bg-slate-50 text-slate-900 dark:bg-neutral-950 dark:text-neutral-50;
|
||||
background-image: radial-gradient(#cbd5e1 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
html.dark body {
|
||||
background-image: radial-gradient(#262626 1px, transparent 1px);
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Excalifont';
|
||||
src: url('./assets/fonts/Excalifont-Regular.woff2') format('woff2');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Excalidraw, convertToExcalidrawElements } from '@excalidraw/excalidraw';
|
||||
import '@excalidraw/excalidraw/index.css';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { Toaster, toast } from 'sonner';
|
||||
import * as api from '../api';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
|
||||
export const Editor: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [drawingName, setDrawingName] = useState('Drawing Editor');
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [initialData, setInitialData] = useState<any>(null);
|
||||
|
||||
// Refs for API interaction
|
||||
const excalidrawAPI = useRef<any>(null);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. STABLE SAVE LOGIC (The Fix)
|
||||
// We use a Ref to hold the save function so the debounce wrapper
|
||||
// doesn't need to be recreated on every render.
|
||||
// ------------------------------------------------------------------
|
||||
const saveDataRef = useRef<(elements: any, appState: any) => Promise<void>>(null);
|
||||
|
||||
// Update the ref on every render to ensure it has access to the latest props/state
|
||||
saveDataRef.current = async (elements, appState) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const persistableAppState = {
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
gridSize: appState.gridSize,
|
||||
};
|
||||
|
||||
await api.updateDrawing(id, {
|
||||
elements,
|
||||
appState: persistableAppState,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to save drawing', err);
|
||||
toast.error("Failed to save changes");
|
||||
}
|
||||
};
|
||||
|
||||
// Create the debounced function ONLY ONCE.
|
||||
// It simply calls whatever is currently in saveDataRef.current
|
||||
const debouncedSave = useCallback(
|
||||
debounce((elements, appState) => {
|
||||
if (saveDataRef.current) {
|
||||
saveDataRef.current(elements, appState);
|
||||
}
|
||||
}, 1000),
|
||||
[] // Empty dependency array = Stable across renders
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. DATA LOADING
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const data = await api.getDrawing(id);
|
||||
setDrawingName(data.name);
|
||||
|
||||
setInitialData({
|
||||
elements: convertToExcalidrawElements(data.elements || []),
|
||||
appState: {
|
||||
...data.appState,
|
||||
collaborators: new Map(),
|
||||
},
|
||||
scrollToContent: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load drawing', err);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. HANDLERS
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Hijack Ctrl+S to save immediately
|
||||
useEffect(() => {
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (excalidrawAPI.current && saveDataRef.current) {
|
||||
const elements = excalidrawAPI.current.getSceneElements();
|
||||
const appState = excalidrawAPI.current.getAppState();
|
||||
// Call save immediately, bypassing debounce
|
||||
await saveDataRef.current(elements, appState);
|
||||
toast.success("Saved changes to server");
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleCanvasChange = (elements: readonly any[], appState: any) => {
|
||||
// Trigger the stable debounced save
|
||||
debouncedSave(elements, appState);
|
||||
};
|
||||
|
||||
const handleRenameSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newName.trim() && id) {
|
||||
setDrawingName(newName);
|
||||
setIsRenaming(false);
|
||||
try {
|
||||
await api.updateDrawing(id, { name: newName });
|
||||
} catch (err) {
|
||||
console.error("Failed to rename", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Disable native Excalidraw save dialogs
|
||||
const UIOptions = {
|
||||
canvasActions: {
|
||||
saveToActiveFile: false,
|
||||
loadScene: false,
|
||||
export: { saveFileToDisk: false },
|
||||
toggleTheme: true,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-white dark:bg-neutral-950 overflow-hidden">
|
||||
<header className="h-14 bg-white dark:bg-neutral-900 border-b border-gray-200 dark:border-neutral-800 flex items-center px-4 justify-between z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => navigate('/')} className="p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded-full text-gray-600 dark:text-gray-300">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
|
||||
{isRenaming ? (
|
||||
<form onSubmit={handleRenameSubmit}>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onBlur={() => setIsRenaming(false)}
|
||||
className="font-medium text-gray-900 dark:text-white bg-transparent px-2 py-1 border-2 border-indigo-500 rounded-md outline-none"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<h1
|
||||
className="font-medium text-gray-900 dark:text-white px-2 py-1 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded cursor-text"
|
||||
onDoubleClick={() => { setNewName(drawingName); setIsRenaming(true); }}
|
||||
>
|
||||
{drawingName}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status indicator removed */}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 w-full relative" style={{ height: 'calc(100vh - 3.5rem)' }}>
|
||||
<Excalidraw
|
||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
||||
initialData={initialData}
|
||||
onChange={handleCanvasChange}
|
||||
excalidrawAPI={(api) => (excalidrawAPI.current = api)}
|
||||
UIOptions={UIOptions}
|
||||
/>
|
||||
<Toaster position="bottom-center" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Collection } from '../types';
|
||||
import { Database, FileJson, Upload, Moon, Sun } from 'lucide-react';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { importDrawings } from '../utils/importUtils';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
// Import state
|
||||
const [importConfirmation, setImportConfirmation] = useState<{ isOpen: boolean; file: File | null }>({ isOpen: false, file: null });
|
||||
const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' });
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCollections = async () => {
|
||||
try {
|
||||
const data = await api.getCollections();
|
||||
setCollections(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch collections:', err);
|
||||
}
|
||||
};
|
||||
fetchCollections();
|
||||
}, []);
|
||||
|
||||
const handleCreateCollection = async (name: string) => {
|
||||
await api.createCollection(name);
|
||||
const newCollections = await api.getCollections();
|
||||
setCollections(newCollections);
|
||||
};
|
||||
|
||||
const handleEditCollection = async (id: string, name: string) => {
|
||||
setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c));
|
||||
await api.updateCollection(id, name);
|
||||
};
|
||||
|
||||
const handleDeleteCollection = async (id: string) => {
|
||||
setCollections(prev => prev.filter(c => c.id !== id));
|
||||
await api.deleteCollection(id);
|
||||
};
|
||||
|
||||
const handleSelectCollection = (id: string | null | undefined) => {
|
||||
// Navigate to dashboard with selected collection
|
||||
if (id === undefined) navigate('/');
|
||||
else if (id === null) navigate('/collections?id=unorganized');
|
||||
else navigate(`/collections?id=${id}`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Layout
|
||||
collections={collections}
|
||||
selectedCollectionId="SETTINGS" // Special ID to highlight Settings in Sidebar if we add logic for it
|
||||
onSelectCollection={handleSelectCollection}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
onEditCollection={handleEditCollection}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
>
|
||||
<h1 className="text-5xl mb-8 text-slate-900 dark:text-white pl-1" style={{ fontFamily: 'Excalifont' }}>
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-amber-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-amber-100 dark:border-neutral-700 group-hover:border-amber-200 dark:group-hover:border-neutral-600 transition-colors">
|
||||
{theme === 'light' ? (
|
||||
<Moon size={32} className="text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<Sun size={32} className="text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">
|
||||
{theme === 'light' ? 'Dark Mode' : 'Light Mode'}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">
|
||||
Switch to {theme === 'light' ? 'dark' : 'light'} theme
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Export SQLite */}
|
||||
<button
|
||||
onClick={() => window.location.href = `${api.API_URL}/export`}
|
||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-indigo-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-indigo-100 dark:border-neutral-700 group-hover:border-indigo-200 dark:group-hover:border-neutral-600 transition-colors">
|
||||
<Database size={32} className="text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">Export Data (SQLite)</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">Download full database backup</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Export JSON */}
|
||||
<button
|
||||
onClick={() => window.location.href = `${api.API_URL}/export/json`}
|
||||
className="flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-emerald-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-emerald-100 dark:border-neutral-700 group-hover:border-emerald-200 dark:group-hover:border-neutral-600 transition-colors">
|
||||
<FileJson size={32} className="text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">Export Data (JSON)</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">Download drawings as JSON</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Import Data */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".sqlite,.json,.excalidraw"
|
||||
className="hidden"
|
||||
id="settings-import-db"
|
||||
onChange={async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Handle SQLite Import
|
||||
const sqliteFile = files.find(f => f.name.endsWith('.sqlite'));
|
||||
if (sqliteFile) {
|
||||
if (files.length > 1) {
|
||||
setImportError({ isOpen: true, message: 'Please import database files separately from other files.' });
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('db', sqliteFile);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${api.API_URL}/import/sqlite/verify`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
setImportError({ isOpen: true, message: errorData.error || 'Invalid database file.' });
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setImportConfirmation({ isOpen: true, file: sqliteFile });
|
||||
} catch (err) {
|
||||
console.error('Verification failed:', err);
|
||||
setImportError({ isOpen: true, message: 'Failed to verify database file.' });
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Bulk Drawing Import
|
||||
const drawingFiles = files.filter(f => f.name.endsWith('.json') || f.name.endsWith('.excalidraw'));
|
||||
if (drawingFiles.length === 0) {
|
||||
setImportError({ isOpen: true, message: 'No supported files found.' });
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await importDrawings(drawingFiles, null, () => { });
|
||||
|
||||
if (result.failed > 0) {
|
||||
setImportError({
|
||||
isOpen: true,
|
||||
message: `Import complete with errors.\nSuccess: ${result.success}\nFailed: ${result.failed}\nErrors:\n${result.errors.join('\n')}`
|
||||
});
|
||||
} else {
|
||||
setImportSuccess(true);
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => document.getElementById('settings-import-db')?.click()}
|
||||
className="w-full h-full flex flex-col items-center justify-center gap-4 p-8 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[6px_6px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-blue-50 dark:bg-neutral-800 rounded-2xl flex items-center justify-center border-2 border-blue-100 dark:border-neutral-700 group-hover:border-blue-200 dark:group-hover:border-neutral-600 transition-colors">
|
||||
<Upload size={32} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-1">Import Data</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-neutral-400 font-medium">Import SQLite or Drawings</p>
|
||||
</div>
|
||||
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<ConfirmModal
|
||||
isOpen={importConfirmation.isOpen}
|
||||
title="Import Database"
|
||||
message="WARNING: This will overwrite your current database with the imported file. This action cannot be undone. Are you sure?"
|
||||
confirmText="Import Database"
|
||||
onConfirm={async () => {
|
||||
if (!importConfirmation.file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('db', importConfirmation.file);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${api.API_URL}/import/sqlite`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.error || 'Import failed');
|
||||
}
|
||||
|
||||
setImportConfirmation({ isOpen: false, file: null });
|
||||
setImportSuccess(true);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setImportError({ isOpen: true, message: `Failed to import database: ${err.message}` });
|
||||
setImportConfirmation({ isOpen: false, file: null });
|
||||
}
|
||||
}}
|
||||
onCancel={() => setImportConfirmation({ isOpen: false, file: null })}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={importError.isOpen}
|
||||
title="Import Failed"
|
||||
message={importError.message}
|
||||
confirmText="OK"
|
||||
cancelText=""
|
||||
showCancel={false}
|
||||
isDangerous={false}
|
||||
onConfirm={() => setImportError({ isOpen: false, message: '' })}
|
||||
onCancel={() => setImportError({ isOpen: false, message: '' })}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={importSuccess}
|
||||
title="Import Successful"
|
||||
message="Data imported successfully."
|
||||
confirmText="OK"
|
||||
showCancel={false}
|
||||
isDangerous={false}
|
||||
onConfirm={() => setImportSuccess(false)}
|
||||
onCancel={() => setImportSuccess(false)}
|
||||
/>
|
||||
</Layout >
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface Drawing {
|
||||
id: string;
|
||||
name: string;
|
||||
elements: any[];
|
||||
appState: any;
|
||||
collectionId: string | null;
|
||||
updatedAt: number;
|
||||
createdAt: number;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { exportToSvg } from "@excalidraw/excalidraw";
|
||||
import { API_URL } from "../api";
|
||||
|
||||
export const importDrawings = async (
|
||||
files: File[],
|
||||
targetCollectionId: string | null,
|
||||
onSuccess?: () => void | Promise<void>
|
||||
) => {
|
||||
const drawingFiles = files.filter(
|
||||
(f) => f.name.endsWith(".json") || f.name.endsWith(".excalidraw")
|
||||
);
|
||||
|
||||
if (drawingFiles.length === 0) {
|
||||
return { success: 0, failed: 0, errors: ["No supported files found."] };
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
await Promise.all(
|
||||
drawingFiles.map(async (file) => {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
// Basic validation
|
||||
if (!data.elements || !data.appState) {
|
||||
throw new Error(`Invalid file structure: ${file.name}`);
|
||||
}
|
||||
|
||||
// Generate Preview
|
||||
const svg = await exportToSvg({
|
||||
elements: data.elements,
|
||||
appState: {
|
||||
...data.appState,
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: data.appState.viewBackgroundColor || "#ffffff",
|
||||
},
|
||||
files: data.files || null,
|
||||
exportPadding: 10,
|
||||
});
|
||||
|
||||
// Prepare payload
|
||||
const payload = {
|
||||
name: file.name.replace(/\.(json|excalidraw)$/, ""),
|
||||
elements: data.elements,
|
||||
appState: data.appState,
|
||||
files: data.files || null,
|
||||
collectionId: targetCollectionId,
|
||||
createdAt: data.createdAt || Date.now(),
|
||||
updatedAt: data.updatedAt || Date.now(),
|
||||
preview: svg.outerHTML,
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_URL}/drawings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("API Error");
|
||||
successCount++;
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to import ${file.name}:`, err);
|
||||
failCount++;
|
||||
errors.push(`${file.name}: ${err.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (successCount > 0 && onSuccess) {
|
||||
await onSuccess();
|
||||
}
|
||||
|
||||
return { success: successCount, failed: failCount, errors };
|
||||
};
|
||||
|
||||
export const importLibrary = async (file: File) => {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
let newItems = [];
|
||||
if (data.libraryItems) {
|
||||
newItems = data.libraryItems;
|
||||
} else if (Array.isArray(data)) {
|
||||
newItems = data;
|
||||
} else {
|
||||
throw new Error("Invalid library file format");
|
||||
}
|
||||
|
||||
// Fetch existing
|
||||
const existingRes = await fetch(`${API_URL}/library`);
|
||||
let existingItems = [];
|
||||
if (existingRes.ok) {
|
||||
const existingData = await existingRes.json();
|
||||
existingItems = existingData.libraryItems || [];
|
||||
}
|
||||
|
||||
// Merge (simple concat)
|
||||
const mergedItems = [...existingItems, ...newItems];
|
||||
|
||||
// Save
|
||||
const saveRes = await fetch(`${API_URL}/library`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ libraryItems: mergedItems }),
|
||||
});
|
||||
|
||||
if (!saveRes.ok) throw new Error("Failed to save library");
|
||||
return { success: true, count: newItems.length };
|
||||
} catch (err: any) {
|
||||
console.error("Library import failed:", err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
+12
-3
@@ -1,7 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user