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

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

Key changes include:

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

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

- **Database & Infrastructure**:
  - Added a new PostgreSQL migration for the `notifications` table.
  - Updated the data models in the workspace to support templates (including snapshot storage) and notifications.
  - Updated `.gitignore` to exclude graphify cache and AST files.
This commit is contained in:
Tomas Dvorak
2026-05-01 15:07:38 +02:00
parent f3f9e99a97
commit 462a70933d
28 changed files with 26645 additions and 289 deletions
+45 -4
View File
@@ -12,7 +12,7 @@ export const FileBrowser: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
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 [sortBy, setSortBy] = useState<'name' | 'updated' | 'created'>('updated');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
@@ -37,6 +37,10 @@ export const FileBrowser: React.FC = () => {
// Move state
const [movingId, setMovingId] = useState<string | null>(null);
// New drawing name modal state
const [showNameModal, setShowNameModal] = useState(false);
const [newDrawingName, setNewDrawingName] = useState('');
// Modal state
const [modal, setModal] = useState<{
open: boolean;
@@ -118,11 +122,18 @@ export const FileBrowser: React.FC = () => {
[navigate]
);
const handleCreateDrawing = async () => {
const handleCreateDrawing = () => {
setNewDrawingName('');
setShowNameModal(true);
};
const confirmCreateDrawing = async () => {
const title = newDrawingName.trim() || 'Untitled Drawing';
setIsCreating(true);
setShowNameModal(false);
try {
const newDrawing = await api.drawings.create({
title: 'Untitled Drawing',
title,
visibility: 'team',
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 () => {
try {
await api.drawings.delete(drawing.id);
setDrawings(drawings.filter(d => d.id !== drawing.id));
removeDrawing(drawing.id);
setActiveMenu(null);
setModal(m => ({ ...m, open: false }));
} catch (err) {
@@ -489,6 +500,36 @@ export const FileBrowser: React.FC = () => {
)}
</main>
</div>
{showNameModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="new-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setShowNameModal(false); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="new-drawing-title">New Drawing</h3>
<button className={styles.modalClose} onClick={() => setShowNameModal(false)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<label htmlFor="drawing-name">Name</label>
<input
id="drawing-name"
type="text"
autoFocus
placeholder="Untitled Drawing"
value={newDrawingName}
onChange={(e) => setNewDrawingName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmCreateDrawing(); if (e.key === 'Escape') setShowNameModal(false); }}
className={styles.modalInput}
/>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setShowNameModal(false)}>Cancel</button>
<button className={styles.modalBtnPrimary} onClick={confirmCreateDrawing} disabled={isCreating}>
{isCreating ? <Loader2 size={16} className={styles.spinner} /> : 'Create'}
</button>
</div>
</div>
</div>
)}
</div>
</>
);