mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +00:00
Compare commits
6 Commits
71dda9d45d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c8088404 | |||
| cd22ee1ee8 | |||
| 19e7ed6ea1 | |||
| 8336c76705 | |||
| 910546230d | |||
| 190be65e4f |
@@ -28,6 +28,15 @@ jobs:
|
|||||||
id: image
|
id: image
|
||||||
run: echo "repository=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
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
|
- name: Use GitHub token for GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
+1
-1
Submodule excalidraw updated: 278cd35772...f6d85bc80f
@@ -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) {
|
function makeText(x: number, y: number, text: string, fontSize = 20, groupId?: string, customData?: Record<string, unknown>) {
|
||||||
return {
|
const el: RawElement = {
|
||||||
id: `txt-${Math.random().toString(36).slice(2)}`,
|
id: `txt-${Math.random().toString(36).slice(2)}`,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
x, y, width: text.length * (fontSize * 0.55), height: fontSize * 1.4,
|
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,
|
originalText: text,
|
||||||
lineHeight: 1.25,
|
lineHeight: 1.25,
|
||||||
};
|
};
|
||||||
|
if (customData) {
|
||||||
|
el.customData = customData;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCheckbox(x: number, y: number, checked = false) {
|
function makeCheckbox(x: number, y: number, checked = false) {
|
||||||
@@ -158,7 +162,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeCheckbox(60, 210, false),
|
makeCheckbox(60, 210, false),
|
||||||
makeText(90, 210, 'Third task'),
|
makeText(90, 210, 'Third task'),
|
||||||
makeAddButton(60, 250, '+', 'todo-add'),
|
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),
|
makeHandDrawnRect(50, 290, 500, 2),
|
||||||
makeText(60, 310, 'Notes:', 18),
|
makeText(60, 310, 'Notes:', 18),
|
||||||
],
|
],
|
||||||
@@ -172,7 +176,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeCheckbox(60, 210, false),
|
makeCheckbox(60, 210, false),
|
||||||
makeText(90, 210, 'Another task', 18),
|
makeText(90, 210, 'Another task', 18),
|
||||||
makeAddButton(60, 250, '+', 'checklist-add'),
|
makeAddButton(60, 250, '+', 'checklist-add'),
|
||||||
makeText(92, 250, 'Add item...', 16),
|
makeText(92, 250, 'Add item...', 16, undefined, { templateRole: 'checklist-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
list: [
|
list: [
|
||||||
makeHandDrawnRect(50, 50, 500, 50),
|
makeHandDrawnRect(50, 50, 500, 50),
|
||||||
@@ -182,7 +186,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeText(60, 210, '• Third bullet point'),
|
makeText(60, 210, '• Third bullet point'),
|
||||||
makeText(60, 250, '• Fourth item with details'),
|
makeText(60, 250, '• Fourth item with details'),
|
||||||
makeAddButton(60, 290, '+', 'list-add'),
|
makeAddButton(60, 290, '+', 'list-add'),
|
||||||
makeText(92, 290, 'Add bullet...', 16),
|
makeText(92, 290, 'Add bullet...', 16, undefined, { templateRole: 'list-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
flow: [
|
flow: [
|
||||||
makeHandDrawnRect(200, 50, 200, 60),
|
makeHandDrawnRect(200, 50, 200, 60),
|
||||||
@@ -197,7 +201,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(200, 350, 200, 60),
|
makeHandDrawnRect(200, 350, 200, 60),
|
||||||
makeText(230, 370, 'End', 20),
|
makeText(230, 370, 'End', 20),
|
||||||
makeAddButton(420, 180, '+', 'flow-add'),
|
makeAddButton(420, 180, '+', 'flow-add'),
|
||||||
makeText(452, 180, 'Add step', 14),
|
makeText(452, 180, 'Add step', 14, undefined, { templateRole: 'flow-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
kanban: [
|
kanban: [
|
||||||
makeText(50, 40, 'Kanban Board', 30),
|
makeText(50, 40, 'Kanban Board', 30),
|
||||||
@@ -234,7 +238,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeCheckbox(70, 390, false),
|
makeCheckbox(70, 390, false),
|
||||||
makeText(105, 390, 'Owner and next step', 18),
|
makeText(105, 390, 'Owner and next step', 18),
|
||||||
makeAddButton(70, 430, '+', 'meeting-add-action'),
|
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: [
|
wireframe: [
|
||||||
makeText(50, 35, 'Page Wireframe', 30),
|
makeText(50, 35, 'Page Wireframe', 30),
|
||||||
@@ -248,7 +252,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(265, 380, 190, 110),
|
makeHandDrawnRect(265, 380, 190, 110),
|
||||||
makeHandDrawnRect(480, 380, 190, 110),
|
makeHandDrawnRect(480, 380, 190, 110),
|
||||||
makeAddButton(480, 500, '+', 'wireframe-add-section'),
|
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: [
|
mindmap: [
|
||||||
makeHandDrawnRect(240, 200, 200, 70),
|
makeHandDrawnRect(240, 200, 200, 70),
|
||||||
@@ -285,7 +289,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeText(520, 196, 'Idea 3', 18),
|
makeText(520, 196, 'Idea 3', 18),
|
||||||
makeArrow(460, 110, 580, 180),
|
makeArrow(460, 110, 580, 180),
|
||||||
makeAddButton(50, 240, '+', 'brainstorm-add'),
|
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
|
// Notes area
|
||||||
makeHandDrawnRect(50, 280, 610, 100),
|
makeHandDrawnRect(50, 280, 610, 100),
|
||||||
makeText(70, 300, 'Notes & connections:', 18),
|
makeText(70, 300, 'Notes & connections:', 18),
|
||||||
@@ -316,7 +320,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeText(80, 156, 'Branch 6', 18),
|
makeText(80, 156, 'Branch 6', 18),
|
||||||
makeArrow(260, 220, 200, 165),
|
makeArrow(260, 220, 200, 165),
|
||||||
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-matrix': [
|
||||||
makeText(50, 30, 'Brainstorm — Matrix', 30),
|
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, 350, '- Idea 1', 16),
|
||||||
makeText(400, 380, '- Idea 2', 16),
|
makeText(400, 380, '- Idea 2', 16),
|
||||||
makeAddButton(50, 450, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-freeform': [
|
||||||
makeText(50, 30, 'Brainstorm — Freeform', 30),
|
makeText(50, 30, 'Brainstorm — Freeform', 30),
|
||||||
@@ -355,7 +359,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(340, 250, 160, 80),
|
makeHandDrawnRect(340, 250, 160, 80),
|
||||||
makeText(360, 280, '✨ Idea 5', 18),
|
makeText(360, 280, '✨ Idea 5', 18),
|
||||||
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-fishbone': [
|
||||||
makeText(50, 30, 'Brainstorm — Fishbone', 30),
|
makeText(50, 30, 'Brainstorm — Fishbone', 30),
|
||||||
@@ -382,7 +386,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(360, 340, 160, 50),
|
makeHandDrawnRect(360, 340, 160, 50),
|
||||||
makeText(380, 358, 'Product', 16),
|
makeText(380, 358, 'Product', 16),
|
||||||
makeAddButton(50, 420, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-venn': [
|
||||||
makeText(50, 30, 'Brainstorm — Venn', 30),
|
makeText(50, 30, 'Brainstorm — Venn', 30),
|
||||||
@@ -399,7 +403,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
// Center overlap note
|
// Center overlap note
|
||||||
makeText(245, 190, 'Overlap', 12),
|
makeText(245, 190, 'Overlap', 12),
|
||||||
makeAddButton(50, 400, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-tree': [
|
||||||
makeText(50, 30, 'Brainstorm — Tree', 30),
|
makeText(50, 30, 'Brainstorm — Tree', 30),
|
||||||
@@ -427,7 +431,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(440, 290, 140, 40),
|
makeHandDrawnRect(440, 290, 140, 40),
|
||||||
makeText(465, 305, 'Leaf 3a', 14),
|
makeText(465, 305, 'Leaf 3a', 14),
|
||||||
makeAddButton(50, 360, '+', 'brainstorm-add'),
|
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': [
|
'brainstorm-converge': [
|
||||||
makeText(50, 30, 'Brainstorm — Converge', 30),
|
makeText(50, 30, 'Brainstorm — Converge', 30),
|
||||||
@@ -450,7 +454,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(240, 350, 220, 50),
|
makeHandDrawnRect(240, 350, 220, 50),
|
||||||
makeText(265, 370, 'Action Plan', 16),
|
makeText(265, 370, 'Action Plan', 16),
|
||||||
makeAddButton(50, 430, '+', 'brainstorm-add'),
|
makeAddButton(50, 430, '+', 'brainstorm-add'),
|
||||||
makeText(82, 430, 'Add idea...', 16),
|
makeText(82, 430, 'Add idea...', 16, undefined, { templateRole: 'brainstorm-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
retrospective: [
|
retrospective: [
|
||||||
makeText(50, 30, 'Retrospective', 30),
|
makeText(50, 30, 'Retrospective', 30),
|
||||||
@@ -529,7 +533,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(50, 300, 600, 2),
|
makeHandDrawnRect(50, 300, 600, 2),
|
||||||
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
|
makeText(50, 320, 'Priority: High → Low (top to bottom)', 14),
|
||||||
makeAddButton(50, 350, '+', 'storymap-add-row'),
|
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: [
|
timeline: [
|
||||||
makeText(50, 30, 'Project Timeline', 30),
|
makeText(50, 30, 'Project Timeline', 30),
|
||||||
@@ -553,7 +557,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(500, 170, 130, 50),
|
makeHandDrawnRect(500, 170, 130, 50),
|
||||||
makeText(515, 185, 'Deploy', 14),
|
makeText(515, 185, 'Deploy', 14),
|
||||||
makeAddButton(80, 240, '+', 'timeline-add'),
|
makeAddButton(80, 240, '+', 'timeline-add'),
|
||||||
makeText(112, 240, 'Add phase...', 14),
|
makeText(112, 240, 'Add phase...', 14, undefined, { templateRole: 'timeline-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
architecture: [
|
architecture: [
|
||||||
makeText(50, 30, 'System Architecture', 30),
|
makeText(50, 30, 'System Architecture', 30),
|
||||||
@@ -580,7 +584,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(50, 200, 160, 70),
|
makeHandDrawnRect(50, 200, 160, 70),
|
||||||
makeText(90, 220, 'CDN', 18),
|
makeText(90, 220, 'CDN', 18),
|
||||||
makeAddButton(300, 290, '+', 'architecture-add'),
|
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': [
|
'er-diagram': [
|
||||||
makeText(50, 30, 'ER Diagram', 30),
|
makeText(50, 30, 'ER Diagram', 30),
|
||||||
@@ -613,7 +617,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(50, 330, 600, 50),
|
makeHandDrawnRect(50, 330, 600, 50),
|
||||||
makeText(70, 350, 'DELETE /users/:id → Delete user', 16),
|
makeText(70, 350, 'DELETE /users/:id → Delete user', 16),
|
||||||
makeAddButton(50, 400, '+', 'api-add'),
|
makeAddButton(50, 400, '+', 'api-add'),
|
||||||
makeText(82, 400, 'Add endpoint...', 14),
|
makeText(82, 400, 'Add endpoint...', 14, undefined, { templateRole: 'api-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
'sitemap': [
|
'sitemap': [
|
||||||
makeText(50, 30, 'Site Map', 30),
|
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, 460, 180),
|
||||||
makeArrow(350, 140, 630, 180),
|
makeArrow(350, 140, 630, 180),
|
||||||
makeAddButton(50, 260, '+', 'sitemap-add'),
|
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': [
|
'user-persona': [
|
||||||
makeText(50, 30, 'User Persona', 30),
|
makeText(50, 30, 'User Persona', 30),
|
||||||
@@ -658,7 +662,7 @@ export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
|||||||
makeHandDrawnRect(50, 330, 620, 70),
|
makeHandDrawnRect(50, 330, 620, 70),
|
||||||
makeText(70, 352, 'Behaviors: Uses Figma, Slack, Notion. Prefers visual tools.', 14),
|
makeText(70, 352, 'Behaviors: Uses Figma, Slack, Notion. Prefers visual tools.', 14),
|
||||||
makeAddButton(50, 420, '+', 'persona-add'),
|
makeAddButton(50, 420, '+', 'persona-add'),
|
||||||
makeText(82, 420, 'Add trait...', 14),
|
makeText(82, 420, 'Add trait...', 14, undefined, { templateRole: 'persona-add', action: 'add' }),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,13 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbarDivider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--default-border-color);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-gray-85);
|
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 {
|
.loadingCanvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -402,11 +432,18 @@
|
|||||||
|
|
||||||
.presentationOverlay {
|
.presentationOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: 0;
|
||||||
right: 12px;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
pointer-events: auto;
|
pointer-events: none;
|
||||||
animation: presentationFadeIn 0.3s var(--ease-out);
|
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 {
|
@keyframes presentationFadeIn {
|
||||||
@@ -418,11 +455,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
background: var(--island-bg-color);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
border: 1px solid var(--default-border-color);
|
border: 1px solid var(--default-border-color);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-xl);
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-3) var(--space-5);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.presentationLabel {
|
.presentationLabel {
|
||||||
@@ -443,6 +482,7 @@
|
|||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.presentationSlideThumb {
|
.presentationSlideThumb {
|
||||||
@@ -630,3 +670,59 @@
|
|||||||
z-index: 80;
|
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;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.folderItem {
|
.folderItem {
|
||||||
@@ -148,6 +152,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-low);
|
background: var(--color-surface-low);
|
||||||
@@ -162,12 +167,107 @@
|
|||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
flex-shrink: 0;
|
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 {
|
.grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -345,17 +445,88 @@
|
|||||||
margin: var(--space-1) 0;
|
margin: var(--space-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownSubmenu {
|
.batchBar {
|
||||||
display: flex;
|
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);
|
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);
|
color: var(--color-muted);
|
||||||
text-transform: uppercase;
|
display: flex;
|
||||||
letter-spacing: 0.05em;
|
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 {
|
.newProjectForm {
|
||||||
@@ -550,3 +721,58 @@
|
|||||||
&:hover { background: var(--color-primary-darker); }
|
&:hover { background: var(--color-primary-darker); }
|
||||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
&: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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Card, Button, Modal } from '@/components';
|
||||||
import { useDrawingStore } from '@/stores';
|
import { useDrawingStore } from '@/stores';
|
||||||
import { api } from '@/services';
|
import { api } from '@/services';
|
||||||
import type { Drawing } from '@/types';
|
import type { Drawing, Folder as FolderType } from '@/types';
|
||||||
import styles from './FileBrowser.module.scss';
|
import styles from './FileBrowser.module.scss';
|
||||||
|
|
||||||
export const FileBrowser: React.FC = () => {
|
export const FileBrowser: React.FC = () => {
|
||||||
@@ -35,7 +35,22 @@ export const FileBrowser: React.FC = () => {
|
|||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
|
||||||
// Move state
|
// 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
|
// New drawing name modal state
|
||||||
const [showNameModal, setShowNameModal] = useState(false);
|
const [showNameModal, setShowNameModal] = useState(false);
|
||||||
@@ -173,6 +188,11 @@ export const FileBrowser: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await api.drawings.delete(drawing.id);
|
await api.drawings.delete(drawing.id);
|
||||||
removeDrawing(drawing.id);
|
removeDrawing(drawing.id);
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(drawing.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setActiveMenu(null);
|
setActiveMenu(null);
|
||||||
setModal(m => ({ ...m, open: false }));
|
setModal(m => ({ ...m, open: false }));
|
||||||
} catch (err) {
|
} 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) => {
|
const handleDuplicateDrawing = async (drawing: Drawing) => {
|
||||||
try {
|
try {
|
||||||
const newDrawing = await api.drawings.create({
|
const newDrawing = await api.drawings.create({
|
||||||
@@ -219,20 +288,165 @@ export const FileBrowser: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await api.drawings.update(drawing.id, { folder_id: folderId });
|
await api.drawings.update(drawing.id, { folder_id: folderId });
|
||||||
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
|
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
|
||||||
setMovingId(null);
|
setMoveModalDrawing(null);
|
||||||
setActiveMenu(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to move drawing:', err);
|
console.error('Failed to move drawing:', err);
|
||||||
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
|
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
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onClick = (e: MouseEvent) => {
|
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);
|
setActiveMenu(null);
|
||||||
}
|
}
|
||||||
|
if (folderMenuRef.current && !folderMenuRef.current.contains(target)) {
|
||||||
|
const isMenuBtn = target.closest(`.${styles.folderMenuBtn}`) !== null;
|
||||||
|
if (!isMenuBtn) {
|
||||||
|
setFolderMenuId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', onClick);
|
document.addEventListener('mousedown', onClick);
|
||||||
return () => document.removeEventListener('mousedown', onClick);
|
return () => document.removeEventListener('mousedown', onClick);
|
||||||
@@ -347,6 +561,28 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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}>
|
<div className={styles.content}>
|
||||||
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
|
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
|
||||||
{showNewProject && (
|
{showNewProject && (
|
||||||
@@ -374,7 +610,20 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className={styles.folderTree} role="tree">
|
<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
|
<button
|
||||||
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
|
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
|
||||||
onClick={() => handleFolderClick(null)}
|
onClick={() => handleFolderClick(null)}
|
||||||
@@ -386,16 +635,93 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{folders.map((folder) => (
|
{folders.map((folder) => (
|
||||||
<li key={folder.id}>
|
<li
|
||||||
<button
|
key={folder.id}
|
||||||
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
|
draggable
|
||||||
onClick={() => handleFolderClick(folder.id)}
|
onDragStart={(e) => handleDragStart(e, folder.id)}
|
||||||
aria-current={activeFolderId === folder.id ? 'true' : undefined}
|
onDragOver={(e) => {
|
||||||
role="treeitem"
|
e.preventDefault();
|
||||||
>
|
e.dataTransfer.dropEffect = 'move';
|
||||||
<Folder size={18} aria-hidden="true" />
|
if ((draggedFolderId && draggedFolderId !== folder.id) || (draggedDrawingId && draggedDrawingId !== folder.id)) {
|
||||||
<span>{folder.name}</span>
|
setDragOverFolderId(folder.id);
|
||||||
</button>
|
}
|
||||||
|
}}
|
||||||
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -410,93 +736,99 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
visibleDrawings.map((drawing) => (
|
visibleDrawings.map((drawing) => {
|
||||||
<Card
|
const isSelected = selectedIds.has(drawing.id);
|
||||||
key={drawing.id}
|
return (
|
||||||
className={styles.drawingCard}
|
<Card
|
||||||
hover
|
key={drawing.id}
|
||||||
role="listitem"
|
className={`${styles.drawingCard} ${isSelected ? styles.drawingSelected : ''}`}
|
||||||
tabIndex={0}
|
hover
|
||||||
onClick={() => handleDrawingClick(drawing)}
|
role="listitem"
|
||||||
onKeyDown={(e: React.KeyboardEvent) => {
|
tabIndex={0}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
draggable
|
||||||
e.preventDefault();
|
onDragStart={(e) => handleDrawingDragStart(e as unknown as React.DragEvent, drawing.id)}
|
||||||
handleDrawingClick(drawing);
|
onDragEnd={handleDrawingDragEnd}
|
||||||
}
|
onClick={() => handleDrawingClick(drawing)}
|
||||||
}}
|
onKeyDown={(e: React.KeyboardEvent) => {
|
||||||
aria-label={`Open drawing ${drawing.title}`}
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
>
|
e.preventDefault();
|
||||||
<div className={styles.thumbnail}>
|
handleDrawingClick(drawing);
|
||||||
{drawing.thumbnail_url ? (
|
}
|
||||||
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
|
}}
|
||||||
) : (
|
aria-label={`Open drawing ${drawing.title}`}
|
||||||
<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
|
<button
|
||||||
className={styles.more}
|
className={styles.selectBox}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
|
toggleSelect(drawing.id);
|
||||||
setRenamingId(null);
|
|
||||||
}}
|
}}
|
||||||
aria-label={`More options for ${drawing.title}`}
|
aria-label={isSelected ? 'Deselect' : 'Select'}
|
||||||
aria-expanded={activeMenu === drawing.id}
|
aria-pressed={isSelected}
|
||||||
>
|
>
|
||||||
<MoreVertical size={16} />
|
{isSelected ? <SquareCheck size={16} /> : <Square size={16} />}
|
||||||
</button>
|
</button>
|
||||||
{activeMenu === drawing.id && (
|
<div className={styles.thumbnail}>
|
||||||
<div className={styles.dropdown}>
|
{drawing.thumbnail_url ? (
|
||||||
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
|
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
|
||||||
<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>
|
<img
|
||||||
{movingId === drawing.id ? (
|
src={`/api/drawings/${drawing.id}/thumbnail`}
|
||||||
<div className={styles.dropdownSubmenu}>
|
alt=""
|
||||||
<button className={styles.dropdownSubheader}>Move to:</button>
|
loading="lazy"
|
||||||
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||||
{folders.map(f => (
|
/>
|
||||||
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
|
)}
|
||||||
))}
|
</div>
|
||||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
|
<div className={styles.info}>
|
||||||
</div>
|
{renamingId === drawing.id ? (
|
||||||
) : (
|
<input
|
||||||
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
|
autoFocus
|
||||||
)}
|
className={styles.renameInput}
|
||||||
<div className={styles.dropdownDivider} />
|
value={renameValue}
|
||||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
</div>
|
onKeyDown={(e) => {
|
||||||
)}
|
if (e.key === 'Enter') handleRenameDrawing(drawing);
|
||||||
</div>
|
if (e.key === 'Escape') setRenamingId(null);
|
||||||
</Card>
|
}}
|
||||||
))
|
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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -530,6 +862,80 @@ export const FileBrowser: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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">×</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">×</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export const api = {
|
|||||||
list: (): Promise<Folder[]> => fetchApi('/folders'),
|
list: (): Promise<Folder[]> => fetchApi('/folders'),
|
||||||
create: (data: object): Promise<Folder> =>
|
create: (data: object): Promise<Folder> =>
|
||||||
fetchApi('/folders', { method: 'POST', body: JSON.stringify(data) }),
|
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: {
|
teams: {
|
||||||
list: (): Promise<Team[]> => fetchApi('/teams'),
|
list: (): Promise<Team[]> => fetchApi('/teams'),
|
||||||
|
|||||||
@@ -27,5 +27,17 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: true,
|
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;
|
||||||
@@ -79,6 +79,9 @@ func (a *API) Routes() chi.Router {
|
|||||||
r.Get("/stats", a.handleStats)
|
r.Get("/stats", a.handleStats)
|
||||||
r.Get("/folders", a.handleListFolders)
|
r.Get("/folders", a.handleListFolders)
|
||||||
r.Post("/folders", a.handleCreateFolder)
|
r.Post("/folders", a.handleCreateFolder)
|
||||||
|
r.Patch("/folders/{folderID}", a.handleUpdateFolder)
|
||||||
|
r.Delete("/folders/{folderID}", a.handleDeleteFolder)
|
||||||
|
r.Post("/folders/reorder", a.handleReorderFolders)
|
||||||
r.Get("/projects", a.handleListProjects)
|
r.Get("/projects", a.handleListProjects)
|
||||||
r.Post("/projects", a.handleCreateProject)
|
r.Post("/projects", a.handleCreateProject)
|
||||||
r.Get("/notifications", a.handleListNotifications)
|
r.Get("/notifications", a.handleListNotifications)
|
||||||
@@ -625,6 +628,51 @@ func (a *API) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, folder)
|
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) {
|
func (a *API) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
user, _ := currentUser(r)
|
user, _ := currentUser(r)
|
||||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||||
|
|||||||
+143
-13
@@ -40,12 +40,33 @@ type CreateDrawingRequest struct {
|
|||||||
Snapshot json.RawMessage `json:"snapshot"`
|
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 {
|
type UpdateDrawingRequest struct {
|
||||||
FolderID *string `json:"folder_id"`
|
FolderID NullString `json:"folder_id"`
|
||||||
ProjectID *string `json:"project_id"`
|
ProjectID NullString `json:"project_id"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Visibility *string `json:"visibility"`
|
Visibility *string `json:"visibility"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateRevisionRequest struct {
|
type CreateRevisionRequest struct {
|
||||||
@@ -61,6 +82,11 @@ type CreateFolderRequest struct {
|
|||||||
Visibility string `json:"visibility"`
|
Visibility string `json:"visibility"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateFolderRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Visibility *string `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreateProjectRequest struct {
|
type CreateProjectRequest struct {
|
||||||
TeamID string `json:"team_id"`
|
TeamID string `json:"team_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -616,11 +642,11 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
|
|||||||
}
|
}
|
||||||
current.Visibility = *req.Visibility
|
current.Visibility = *req.Visibility
|
||||||
}
|
}
|
||||||
if req.FolderID != nil {
|
if req.FolderID.Valid {
|
||||||
current.FolderID = req.FolderID
|
current.FolderID = req.FolderID.Value
|
||||||
}
|
}
|
||||||
if req.ProjectID != nil {
|
if req.ProjectID.Valid {
|
||||||
current.ProjectID = req.ProjectID
|
current.ProjectID = req.ProjectID.Value
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings
|
_, 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
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -972,10 +998,12 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: 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
|
_, 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)
|
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at, sort_order)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -983,6 +1011,108 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
|||||||
return folder, nil
|
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) {
|
func (s *Store) ListProjects(ctx context.Context, userID, teamID string) ([]Project, error) {
|
||||||
if teamID == "" {
|
if teamID == "" {
|
||||||
teamID, _ = s.defaultTeamID(ctx, userID)
|
teamID, _ = s.defaultTeamID(ctx, userID)
|
||||||
|
|||||||
Reference in New Issue
Block a user