Compare commits

..

6 Commits

Author SHA1 Message Date
Tomas Dvorak 54c8088404 fix(db): handle explicit null values in update requests
Docker Images / Build and push (push) Failing after 16s
Introduce a `NullString` type to distinguish between JSON `null` and
absent fields in `UpdateDrawingRequest`. This ensures that when a
field is explicitly set to `null` in a request, the database can
correctly process the update.

Additionally, refactor the folder order migration to include proper
`goose` up/down instructions.
2026-05-21 14:17:58 +02:00
Tomas Dvorak cd22ee1ee8 feat(ui,api): implement multi-select and folder management enhancements
Implements multi-select functionality in the file browser, allowing users to
perform batch actions such as deleting or moving multiple drawings at once.
Adds full CRUD support for folders, including updating folder properties
and reordering folders via a new `sort_order` column in the database.

- feat(ui): add multi-select, batch delete, and batch move in FileBrowser
- feat(api): add endpoints for updating, deleting, and reordering folders
- feat(db): add `sort_order` column and index to `workspace_folders`
- fix(editor): integrate `useHandleLibrary` for better library management
- chore(deps): update excalidraw subproject
2026-05-21 13:20:44 +02:00
Tomas Dvorak 19e7ed6ea1 feat(ui): enhance file browser drag-and-drop and move functionality
Docker Images / Build and push (push) Failing after 15s
Refactor the file browser to improve the user experience and reliability of
folder management and item movement.

- Implement a dedicated drag handle wrapper to improve drag-and-drop
  precision and visual feedback.
- Improve drag-and-drop event handling to prevent accidental triggers
  and ensure correct visual states during drag operations.
- Refactor the move modal logic and styling for better clarity and
  usability.
- Fix folder menu closing logic to correctly handle outside clicks.
- Update CI workflow to ensure Docker is installed before building
  images.
2026-05-10 10:02:02 +02:00
Tomas Dvorak 8336c76705 fix(editor): correct templates, library import, and custom tools
- Move custom tools (checkbox, correct/incorrect, star) to floating
  canvas toolbar above native Excalidraw tools
- Fix correct/incorrect toggle: square text element cycling empty/
  check/cross with debounce to prevent double-firing
- Fix star rating: updates displayed stars on click
- Remove broken infinity-arrow feature entirely
- Fix autosave false "unsaved" on load by skipping first change
  after initial data normalization
- Fix presentation mode: remove opaque overlay blocking canvas,
  add pointer-events to only toolbar/thumbnails
- Fix to-do list add: full text area clickable via customData,
  correct spacing when inserting new tasks
- Add customData to all template "Add..." text elements so every
  template add button + text pair is fully clickable
- Expand generic add handler with role-specific creation for
  brainstorm, retro, swot, storymap, wireframe, timeline, api,
  sitemap, and persona templates
- Fix library import from libraries.excalidraw.com via #addLibrary
  hash: extract into reusable callback, listen to hashchange events,
  and offset imported elements to viewport center

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-10 10:01:06 +02:00
Tomas Dvorak 910546230d refactor(editor): improve type safety and optimize build chunks
Docker Images / Build and push (push) Failing after 21s
Refactor the Editor component to replace `any` types with explicit interfaces for Excalidraw props, library items, and elements, improving type safety and developer experience.

Additionally, update the Vite configuration to implement manual chunking for large dependencies like Excalidraw, React, and Zustand to optimize bundle loading and improve build performance.
2026-05-09 19:27:36 +02:00
Tomas Dvorak 190be65e4f feat(ui): implement folder management and enhance editor functionality
Implements full folder CRUD operations in the file browser, including
renaming, deleting, and drag-and-drop reordering. Enhances the editor
with improved autosave logic and new template role toggling.

- Add folder management (create, update, delete, reorder) to API and UI
- Implement drag-and-drop functionality for folders in FileBrowser
- Add folder context menus and improved styling for Editor and FileBrowser
- Optimize editor autosave to only trigger on actual data changes
- Add support for 'correct-incorrect' template roles in the editor
2026-05-09 18:54:57 +02:00
12 changed files with 1789 additions and 324 deletions
+9
View File
@@ -28,6 +28,15 @@ jobs:
id: image
run: echo "repository=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Set up Docker
run: |
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
rm get-docker.sh
fi
docker --version
- name: Use GitHub token for GHCR
uses: docker/login-action@v3
with:
@@ -48,8 +48,8 @@ function makeHandDrawnRect(x: number, y: number, w: number, h: number, groupId?:
};
}
function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: string) {
return {
function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: string, customData?: Record<string, unknown>) {
const el: RawElement = {
id: `txt-${Math.random().toString(36).slice(2)}`,
type: 'text',
x, y, width: text.length * (fontSize * 0.55), height: fontSize * 1.4,
@@ -82,6 +82,10 @@ function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: s
originalText: text,
lineHeight: 1.25,
};
if (customData) {
el.customData = customData;
}
return el;
}
function makeCheckbox(x: number, y: number, checked = false) {
@@ -158,7 +162,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeCheckbox(60, 210, false),
makeText(90, 210, 'Third task'),
makeAddButton(60, 250, '+', 'todo-add'),
makeText(92, 250, 'Add task...', 16),
makeText(92, 250, 'Add task...', 16, undefined, { templateRole: 'todo-add', action: 'add' }),
makeHandDrawnRect(50, 290, 500, 2),
makeText(60, 310, 'Notes:', 18),
],
@@ -172,7 +176,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeCheckbox(60, 210, false),
makeText(90, 210, 'Another task', 18),
makeAddButton(60, 250, '+', 'checklist-add'),
makeText(92, 250, 'Add item...', 16),
makeText(92, 250, 'Add item...', 16, undefined, { templateRole: 'checklist-add', action: 'add' }),
],
list: [
makeHandDrawnRect(50, 50, 500, 50),
@@ -182,7 +186,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(60, 210, '• Third bullet point'),
makeText(60, 250, '• Fourth item with details'),
makeAddButton(60, 290, '+', 'list-add'),
makeText(92, 290, 'Add bullet...', 16),
makeText(92, 290, 'Add bullet...', 16, undefined, { templateRole: 'list-add', action: 'add' }),
],
flow: [
makeHandDrawnRect(200, 50, 200, 60),
@@ -197,7 +201,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(200, 350, 200, 60),
makeText(230, 370, 'End', 20),
makeAddButton(420, 180, '+', 'flow-add'),
makeText(452, 180, 'Add step', 14),
makeText(452, 180, 'Add step', 14, undefined, { templateRole: 'flow-add', action: 'add' }),
],
kanban: [
makeText(50, 40, 'Kanban Board', 30),
@@ -234,7 +238,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeCheckbox(70, 390, false),
makeText(105, 390, 'Owner and next step', 18),
makeAddButton(70, 430, '+', 'meeting-add-action'),
makeText(102, 430, 'Add action...', 14),
makeText(102, 430, 'Add action...', 14, undefined, { templateRole: 'meeting-add-action', action: 'add' }),
],
wireframe: [
makeText(50, 35, 'Page Wireframe', 30),
@@ -248,7 +252,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(265, 380, 190, 110),
makeHandDrawnRect(480, 380, 190, 110),
makeAddButton(480, 500, '+', 'wireframe-add-section'),
makeText(512, 500, 'Add section', 14),
makeText(512, 500, 'Add section', 14, undefined, { templateRole: 'wireframe-add-section', action: 'add' }),
],
mindmap: [
makeHandDrawnRect(240, 200, 200, 70),
@@ -285,7 +289,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(520, 196, 'Idea 3', 18),
makeArrow(460, 110, 580, 180),
makeAddButton(50, 240, '+', 'brainstorm-add'),
makeText(82, 240, 'Add idea...', 16),
makeText(82, 240, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
// Notes area
makeHandDrawnRect(50, 280, 610, 100),
makeText(70, 300, 'Notes & connections:', 18),
@@ -316,7 +320,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(80, 156, 'Branch 6', 18),
makeArrow(260, 220, 200, 165),
makeAddButton(50, 400, '+', 'brainstorm-add'),
makeText(82, 400, 'Add branch...', 16),
makeText(82, 400, 'Add branch...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-matrix': [
makeText(50, 30, 'Brainstorm — Matrix', 30),
@@ -338,7 +342,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeText(400, 350, '- Idea 1', 16),
makeText(400, 380, '- Idea 2', 16),
makeAddButton(50, 450, '+', 'brainstorm-add'),
makeText(82, 450, 'Add idea...', 16),
makeText(82, 450, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-freeform': [
makeText(50, 30, 'Brainstorm — Freeform', 30),
@@ -355,7 +359,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(340, 250, 160, 80),
makeText(360, 280, '✨ Idea 5', 18),
makeAddButton(50, 360, '+', 'brainstorm-add'),
makeText(82, 360, 'Add note...', 16),
makeText(82, 360, 'Add note...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-fishbone': [
makeText(50, 30, 'Brainstorm — Fishbone', 30),
@@ -382,7 +386,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(360, 340, 160, 50),
makeText(380, 358, 'Product', 16),
makeAddButton(50, 420, '+', 'brainstorm-add'),
makeText(82, 420, 'Add cause...', 16),
makeText(82, 420, 'Add cause...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-venn': [
makeText(50, 30, 'Brainstorm — Venn', 30),
@@ -399,7 +403,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
// Center overlap note
makeText(245, 190, 'Overlap', 12),
makeAddButton(50, 400, '+', 'brainstorm-add'),
makeText(82, 400, 'Add set...', 16),
makeText(82, 400, 'Add set...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-tree': [
makeText(50, 30, 'Brainstorm — Tree', 30),
@@ -427,7 +431,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(440, 290, 140, 40),
makeText(465, 305, 'Leaf 3a', 14),
makeAddButton(50, 360, '+', 'brainstorm-add'),
makeText(82, 360, 'Add branch...', 16),
makeText(82, 360, 'Add branch...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
'brainstorm-converge': [
makeText(50, 30, 'Brainstorm — Converge', 30),
@@ -450,7 +454,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(240, 350, 220, 50),
makeText(265, 370, 'Action Plan', 16),
makeAddButton(50, 430, '+', 'brainstorm-add'),
makeText(82, 430, 'Add idea...', 16),
makeText(82, 430, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
],
retrospective: [
makeText(50, 30, 'Retrospective', 30),
@@ -529,7 +533,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
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),
makeText(82, 350, 'Add row...', 14, undefined, { templateRole: 'storymap-add-row', action: 'add' }),
],
timeline: [
makeText(50, 30, 'Project Timeline', 30),
@@ -553,7 +557,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(500, 170, 130, 50),
makeText(515, 185, 'Deploy', 14),
makeAddButton(80, 240, '+', 'timeline-add'),
makeText(112, 240, 'Add phase...', 14),
makeText(112, 240, 'Add phase...', 14, undefined, { templateRole: 'timeline-add', action: 'add' }),
],
architecture: [
makeText(50, 30, 'System Architecture', 30),
@@ -580,7 +584,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(50, 200, 160, 70),
makeText(90, 220, 'CDN', 18),
makeAddButton(300, 290, '+', 'architecture-add'),
makeText(332, 290, 'Add component...', 14),
makeText(332, 290, 'Add component...', 14, undefined, { templateRole: 'architecture-add', action: 'add' }),
],
'er-diagram': [
makeText(50, 30, 'ER Diagram', 30),
@@ -613,7 +617,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(50, 330, 600, 50),
makeText(70, 350, 'DELETE /users/:id → Delete user', 16),
makeAddButton(50, 400, '+', 'api-add'),
makeText(82, 400, 'Add endpoint...', 14),
makeText(82, 400, 'Add endpoint...', 14, undefined, { templateRole: 'api-add', action: 'add' }),
],
'sitemap': [
makeText(50, 30, 'Site Map', 30),
@@ -635,7 +639,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeArrow(350, 140, 460, 180),
makeArrow(350, 140, 630, 180),
makeAddButton(50, 260, '+', 'sitemap-add'),
makeText(82, 260, 'Add page...', 14),
makeText(82, 260, 'Add page...', 14, undefined, { templateRole: 'sitemap-add', action: 'add' }),
],
'user-persona': [
makeText(50, 30, 'User Persona', 30),
@@ -658,7 +662,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
makeHandDrawnRect(50, 330, 620, 70),
makeText(70, 352, 'Behaviors: Uses Figma, Slack, Notion. Prefers visual tools.', 14),
makeAddButton(50, 420, '+', 'persona-add'),
makeText(82, 420, 'Add trait...', 14),
makeText(82, 420, 'Add trait...', 14, undefined, { templateRole: 'persona-add', action: 'add' }),
],
};
+103 -7
View File
@@ -38,6 +38,13 @@
gap: var(--space-2);
}
.toolbarDivider {
width: 1px;
height: 24px;
background: var(--default-border-color);
margin: 0 var(--space-2);
}
.title {
font-weight: 500;
color: var(--color-gray-85);
@@ -92,6 +99,29 @@
}
}
.canvasTools {
position: absolute;
top: 8px;
left: 8px;
z-index: 10;
display: flex;
gap: 4px;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
padding: 4px;
box-shadow: var(--shadow-island);
button {
padding: 4px;
min-width: 32px;
min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
}
.loadingCanvas {
position: absolute;
inset: 0;
@@ -402,11 +432,18 @@
.presentationOverlay {
position: fixed;
top: 12px;
right: 12px;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
pointer-events: auto;
pointer-events: none;
animation: presentationFadeIn 0.3s var(--ease-out);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: var(--space-4);
}
@keyframes presentationFadeIn {
@@ -418,11 +455,13 @@
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--island-bg-color);
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-4);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-xl);
padding: var(--space-3) var(--space-5);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
margin-bottom: var(--space-4);
pointer-events: auto;
}
.presentationLabel {
@@ -443,6 +482,7 @@
box-shadow: var(--shadow-island);
max-width: 400px;
overflow-x: auto;
pointer-events: auto;
}
.presentationSlideThumb {
@@ -630,3 +670,59 @@
z-index: 80;
}
}
// Excalidraw context menu styling
:global(.context-menu),
:global(.excalidraw-context-menu) {
background: var(--island-bg-color) !important;
border: 1px solid var(--default-border-color) !important;
border-radius: var(--border-radius-xl) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
padding: var(--space-1) !important;
.context-menu-item,
.menu-item {
border-radius: var(--border-radius-lg) !important;
padding: var(--space-2) var(--space-3) !important;
margin: 2px 0 !important;
font-size: var(--text-sm) !important;
&:hover {
background: var(--color-surface-low) !important;
}
&:first-child {
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0 !important;
}
&:last-child {
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg) !important;
}
}
.context-menu-separator,
.menu-item-separator {
background: var(--default-border-color) !important;
margin: var(--space-1) var(--space-2) !important;
}
}
// Excalidraw dropdown menus
:global(.dropdown-menu),
:global(.excalidraw-dropdown) {
background: var(--island-bg-color) !important;
border: 1px solid var(--default-border-color) !important;
border-radius: var(--border-radius-xl) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important;
padding: var(--space-1) !important;
.dropdown-menu-item,
.menu-item {
border-radius: var(--border-radius-lg) !important;
padding: var(--space-2) var(--space-3) !important;
&:hover {
background: var(--color-surface-low) !important;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -132,6 +132,10 @@
list-style: none;
padding: 0;
margin: 0;
li {
position: relative;
}
}
.folderItem {
@@ -148,6 +152,7 @@
width: 100%;
text-align: left;
font-size: var(--text-sm);
position: relative;
&:hover {
background: var(--color-surface-low);
@@ -162,12 +167,107 @@
border-color: var(--color-primary);
}
&.dragging {
opacity: 0.5;
}
svg {
color: var(--color-primary);
flex-shrink: 0;
}
}
.dragHandleWrapper {
cursor: grab;
display: flex;
align-items: center;
padding: 2px;
border-radius: var(--border-radius-sm);
&:hover {
background: var(--color-surface-low);
}
&:active {
cursor: grabbing;
}
}
.dragHandle {
color: var(--color-muted);
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out);
flex-shrink: 0;
.folderItem:hover & {
opacity: 1;
}
}
.dragOver {
background: var(--color-surface-primary-container);
border: 2px dashed var(--color-primary);
border-radius: var(--border-radius-lg);
}
.folderMenuBtn {
margin-left: auto;
background: none;
border: none;
padding: var(--space-1);
cursor: pointer;
color: var(--color-muted);
border-radius: var(--border-radius-md);
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.folderMenu {
position: absolute;
right: var(--space-2);
top: 100%;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
min-width: 140px;
z-index: 20;
padding: var(--space-1);
}
.folderMenuItem {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
background: none;
border: none;
padding: var(--space-2) var(--space-2);
cursor: pointer;
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
font-size: var(--text-sm);
text-align: left;
&:hover {
background: var(--color-surface-low);
}
}
.folderMenuDanger {
color: var(--color-danger-text);
&:hover {
background: rgba(224, 49, 49, 0.08);
}
}
.grid {
flex: 1;
display: grid;
@@ -345,17 +445,88 @@
margin: var(--space-1) 0;
}
.dropdownSubmenu {
.batchBar {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
background: var(--color-surface-primary-container);
border: 1px solid var(--color-primary);
border-radius: var(--border-radius-lg);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.dropdownSubheader {
.batchCount {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-primary-darkest);
}
.batchActions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.batchBtn {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
color: var(--color-on-surface);
cursor: pointer;
&:hover {
background: var(--color-surface-low);
}
}
.batchDanger {
color: var(--color-danger-text);
&:hover {
background: rgba(224, 49, 49, 0.08);
}
}
.selectBox {
position: absolute;
top: var(--space-2);
left: var(--space-2);
z-index: 5;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-1);
cursor: pointer;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out);
.drawingCard:hover &,
&:focus {
opacity: 1;
}
}
.drawingSelected {
border: 2px solid var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
.selectBox {
opacity: 1;
color: var(--color-primary);
border-color: var(--color-primary);
}
}
.newProjectForm {
@@ -550,3 +721,58 @@
&:hover { background: var(--color-primary-darker); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
.moveHint {
font-size: var(--text-sm);
color: var(--color-muted);
margin: 0 0 var(--space-3);
}
.moveList {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 320px;
overflow-y: auto;
}
.moveItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: none;
border: 1px solid transparent;
border-radius: var(--border-radius-lg);
cursor: pointer;
text-align: left;
color: var(--color-on-surface);
font-size: var(--text-sm);
transition: all var(--duration-fast) var(--ease-out);
svg {
color: var(--color-primary);
flex-shrink: 0;
}
&:hover {
background: var(--color-surface-low);
border-color: var(--default-border-color);
}
&.moveItemActive {
background: var(--color-surface-primary-container);
border-color: var(--color-primary);
color: var(--color-primary-darkest);
font-weight: 500;
}
}
.moveCurrent {
margin-left: auto;
font-size: var(--text-xs);
color: var(--color-muted);
background: var(--color-surface-low);
padding: var(--space-1) var(--space-2);
border-radius: var(--border-radius-md);
}
+504 -98
View File
@@ -1,11 +1,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle } from 'lucide-react';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle, Pencil, Trash2, GripVertical, Square, SquareCheck, Move } from 'lucide-react';
import { Card, Button, Modal } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Drawing } from '@/types';
import type { Drawing, Folder as FolderType } from '@/types';
import styles from './FileBrowser.module.scss';
export const FileBrowser: React.FC = () => {
@@ -35,7 +35,22 @@ export const FileBrowser: React.FC = () => {
const [renameValue, setRenameValue] = useState('');
// Move state
const [movingId, setMovingId] = useState<string | null>(null);
const [moveModalDrawing, setMoveModalDrawing] = useState<Drawing | null>(null);
// Folder menu state
const [folderMenuId, setFolderMenuId] = useState<string | null>(null);
const folderMenuRef = useRef<HTMLDivElement | null>(null);
// Drag-drop state for folders
const [draggedFolderId, setDraggedFolderId] = useState<string | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
// Drag-drop state for drawings
const [draggedDrawingId, setDraggedDrawingId] = useState<string | null>(null);
// Multi-select state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchMoveOpen, setBatchMoveOpen] = useState(false);
// New drawing name modal state
const [showNameModal, setShowNameModal] = useState(false);
@@ -173,6 +188,11 @@ export const FileBrowser: React.FC = () => {
try {
await api.drawings.delete(drawing.id);
removeDrawing(drawing.id);
setSelectedIds(prev => {
const next = new Set(prev);
next.delete(drawing.id);
return next;
});
setActiveMenu(null);
setModal(m => ({ ...m, open: false }));
} catch (err) {
@@ -183,6 +203,55 @@ export const FileBrowser: React.FC = () => {
});
};
const handleDeleteSelected = () => {
const count = selectedIds.size;
if (count === 0) return;
const selectedDrawings = visibleDrawings.filter(d => selectedIds.has(d.id));
showModal('confirm', 'Delete Drawings', `Delete ${count} drawing(s)? This cannot be undone.`, async () => {
try {
await Promise.all(selectedDrawings.map(d => api.drawings.delete(d.id)));
selectedDrawings.forEach(d => removeDrawing(d.id));
setSelectedIds(new Set());
setModal(m => ({ ...m, open: false }));
} catch (err) {
console.error('Failed to delete drawings:', err);
setModal(m => ({ ...m, open: false }));
setTimeout(() => showModal('alert', 'Error', 'Failed to delete drawings.'), 100);
}
});
};
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
setSelectedIds(new Set(visibleDrawings.map(d => d.id)));
};
const clearSelection = () => {
setSelectedIds(new Set());
};
const handleBatchMove = async (folderId: string | null) => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
try {
await Promise.all(ids.map(id => api.drawings.update(id, { folder_id: folderId })));
setDrawings(drawings.map(d => selectedIds.has(d.id) ? { ...d, folder_id: folderId } : d));
setSelectedIds(new Set());
setBatchMoveOpen(false);
} catch (err) {
console.error('Failed to move drawings:', err);
showModal('alert', 'Error', 'Failed to move drawings. Please try again.');
}
};
const handleDuplicateDrawing = async (drawing: Drawing) => {
try {
const newDrawing = await api.drawings.create({
@@ -219,20 +288,165 @@ export const FileBrowser: React.FC = () => {
try {
await api.drawings.update(drawing.id, { folder_id: folderId });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
setMovingId(null);
setActiveMenu(null);
setMoveModalDrawing(null);
} catch (err) {
console.error('Failed to move drawing:', err);
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
}
};
const handleRenameFolder = async (folder: FolderType) => {
const name = renameValue.trim();
if (!name || name === folder.name) {
setRenamingId(null);
return;
}
try {
const updated = await api.folders.update(folder.id, { name });
setFolders(folders.map(f => f.id === folder.id ? updated : f));
setRenamingId(null);
setFolderMenuId(null);
} catch (err) {
console.error('Failed to rename folder:', err);
showModal('alert', 'Error', 'Failed to rename folder. Please try again.');
}
};
const handleDeleteFolder = (folder: FolderType) => {
const drawingsInFolder = drawings.filter(d => d.folder_id === folder.id);
const message = drawingsInFolder.length > 0
? `Delete "${folder.name}" and move its ${drawingsInFolder.length} drawing(s) to root? This cannot be undone.`
: `Delete "${folder.name}"? This cannot be undone.`;
showModal('confirm', 'Delete Folder', message, async () => {
try {
// Move drawings to root first
for (const drawing of drawingsInFolder) {
await api.drawings.update(drawing.id, { folder_id: null });
}
setDrawings(drawings.map(d =>
d.folder_id === folder.id ? { ...d, folder_id: null } : d
));
await api.folders.delete(folder.id);
setFolders(folders.filter(f => f.id !== folder.id));
setFolderMenuId(null);
setModal(m => ({ ...m, open: false }));
if (activeFolderId === folder.id) {
navigate('/files');
}
} catch (err) {
console.error('Failed to delete folder:', err);
setModal(m => ({ ...m, open: false }));
setTimeout(() => showModal('alert', 'Error', 'Failed to delete folder.'), 100);
}
});
};
// Drag and drop handlers for folders
const handleDragStart = (e: React.DragEvent, folderId: string) => {
const target = e.target as HTMLElement;
const isHandle = target.closest(`.${styles.dragHandleWrapper}`) !== null;
if (!isHandle) {
e.preventDefault();
return;
}
setDraggedFolderId(folderId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragLeave = (e: React.DragEvent) => {
const related = e.relatedTarget as HTMLElement;
const current = e.currentTarget as HTMLElement;
if (related && current.contains(related)) {
return;
}
setDragOverFolderId(null);
};
const handleDrop = async (e: React.DragEvent, targetFolderId: string | null) => {
e.preventDefault();
e.stopPropagation();
// Handle drawing drop onto folder
if (draggedDrawingId) {
const drawing = drawings.find(d => d.id === draggedDrawingId);
if (drawing && drawing.folder_id !== targetFolderId) {
try {
await api.drawings.update(drawing.id, { folder_id: targetFolderId });
setDrawings(drawings.map(d => d.id === draggedDrawingId ? { ...d, folder_id: targetFolderId } : d));
} catch (err) {
console.error('Failed to move drawing to folder:', err);
}
}
setDraggedDrawingId(null);
setDragOverFolderId(null);
return;
}
// Handle folder reorder drop
if (!draggedFolderId || draggedFolderId === targetFolderId) {
setDraggedFolderId(null);
setDragOverFolderId(null);
return;
}
// Reorder: move dragged folder to target position
const currentFolders = [...folders];
const draggedIndex = currentFolders.findIndex(f => f.id === draggedFolderId);
const targetIndex = currentFolders.findIndex(f => f.id === targetFolderId);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedFolderId(null);
setDragOverFolderId(null);
return;
}
const [draggedFolder] = currentFolders.splice(draggedIndex, 1);
currentFolders.splice(targetIndex, 0, draggedFolder);
const newOrder = currentFolders.map(f => f.id);
try {
const reordered = await api.folders.reorder(newOrder);
setFolders(reordered);
} catch (err) {
console.error('Failed to reorder folders:', err);
}
setDraggedFolderId(null);
setDragOverFolderId(null);
};
const handleDragEnd = () => {
setDraggedFolderId(null);
setDragOverFolderId(null);
setDraggedDrawingId(null);
};
// Drawing drag handlers
const handleDrawingDragStart = (e: React.DragEvent, drawingId: string) => {
setDraggedDrawingId(drawingId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDrawingDragEnd = () => {
setDraggedDrawingId(null);
setDragOverFolderId(null);
};
// Close menu on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
const target = e.target as HTMLElement;
if (menuRef.current && !menuRef.current.contains(target)) {
setActiveMenu(null);
}
if (folderMenuRef.current && !folderMenuRef.current.contains(target)) {
const isMenuBtn = target.closest(`.${styles.folderMenuBtn}`) !== null;
if (!isMenuBtn) {
setFolderMenuId(null);
}
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
@@ -347,6 +561,28 @@ export const FileBrowser: React.FC = () => {
</div>
</div>
{selectedIds.size > 0 && (
<div className={styles.batchBar}>
<span className={styles.batchCount}>{selectedIds.size} selected</span>
<div className={styles.batchActions}>
<button className={styles.batchBtn} onClick={selectAll}>
Select All
</button>
<button className={styles.batchBtn} onClick={() => setBatchMoveOpen(true)}>
<Move size={14} />
Move to...
</button>
<button className={`${styles.batchBtn} ${styles.batchDanger}`} onClick={handleDeleteSelected}>
<Trash2 size={14} />
Delete
</button>
<button className={styles.batchBtn} onClick={clearSelection}>
Clear
</button>
</div>
</div>
)}
<div className={styles.content}>
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
{showNewProject && (
@@ -374,7 +610,20 @@ export const FileBrowser: React.FC = () => {
</div>
)}
<ul className={styles.folderTree} role="tree">
<li>
<li
onDragOver={(e) => {
e.preventDefault();
if (draggedDrawingId) setDragOverFolderId('__root__');
}}
onDragLeave={(e) => {
const related = e.relatedTarget as HTMLElement;
const current = e.currentTarget as HTMLElement;
if (related && current.contains(related)) return;
setDragOverFolderId(null);
}}
onDrop={(e) => handleDrop(e, null)}
className={dragOverFolderId === '__root__' ? styles.dragOver : ''}
>
<button
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(null)}
@@ -386,16 +635,93 @@ export const FileBrowser: React.FC = () => {
</button>
</li>
{folders.map((folder) => (
<li key={folder.id}>
<button
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(folder.id)}
aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>{folder.name}</span>
</button>
<li
key={folder.id}
draggable
onDragStart={(e) => handleDragStart(e, folder.id)}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if ((draggedFolderId && draggedFolderId !== folder.id) || (draggedDrawingId && draggedDrawingId !== folder.id)) {
setDragOverFolderId(folder.id);
}
}}
onDragLeave={(e) => handleDragLeave(e)}
onDrop={(e) => handleDrop(e, folder.id)}
onDragEnd={handleDragEnd}
className={dragOverFolderId === folder.id ? styles.dragOver : ''}
>
{renamingId === folder.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameFolder(folder);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameFolder(folder)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<button
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''} ${draggedFolderId === folder.id ? styles.dragging : ''}`}
onClick={() => handleFolderClick(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
setFolderMenuId(folder.id);
}}
aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem"
>
<span
className={styles.dragHandleWrapper}
onClick={(e) => e.stopPropagation()}
aria-hidden="true"
>
<GripVertical size={14} className={styles.dragHandle} />
</span>
<Folder size={18} aria-hidden="true" />
<span>{folder.name}</span>
<button
className={styles.folderMenuBtn}
onClick={(e) => {
e.stopPropagation();
setFolderMenuId(folderMenuId === folder.id ? null : folder.id);
}}
aria-label="Folder options"
>
<MoreVertical size={14} />
</button>
</button>
)}
{folderMenuId === folder.id && (
<div className={styles.folderMenu} ref={folderMenuRef}>
<button
className={styles.folderMenuItem}
onClick={(e) => {
e.stopPropagation();
setRenamingId(folder.id);
setRenameValue(folder.name);
setFolderMenuId(null);
}}
>
<Pencil size={14} />
Rename
</button>
<button
className={`${styles.folderMenuItem} ${styles.folderMenuDanger}`}
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(folder);
}}
>
<Trash2 size={14} />
Delete
</button>
</div>
)}
</li>
))}
</ul>
@@ -410,93 +736,99 @@ export const FileBrowser: React.FC = () => {
</p>
</div>
) : (
visibleDrawings.map((drawing) => (
<Card
key={drawing.id}
className={styles.drawingCard}
hover
role="listitem"
tabIndex={0}
onClick={() => handleDrawingClick(drawing)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDrawingClick(drawing);
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.thumbnail}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.info}>
{renamingId === drawing.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameDrawing(drawing);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameDrawing(drawing)}
/>
) : (
<>
<h4 className={styles.title}>{drawing.title}</h4>
<p className={styles.meta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</>
)}
</div>
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
visibleDrawings.map((drawing) => {
const isSelected = selectedIds.has(drawing.id);
return (
<Card
key={drawing.id}
className={`${styles.drawingCard} ${isSelected ? styles.drawingSelected : ''}`}
hover
role="listitem"
tabIndex={0}
draggable
onDragStart={(e) => handleDrawingDragStart(e as unknown as React.DragEvent, drawing.id)}
onDragEnd={handleDrawingDragEnd}
onClick={() => handleDrawingClick(drawing)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDrawingClick(drawing);
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<button
className={styles.more}
className={styles.selectBox}
onClick={(e) => {
e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
setRenamingId(null);
toggleSelect(drawing.id);
}}
aria-label={`More options for ${drawing.title}`}
aria-expanded={activeMenu === drawing.id}
aria-label={isSelected ? 'Deselect' : 'Select'}
aria-pressed={isSelected}
>
<MoreVertical size={16} />
{isSelected ? <SquareCheck size={16} /> : <Square size={16} />}
</button>
{activeMenu === drawing.id && (
<div className={styles.dropdown}>
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
{movingId === drawing.id ? (
<div className={styles.dropdownSubmenu}>
<button className={styles.dropdownSubheader}>Move to:</button>
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
{folders.map(f => (
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
))}
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
</div>
) : (
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
)}
<div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div>
)}
</div>
</Card>
))
<div className={styles.thumbnail}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.info}>
{renamingId === drawing.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameDrawing(drawing);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameDrawing(drawing)}
/>
) : (
<>
<h4 className={styles.title}>{drawing.title}</h4>
<p className={styles.meta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</>
)}
</div>
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
<button
className={styles.more}
onClick={(e) => {
e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
setRenamingId(null);
}}
aria-label={`More options for ${drawing.title}`}
aria-expanded={activeMenu === drawing.id}
>
<MoreVertical size={16} />
</button>
{activeMenu === drawing.id && (
<div className={styles.dropdown}>
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
<button onClick={(e) => { e.stopPropagation(); setMoveModalDrawing(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Move to...</button>
<div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div>
)}
</div>
</Card>
);
})
)}
</main>
</div>
@@ -530,6 +862,80 @@ export const FileBrowser: React.FC = () => {
</div>
</div>
)}
{moveModalDrawing && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="move-drawing-title" onClick={(e) => { if (e.target === e.currentTarget) setMoveModalDrawing(null); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="move-drawing-title">Move "{moveModalDrawing.title}"</h3>
<button className={styles.modalClose} onClick={() => setMoveModalDrawing(null)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<p className={styles.moveHint}>Select a destination:</p>
<div className={styles.moveList}>
<button
className={`${styles.moveItem} ${moveModalDrawing.folder_id === null ? styles.moveItemActive : ''}`}
onClick={() => handleMoveDrawing(moveModalDrawing, null)}
>
<Folder size={18} />
<span>All Projects</span>
{moveModalDrawing.folder_id === null && <span className={styles.moveCurrent}>Current</span>}
</button>
{folders.map((f) => (
<button
key={f.id}
className={`${styles.moveItem} ${moveModalDrawing.folder_id === f.id ? styles.moveItemActive : ''}`}
onClick={() => handleMoveDrawing(moveModalDrawing, f.id)}
>
<Folder size={18} />
<span>{f.name}</span>
{moveModalDrawing.folder_id === f.id && <span className={styles.moveCurrent}>Current</span>}
</button>
))}
</div>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setMoveModalDrawing(null)}>Cancel</button>
</div>
</div>
</div>
)}
{batchMoveOpen && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="batch-move-title" onClick={(e) => { if (e.target === e.currentTarget) setBatchMoveOpen(false); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="batch-move-title">Move {selectedIds.size} drawing(s)</h3>
<button className={styles.modalClose} onClick={() => setBatchMoveOpen(false)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<p className={styles.moveHint}>Select a destination:</p>
<div className={styles.moveList}>
<button
className={styles.moveItem}
onClick={() => handleBatchMove(null)}
>
<Folder size={18} />
<span>All Projects</span>
</button>
{folders.map((f) => (
<button
key={f.id}
className={styles.moveItem}
onClick={() => handleBatchMove(f.id)}
>
<Folder size={18} />
<span>{f.name}</span>
</button>
))}
</div>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setBatchMoveOpen(false)}>Cancel</button>
</div>
</div>
</div>
)}
</div>
</>
);
+6
View File
@@ -61,6 +61,12 @@ export const api = {
list: (): Promise<Folder[]> => fetchApi('/folders'),
create: (data: object): Promise<Folder> =>
fetchApi('/folders', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: object): Promise<Folder> =>
fetchApi(`/folders/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string): Promise<void> =>
fetchApi(`/folders/${id}`, { method: 'DELETE' }),
reorder: (folderIds: string[]): Promise<Folder[]> =>
fetchApi('/folders/reorder', { method: 'POST', body: JSON.stringify({ folder_ids: folderIds }) }),
},
teams: {
list: (): Promise<Team[]> => fetchApi('/teams'),
+12
View File
@@ -27,5 +27,17 @@ export default defineConfig({
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
'excalidraw': ['@excalidraw/excalidraw'],
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-vendor': ['lucide-react', 'clsx'],
'i18n': ['i18next', 'react-i18next', 'i18next-browser-languagedetector'],
'state': ['zustand'],
},
},
},
chunkSizeWarningLimit: 1000,
},
})
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE workspace_folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
CREATE INDEX idx_workspace_folders_sort_order ON workspace_folders(team_id, sort_order);
-- +goose Down
DROP INDEX IF EXISTS idx_workspace_folders_sort_order;
ALTER TABLE workspace_folders DROP COLUMN IF EXISTS sort_order;
+48
View File
@@ -79,6 +79,9 @@ func (a *API) Routes() chi.Router {
r.Get("/stats", a.handleStats)
r.Get("/folders", a.handleListFolders)
r.Post("/folders", a.handleCreateFolder)
r.Patch("/folders/{folderID}", a.handleUpdateFolder)
r.Delete("/folders/{folderID}", a.handleDeleteFolder)
r.Post("/folders/reorder", a.handleReorderFolders)
r.Get("/projects", a.handleListProjects)
r.Post("/projects", a.handleCreateProject)
r.Get("/notifications", a.handleListNotifications)
@@ -625,6 +628,51 @@ func (a *API) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, folder)
}
func (a *API) handleUpdateFolder(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
folderID := chi.URLParam(r, "folderID")
var req UpdateFolderRequest
if !decodeJSON(w, r, &req, 128<<10) {
return
}
folder, err := a.store.UpdateFolder(r.Context(), user.ID, folderID, req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, folder)
}
func (a *API) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
folderID := chi.URLParam(r, "folderID")
if err := a.store.DeleteFolder(r.Context(), user.ID, folderID); err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *API) handleReorderFolders(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req struct {
FolderIDs []string `json:"folder_ids"`
}
if !decodeJSON(w, r, &req, 128<<10) {
return
}
if err := a.store.ReorderFolders(r.Context(), user.ID, req.FolderIDs); err != nil {
writeLookupError(w, err)
return
}
folders, err := a.store.ListFolders(r.Context(), user.ID, "")
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, folders)
}
func (a *API) handleListProjects(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
+143 -13
View File
@@ -40,12 +40,33 @@ type CreateDrawingRequest struct {
Snapshot json.RawMessage `json:"snapshot"`
}
// NullString distinguishes JSON null (Valid=true, Value=nil) from absent field (Valid=false).
type NullString struct {
Value *string
Valid bool
}
func (n *NullString) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
n.Value = nil
n.Valid = true
return nil
}
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
n.Value = &s
n.Valid = true
return nil
}
type UpdateDrawingRequest struct {
FolderID *string `json:"folder_id"`
ProjectID *string `json:"project_id"`
Title *string `json:"title"`
Description *string `json:"description"`
Visibility *string `json:"visibility"`
FolderID NullString `json:"folder_id"`
ProjectID NullString `json:"project_id"`
Title *string `json:"title"`
Description *string `json:"description"`
Visibility *string `json:"visibility"`
}
type CreateRevisionRequest struct {
@@ -61,6 +82,11 @@ type CreateFolderRequest struct {
Visibility string `json:"visibility"`
}
type UpdateFolderRequest struct {
Name *string `json:"name"`
Visibility *string `json:"visibility"`
}
type CreateProjectRequest struct {
TeamID string `json:"team_id"`
Name string `json:"name"`
@@ -616,11 +642,11 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
}
current.Visibility = *req.Visibility
}
if req.FolderID != nil {
current.FolderID = req.FolderID
if req.FolderID.Valid {
current.FolderID = req.FolderID.Value
}
if req.ProjectID != nil {
current.ProjectID = req.ProjectID
if req.ProjectID.Valid {
current.ProjectID = req.ProjectID.Value
}
now := time.Now().UTC()
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings
@@ -922,7 +948,7 @@ func (s *Store) ListFolders(ctx context.Context, userID, teamID string) ([]Folde
return nil, ErrForbidden
}
rows, err := s.db.QueryContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at
FROM workspace_folders WHERE team_id = ? ORDER BY path_cache ASC`, teamID)
FROM workspace_folders WHERE team_id = ? ORDER BY sort_order ASC, created_at ASC`, teamID)
if err != nil {
return nil, err
}
@@ -972,10 +998,12 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
CreatedAt: now,
UpdatedAt: now,
}
var maxOrder int
s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) + 1 FROM workspace_folders WHERE team_id = ?`, teamID).Scan(&maxOrder)
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_folders
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt,
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt, maxOrder,
)
if err != nil {
return nil, err
@@ -983,6 +1011,108 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
return folder, nil
}
func (s *Store) UpdateFolder(ctx context.Context, userID, folderID string, req UpdateFolderRequest) (*Folder, error) {
var teamID string
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return nil, ErrForbidden
}
var updates []string
var args []any
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" || len(name) > 120 {
return nil, fmt.Errorf("folder name must be between 1 and 120 characters")
}
updates = append(updates, "name = ?")
args = append(args, name)
updates = append(updates, "slug = ?")
args = append(args, slugify(name))
updates = append(updates, "path_cache = ?")
args = append(args, slugify(name))
}
if req.Visibility != nil {
updates = append(updates, "visibility = ?")
args = append(args, *req.Visibility)
}
if len(updates) == 0 {
return s.GetFolder(ctx, folderID)
}
updates = append(updates, "updated_at = ?")
args = append(args, time.Now().UTC())
args = append(args, folderID)
query := "UPDATE workspace_folders SET " + strings.Join(updates, ", ") + " WHERE id = ?"
_, err = s.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, err
}
return s.GetFolder(ctx, folderID)
}
func (s *Store) GetFolder(ctx context.Context, folderID string) (*Folder, error) {
var folder Folder
err := s.db.QueryRowContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at FROM workspace_folders WHERE id = ?`, folderID).Scan(
&folder.ID, &folder.TeamID, &folder.ProjectID, &folder.ParentFolderID, &folder.Name, &folder.Slug, &folder.PathCache, &folder.Visibility, &folder.CreatedBy, &folder.CreatedAt, &folder.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &folder, nil
}
func (s *Store) DeleteFolder(ctx context.Context, userID, folderID string) error {
var teamID string
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return ErrForbidden
}
_, err = s.db.ExecContext(ctx, `DELETE FROM workspace_folders WHERE id = ?`, folderID)
return err
}
func (s *Store) ReorderFolders(ctx context.Context, userID string, folderIDs []string) error {
if len(folderIDs) == 0 {
return nil
}
var teamID string
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderIDs[0]).Scan(&teamID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return ErrForbidden
}
for i, id := range folderIDs {
_, err := s.db.ExecContext(ctx, `UPDATE workspace_folders SET sort_order = ? WHERE id = ? AND team_id = ?`, i, id, teamID)
if err != nil {
return err
}
}
return nil
}
func (s *Store) ListProjects(ctx context.Context, userID, teamID string) ([]Project, error) {
if teamID == "" {
teamID, _ = s.defaultTeamID(ctx, userID)