feat: inline dashboard editing mode with WYSIWYG experience
This commit is contained in:
Generated
+48
-11
@@ -11,6 +11,7 @@
|
|||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bits-ui": "^1.3.0",
|
"bits-ui": "^1.3.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
@@ -1890,6 +1891,29 @@
|
|||||||
"node": ">= 20"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/typography": {
|
||||||
|
"version": "0.5.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||||
|
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||||
|
"dependencies": {
|
||||||
|
"postcss-selector-parser": "6.0.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||||
|
"version": "6.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||||
|
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||||
|
"dependencies": {
|
||||||
|
"cssesc": "^3.0.0",
|
||||||
|
"util-deprecate": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/vite": {
|
"node_modules/@tailwindcss/vite": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
|
||||||
@@ -2956,7 +2980,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||||
"dev": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"cssesc": "bin/cssesc"
|
"cssesc": "bin/cssesc"
|
||||||
},
|
},
|
||||||
@@ -6140,8 +6163,7 @@
|
|||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
@@ -6879,8 +6901,7 @@
|
|||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "8.3.2",
|
"version": "8.3.2",
|
||||||
@@ -8290,6 +8311,25 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"@tailwindcss/typography": {
|
||||||
|
"version": "0.5.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||||
|
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||||
|
"requires": {
|
||||||
|
"postcss-selector-parser": "6.0.10"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"postcss-selector-parser": {
|
||||||
|
"version": "6.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||||
|
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||||
|
"requires": {
|
||||||
|
"cssesc": "^3.0.0",
|
||||||
|
"util-deprecate": "^1.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@tailwindcss/vite": {
|
"@tailwindcss/vite": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
|
||||||
@@ -9056,8 +9096,7 @@
|
|||||||
"cssesc": {
|
"cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"d": {
|
"d": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
@@ -11045,8 +11084,7 @@
|
|||||||
"tailwindcss": {
|
"tailwindcss": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"tapable": {
|
"tapable": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
@@ -11456,8 +11494,7 @@
|
|||||||
"util-deprecate": {
|
"util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"uuid": {
|
"uuid": {
|
||||||
"version": "8.3.2",
|
"version": "8.3.2",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bits-ui": "^1.3.0",
|
"bits-ui": "^1.3.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Feature Context: Inline Dashboard Editing
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
- **Development mode:** Automated
|
||||||
|
- **Execution mode:** Direct
|
||||||
|
- **Strategy:** Big Bang
|
||||||
|
- **Build:** `npm run build`
|
||||||
|
- **Test:** `npm run test`
|
||||||
|
- **Lint:** `npm run lint`
|
||||||
|
- **Dev server:** `npm run dev` (port: 5173)
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
Starting fresh. The board view page (`/boards/[boardId]`) is read-only.
|
||||||
|
The edit page (`/boards/[boardId]/edit`) is a separate form-based page.
|
||||||
|
|
||||||
|
## Key Architecture Notes
|
||||||
|
- SvelteKit 2 + Svelte 5 (runes: $state, $derived, $props)
|
||||||
|
- Prisma ORM with SQLite
|
||||||
|
- Tailwind CSS v4
|
||||||
|
- `svelte-dnd-action` for drag-and-drop
|
||||||
|
- `lucide-svelte` for icons
|
||||||
|
- `bits-ui` for UI primitives
|
||||||
|
- Widget configs stored as JSON strings in `Widget.config`
|
||||||
|
- Each widget type has Zod validation in `src/lib/utils/validators.ts`
|
||||||
|
- Existing form actions on edit page: ?/updateBoard, ?/addSection, ?/deleteSection, ?/updateSection, ?/addWidget, ?/deleteWidget
|
||||||
|
- Board view components: Board.svelte → Section.svelte → WidgetGrid.svelte → WidgetRenderer.svelte → [TypeWidget].svelte
|
||||||
|
|
||||||
|
## Temporary Workarounds
|
||||||
|
(none yet)
|
||||||
|
|
||||||
|
## Cross-Phase Dependencies
|
||||||
|
- Phase 3 (widget overlay) depends on Phase 1 (edit mode state)
|
||||||
|
- Phase 4 (config panels) depends on Phase 3 (overlay triggers)
|
||||||
|
- Phase 6 (add widget) depends on Phase 4 (config panel infrastructure)
|
||||||
|
- Phase 7 (DnD) depends on Phase 1 (edit mode gate)
|
||||||
|
- Phase 8 (batch save) depends on Phases 1-7 (all accumulated changes)
|
||||||
|
- Phase 9 (board properties) depends on Phase 2 (toolbar trigger)
|
||||||
|
- Phase 10 (migration) depends on all previous phases
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
(none yet)
|
||||||
|
|
||||||
|
## Failed Approaches
|
||||||
|
(none yet)
|
||||||
|
|
||||||
|
## Review Findings Log
|
||||||
|
(none yet)
|
||||||
|
|
||||||
|
## Phase Execution Log
|
||||||
|
| Phase | Agent Used | Test Writer | Parallel | Notes |
|
||||||
|
|-------|-----------|-------------|----------|-------|
|
||||||
|
| — | — | — | — | — |
|
||||||
|
|
||||||
|
## Environment & Runtime Notes
|
||||||
|
- Windows 10, Git Bash
|
||||||
|
- Node.js project with Vite dev server
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
(none yet)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Feature: Inline Dashboard Editing (Edit Mode)
|
||||||
|
|
||||||
|
**Branch:** `feature/inline-dashboard-editing`
|
||||||
|
**Base branch:** `master`
|
||||||
|
**Created:** 2026-04-02
|
||||||
|
**Status:** 🟡 In Progress
|
||||||
|
**Strategy:** Big Bang
|
||||||
|
**Mode:** Automated
|
||||||
|
**Execution:** Direct
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Replace the disconnected board edit page with a WYSIWYG inline editing experience.
|
||||||
|
Users toggle edit mode directly on the board view — widgets show edit/delete overlays,
|
||||||
|
"+" buttons appear for adding widgets and sections, drag-and-drop works across sections,
|
||||||
|
and all changes accumulate as a batch save. The board looks exactly as it will when
|
||||||
|
saved, at all times.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
- **Build:** `npm run build`
|
||||||
|
- **Test:** `npm run test`
|
||||||
|
- **Lint:** `npm run lint`
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
- [ ] Phase 1: Edit Mode State Infrastructure [domain: frontend] → [subplan](./phase-1-edit-mode-state.md)
|
||||||
|
- [ ] Phase 2: Floating Edit Toolbar [domain: frontend] → [subplan](./phase-2-floating-toolbar.md)
|
||||||
|
- [ ] Phase 3: Widget Edit Overlay [domain: frontend] → [subplan](./phase-3-widget-overlay.md)
|
||||||
|
- [ ] Phase 4: Inline Widget Configuration Panels [domain: frontend] → [subplan](./phase-4-widget-config-panels.md)
|
||||||
|
- [ ] Phase 5: Section Inline Editing [domain: frontend] → [subplan](./phase-5-section-editing.md)
|
||||||
|
- [ ] Phase 6: Add Widget Inline ("+" Buttons) [domain: frontend] → [subplan](./phase-6-add-widget-inline.md)
|
||||||
|
- [ ] Phase 7: Drag-and-Drop Enhancements [domain: frontend] → [subplan](./phase-7-dnd-enhancements.md)
|
||||||
|
- [ ] Phase 8: Optimistic Updates & Batch Save [domain: fullstack] → [subplan](./phase-8-batch-save.md)
|
||||||
|
- [ ] Phase 9: Board Properties Quick Panel [domain: frontend] → [subplan](./phase-9-board-properties-panel.md)
|
||||||
|
- [ ] Phase 10: Legacy Edit Page Migration & Polish [domain: fullstack] → [subplan](./phase-10-migration-polish.md)
|
||||||
|
|
||||||
|
## Phase Progress Log
|
||||||
|
|
||||||
|
| Phase | Domain | Status | Review | Build | Committed |
|
||||||
|
|-------|--------|--------|--------|-------|-----------|
|
||||||
|
| Phase 1: Edit Mode State | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
| Phase 2: Floating Toolbar | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
| Phase 3: Widget Overlay | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
| Phase 4: Widget Config Panels | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
| Phase 5: Section Editing | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
| Phase 6: Add Widget Inline | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
| Phase 7: DnD Enhancements | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
| Phase 8: Batch Save | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
| Phase 9: Board Properties Panel | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
| Phase 10: Migration & Polish | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
|
||||||
|
## Final Review
|
||||||
|
- [ ] Comprehensive code review
|
||||||
|
- [ ] Full build passes
|
||||||
|
- [ ] Full test suite passes
|
||||||
|
- [ ] Merged to `master`
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Phase 1: Edit Mode State Infrastructure
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Create the foundational edit mode state management and toggle mechanism that all subsequent phases build upon.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Create `src/lib/stores/editMode.svelte.ts` with state: `{ active, boardId, dirty, changeCount }`
|
||||||
|
- [ ] Export functions: `enterEditMode(boardId)`, `exitEditMode()`, `markDirty()`, `resetDirty()`
|
||||||
|
- [ ] Add "Edit Mode" toggle button to `BoardHeader.svelte` (replaces the current "Edit" link to `/boards/[id]/edit`)
|
||||||
|
- [ ] When toggled ON: set edit mode active, show visual indicator (subtle board border glow or tint)
|
||||||
|
- [ ] When toggled OFF: if dirty, show confirmation dialog "Discard unsaved changes?"
|
||||||
|
- [ ] Register keyboard shortcut `Ctrl+E` / `Cmd+E` to toggle edit mode
|
||||||
|
- [ ] Pass edit mode state as Svelte context from board view page
|
||||||
|
- [ ] Add `editMode` context consumer helpers for child components
|
||||||
|
- [ ] Visual indicator: board gets a subtle colored top-bar or border when in edit mode
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `src/lib/stores/editMode.svelte.ts` — new store
|
||||||
|
- `src/lib/components/board/BoardHeader.svelte` — replace Edit link with toggle button
|
||||||
|
- `src/routes/boards/[boardId]/+page.svelte` — provide edit mode context, visual indicators
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Clicking the toggle enters/exits edit mode
|
||||||
|
- Ctrl+E toggles edit mode
|
||||||
|
- Board view page visually indicates edit mode is active
|
||||||
|
- Child components can read edit mode state via context
|
||||||
|
- Dirty state tracking works (increments on markDirty, resets on save/discard)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Use Svelte 5 runes ($state, $derived) for the store
|
||||||
|
- The keyboard shortcut must not conflict with existing shortcuts
|
||||||
|
- Guest users / users without edit permission must NOT see the toggle
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completing this phase -->
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Phase 10: Legacy Edit Page Migration & Polish
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** fullstack
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Migrate remaining functionality from the legacy edit page, add polish, transitions, and ensure full accessibility.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Redirect `/boards/[id]/edit` to `/boards/[id]?edit=true` (auto-enters edit mode)
|
||||||
|
- [ ] Handle `?edit=true` query param on board view page to auto-enter edit mode
|
||||||
|
- [ ] Migrate permission management: add permissions editor accessible from edit mode (or link to legacy page as "Advanced Settings")
|
||||||
|
- [ ] Add smooth transition animations between view and edit modes
|
||||||
|
- [ ] Keyboard navigation: Tab through edit controls, Enter to confirm, Escape to cancel/close
|
||||||
|
- [ ] ARIA labels on all edit controls (buttons, overlays, panels)
|
||||||
|
- [ ] Focus management: auto-focus appropriate elements when panels open
|
||||||
|
- [ ] Focus trap in modals/panels
|
||||||
|
- [ ] Screen reader announcements for mode changes ("Edit mode enabled", "Widget deleted")
|
||||||
|
- [ ] Ensure all existing edit page functionality is accessible through inline UI
|
||||||
|
- [ ] Polish: loading states, error boundaries, edge case handling
|
||||||
|
- [ ] Mobile responsiveness: touch-friendly edit controls, appropriate sizing
|
||||||
|
- [ ] Verify no regressions with guest access (guests should never see edit controls)
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `src/routes/boards/[boardId]/edit/+page.server.ts` — add redirect
|
||||||
|
- `src/routes/boards/[boardId]/+page.svelte` — handle ?edit=true, transitions
|
||||||
|
- Various components — accessibility attributes, animations
|
||||||
|
- `src/lib/components/board/BoardAccessControl.svelte` — integrate or link from edit mode
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- `/boards/[id]/edit` redirects to inline edit mode
|
||||||
|
- All functionality from legacy edit page is accessible
|
||||||
|
- Keyboard navigation works throughout edit mode
|
||||||
|
- Screen readers can use edit mode
|
||||||
|
- Transitions are smooth and non-jarring
|
||||||
|
- Mobile experience is usable
|
||||||
|
- Guest users cannot access edit features
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Consider keeping legacy edit page temporarily as "Advanced Edit" for power users
|
||||||
|
- Permission management is complex — may be better as a dedicated panel than inline
|
||||||
|
- Test with different board sizes (empty, 1 section, 10+ sections with many widgets)
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completing this phase -->
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Phase 2: Floating Edit Toolbar
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Create a sticky floating toolbar that appears when edit mode is active, providing quick access to common editing actions.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Create `src/lib/components/board/EditToolbar.svelte`
|
||||||
|
- [ ] Toolbar actions: Save All, Discard, Add Section, Board Settings (gear), Exit Edit Mode
|
||||||
|
- [ ] Show unsaved change count badge on Save button
|
||||||
|
- [ ] Position: fixed at bottom-center of viewport (floating pill shape)
|
||||||
|
- [ ] Entrance animation: slide up from bottom with fade
|
||||||
|
- [ ] Exit animation: slide down with fade
|
||||||
|
- [ ] Responsive: collapses to icon-only on small screens
|
||||||
|
- [ ] Only renders when edit mode is active
|
||||||
|
- [ ] Wire "Exit Edit Mode" to the store's exitEditMode()
|
||||||
|
- [ ] Wire "Add Section" to emit event (handled in Phase 6)
|
||||||
|
- [ ] Wire "Board Settings" to emit event (handled in Phase 9)
|
||||||
|
- [ ] Wire "Save All" to emit event (handled in Phase 8)
|
||||||
|
- [ ] Wire "Discard" to revert all changes and exit edit mode
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `src/lib/components/board/EditToolbar.svelte` — new component
|
||||||
|
- `src/routes/boards/[boardId]/+page.svelte` — mount toolbar when edit mode active
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Toolbar appears/disappears smoothly with edit mode toggle
|
||||||
|
- All buttons are present and visually clear
|
||||||
|
- Change count badge updates reactively
|
||||||
|
- Responsive layout works on mobile
|
||||||
|
- Toolbar doesn't obscure board content (proper z-index, positioning)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Use lucide-svelte icons for toolbar buttons
|
||||||
|
- z-index must be above board content but below modals/dialogs
|
||||||
|
- "Save" and "Discard" will be wired to real logic in Phase 8
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completing this phase -->
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Phase 3: Widget Edit Overlay
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Add hover overlays to every widget when in edit mode, showing edit/delete/drag controls without obscuring the widget content.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Create `src/lib/components/widget/WidgetEditOverlay.svelte`
|
||||||
|
- [ ] Overlay appears on hover over a widget in edit mode
|
||||||
|
- [ ] Controls: pencil icon (top-right), trash icon (top-right, secondary), drag handle (top-left)
|
||||||
|
- [ ] Semi-transparent backdrop on hover (e.g., bg-black/5)
|
||||||
|
- [ ] Pencil click emits `onEdit(widgetId)` event
|
||||||
|
- [ ] Trash click shows inline confirmation ("Delete?" with Yes/No), then emits `onDelete(widgetId)`
|
||||||
|
- [ ] Drag handle integrated with svelte-dnd-action (prepared for Phase 7)
|
||||||
|
- [ ] Wrap each widget in WidgetEditOverlay in `WidgetGrid.svelte` when edit mode is active
|
||||||
|
- [ ] Overlay transitions: fade in on hover, fade out on leave
|
||||||
|
- [ ] Overlay does NOT block widget interaction when not hovered (pointer-events)
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `src/lib/components/widget/WidgetEditOverlay.svelte` — new component
|
||||||
|
- `src/lib/components/widget/WidgetGrid.svelte` — wrap widgets conditionally
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Hovering over a widget in edit mode shows the overlay
|
||||||
|
- Overlay has pencil, trash, and drag handle controls
|
||||||
|
- Controls are clickable and emit correct events
|
||||||
|
- Overlay does not appear when NOT in edit mode
|
||||||
|
- Widget content remains visible through the overlay
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Keep overlay minimal — don't overwhelm the widget visually
|
||||||
|
- Trash confirmation should be inline (not a browser confirm dialog)
|
||||||
|
- The actual edit panel opening (pencil) is Phase 4
|
||||||
|
- The actual delete logic is Phase 8
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completing this phase -->
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Phase 4: Inline Widget Configuration Panels
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Create type-specific configuration panels that open inline when the user clicks the edit (pencil) button on a widget, allowing real-time config editing with live preview.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Create `src/lib/components/widget/WidgetConfigPanel.svelte` — container/router for type-specific panels
|
||||||
|
- [ ] Create config panel components for each widget type (or a dynamic form approach):
|
||||||
|
- App: app selector dropdown
|
||||||
|
- Bookmark: url, label, icon, description
|
||||||
|
- Note/Markdown: inline content editor
|
||||||
|
- Embed: url, height, sandbox
|
||||||
|
- Status: multi-app selector, label
|
||||||
|
- Clock: timezone, style, weather toggle, coordinates
|
||||||
|
- System Stats: source url/type, metrics, refresh interval
|
||||||
|
- RSS: feed url, max items, show summary
|
||||||
|
- Calendar: iCal URLs, days ahead
|
||||||
|
- Metric: label, source, value/url/query, unit, refresh
|
||||||
|
- Link Group: links array editor, collapsible toggle
|
||||||
|
- Camera: stream url, type, refresh, aspect ratio
|
||||||
|
- Integration: app selector, endpoint selector, refresh
|
||||||
|
- [ ] Panel opens as a popover/slide-out anchored to the widget
|
||||||
|
- [ ] Pre-populate fields with current widget config
|
||||||
|
- [ ] Live preview: changes update the widget rendering in real-time (optimistic, local state)
|
||||||
|
- [ ] Save/Cancel buttons per panel
|
||||||
|
- [ ] Save stores changes in the edit mode changeset (not persisted until batch save in Phase 8)
|
||||||
|
- [ ] Cancel reverts to original config
|
||||||
|
- [ ] Auto-focus first field when panel opens
|
||||||
|
- [ ] Close panel on Escape key
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `src/lib/components/widget/WidgetConfigPanel.svelte` — new panel router
|
||||||
|
- `src/lib/components/widget/config/` — new directory for type-specific config forms
|
||||||
|
- `src/lib/components/widget/WidgetEditOverlay.svelte` — wire pencil to open config panel
|
||||||
|
- `src/lib/components/widget/WidgetRenderer.svelte` — support config override from edit state
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Clicking pencil on any widget type opens the correct config panel
|
||||||
|
- Fields are pre-populated with current values
|
||||||
|
- Changes preview live on the widget
|
||||||
|
- Save adds to changeset, Cancel reverts
|
||||||
|
- Panel closes on Save, Cancel, or Escape
|
||||||
|
- All 14 widget types have config support
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Reuse Zod schemas from `src/lib/utils/validators.ts` for field validation
|
||||||
|
- Consider a generic form approach for simple types (key-value pairs) vs custom for complex ones (link_group links array)
|
||||||
|
- Panel positioning: use a popover that doesn't overflow viewport
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completing this phase -->
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Phase 5: Section Inline Editing
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Make section headers editable inline in edit mode — title, icon, card size, expand default, delete.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Modify `SectionHeader.svelte` to show edit controls when edit mode is active
|
||||||
|
- [ ] Pencil icon on section header — click to toggle inline editing of title and icon
|
||||||
|
- [ ] Inline title editing: click title text to replace with input field, Enter to confirm, Escape to cancel
|
||||||
|
- [ ] Icon picker for section icon (reuse `AppIconPicker` or simplified version)
|
||||||
|
- [ ] Card size dropdown override (compact/medium/large/inherit)
|
||||||
|
- [ ] Toggle for `isExpandedByDefault`
|
||||||
|
- [ ] Delete section button with confirmation ("Delete section 'X' and its N widgets?")
|
||||||
|
- [ ] Drag handle for section reordering (left side of header, visible only in edit mode)
|
||||||
|
- [ ] All changes stored in edit mode changeset
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `src/lib/components/section/SectionHeader.svelte` — add edit controls
|
||||||
|
- `src/lib/components/section/Section.svelte` — pass edit mode state
|
||||||
|
- `src/lib/components/section/SectionEditControls.svelte` — new, extracted edit controls
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Section title is editable inline in edit mode
|
||||||
|
- Section icon is changeable via picker
|
||||||
|
- Card size override works
|
||||||
|
- Delete shows confirmation with widget count
|
||||||
|
- Changes accumulate in changeset (not persisted until Save)
|
||||||
|
- Controls hidden when not in edit mode
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Section drag-and-drop reorder is handled further in Phase 7
|
||||||
|
- Delete confirmation should show actual widget count from current state
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completing this phase -->
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Phase 6: Add Widget Inline ("+" Buttons)
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Add prominent "+" buttons for adding widgets to sections and adding new sections, all inline on the board view.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Create `src/lib/components/widget/AddWidgetButton.svelte` — the "+" button shown at end of widget grid
|
||||||
|
- [ ] Create `src/lib/components/board/AddSectionDivider.svelte` — subtle divider between sections with "+" button
|
||||||
|
- [ ] Widget type picker: grid of icons with labels (App, Bookmark, Note, Embed, Status, Clock, etc.)
|
||||||
|
- [ ] Clicking a type opens the config panel from Phase 4 for the new widget
|
||||||
|
- [ ] New widget appears immediately in grid as a skeleton/placeholder while being configured
|
||||||
|
- [ ] "Add Section" shows minimal inline form: title input + optional icon + confirm button
|
||||||
|
- [ ] New section appears immediately in the board with empty widget grid
|
||||||
|
- [ ] All additions tracked in edit mode changeset (temporary IDs until batch save)
|
||||||
|
- [ ] "Add Section" button also available from the floating toolbar (Phase 2)
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `src/lib/components/widget/AddWidgetButton.svelte` — new
|
||||||
|
- `src/lib/components/widget/WidgetTypePicker.svelte` — new, type selection grid
|
||||||
|
- `src/lib/components/board/AddSectionDivider.svelte` — new
|
||||||
|
- `src/lib/components/board/AddSectionForm.svelte` — new, inline section creation
|
||||||
|
- `src/lib/components/widget/WidgetGrid.svelte` — append AddWidgetButton in edit mode
|
||||||
|
- `src/lib/components/board/Board.svelte` — insert AddSectionDivider between sections
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- "+" button visible at end of each section's widget grid in edit mode
|
||||||
|
- "+" section divider visible between sections in edit mode
|
||||||
|
- Type picker shows all available widget types with icons
|
||||||
|
- Selecting a type opens config panel for new widget
|
||||||
|
- New widgets/sections appear immediately (optimistic)
|
||||||
|
- Hidden when not in edit mode
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Use temporary client-side IDs (e.g., `temp-${crypto.randomUUID()}`) for new items
|
||||||
|
- Widget type icons should use lucide-svelte icons matching each type
|
||||||
|
- Empty sections should still show the "+" add widget button
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completing this phase -->
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Phase 7: Drag-and-Drop Enhancements
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Enhance drag-and-drop to support cross-section widget moves, section reordering, and visual drop zone indicators — all gated behind edit mode.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Enable widget drag-and-drop ONLY in edit mode (disable in view mode)
|
||||||
|
- [ ] Cross-section widget drag: allow dragging a widget from one section to another
|
||||||
|
- [ ] Visual drop zones: highlight target section/position when dragging
|
||||||
|
- [ ] Section-level drag-and-drop with visual indicators (reorder sections)
|
||||||
|
- [ ] Drag handles only visible in edit mode
|
||||||
|
- [ ] Track all reorder/move changes in edit mode changeset
|
||||||
|
- [ ] Handle edge cases: dragging to empty sections, dragging last widget out of section
|
||||||
|
- [ ] Smooth animations during drag operations
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `src/lib/components/widget/WidgetGrid.svelte` — enable DnD only in edit mode, cross-section support
|
||||||
|
- `src/lib/components/board/Board.svelte` — section-level DnD in edit mode
|
||||||
|
- `src/lib/components/section/Section.svelte` — drop zone indicators
|
||||||
|
- `src/lib/components/widget/WidgetEditOverlay.svelte` — drag handle activation
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Widgets can be dragged between sections in edit mode
|
||||||
|
- Sections can be reordered by dragging in edit mode
|
||||||
|
- Drop zones highlight during drag
|
||||||
|
- No drag-and-drop functionality in view mode
|
||||||
|
- All moves tracked in changeset (not persisted until Save)
|
||||||
|
- Animations are smooth
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `svelte-dnd-action` supports cross-container DnD via shared `dropTargetStyle`
|
||||||
|
- Need to handle `sectionId` changes when widgets move between sections
|
||||||
|
- Existing DraggableBoard/DraggableSection are used on edit page — may adapt or replace
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completing this phase -->
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Phase 8: Optimistic Updates & Batch Save
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** fullstack
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Implement the changeset accumulation system and a batch API endpoint that persists all edit mode changes in a single transaction.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Design changeset data structure in editMode store:
|
||||||
|
- `widgetUpdates: Map<id, configChanges>`
|
||||||
|
- `widgetAdds: Array<{tempId, sectionId, type, config, order}>`
|
||||||
|
- `widgetDeletes: Set<id>`
|
||||||
|
- `widgetMoves: Map<id, {fromSectionId, toSectionId, newOrder}>`
|
||||||
|
- `sectionUpdates: Map<id, changes>`
|
||||||
|
- `sectionAdds: Array<{tempId, title, icon, order}>`
|
||||||
|
- `sectionDeletes: Set<id>`
|
||||||
|
- `sectionReorders: Array<{id, newOrder}>`
|
||||||
|
- `boardUpdates: Partial<BoardProps>`
|
||||||
|
- [ ] Create `POST /api/boards/[id]/batch-update` endpoint
|
||||||
|
- [ ] Endpoint accepts the full changeset as JSON body
|
||||||
|
- [ ] Server-side: validate all changes, execute in a single Prisma transaction
|
||||||
|
- [ ] Server-side: handle temp IDs → real IDs mapping for new items
|
||||||
|
- [ ] Server-side: authorization check (user must have edit permission)
|
||||||
|
- [ ] Wire "Save All" toolbar button to serialize changeset and call batch endpoint
|
||||||
|
- [ ] On success: clear changeset, reset dirty state, broadcast to other tabs, invalidateAll()
|
||||||
|
- [ ] On failure: show error, keep changeset intact (no data loss)
|
||||||
|
- [ ] Wire "Discard" toolbar button to reset changeset, revert optimistic UI, exit edit mode
|
||||||
|
- [ ] Wire widget delete (from Phase 3 overlay) to add to changeset
|
||||||
|
- [ ] Wire widget config save (from Phase 4) to add to changeset
|
||||||
|
- [ ] Wire section changes (from Phase 5) to add to changeset
|
||||||
|
- [ ] Wire new items (from Phase 6) to add to changeset
|
||||||
|
- [ ] Wire DnD moves (from Phase 7) to add to changeset
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `src/lib/stores/editMode.svelte.ts` — add changeset state and mutation functions
|
||||||
|
- `src/routes/api/boards/[id]/batch-update/+server.ts` — new batch API endpoint
|
||||||
|
- `src/lib/components/board/EditToolbar.svelte` — wire Save/Discard to real logic
|
||||||
|
- `src/lib/components/widget/WidgetEditOverlay.svelte` — wire delete to changeset
|
||||||
|
- `src/lib/components/widget/WidgetConfigPanel.svelte` — wire save to changeset
|
||||||
|
- Various components — connect to changeset mutations
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- All changes from Phases 3-7 accumulate in the changeset
|
||||||
|
- "Save All" sends one HTTP request with all changes
|
||||||
|
- Server processes all changes in a single transaction
|
||||||
|
- On success: board reloads with persisted state
|
||||||
|
- On failure: changes are preserved, error is shown
|
||||||
|
- "Discard" reverts everything to pre-edit state
|
||||||
|
- Change count in toolbar updates reactively
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Batch endpoint must be idempotent-safe (temp IDs prevent double-creates)
|
||||||
|
- Widget order values must be recalculated during batch save
|
||||||
|
- Prisma `$transaction` for atomicity
|
||||||
|
- Consider payload size limits for very large boards
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completing this phase -->
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Phase 9: Board Properties Quick Panel
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Create a side panel / modal accessible from the edit toolbar's gear icon for editing board-level properties with live preview.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Create `src/lib/components/board/BoardPropertiesPanel.svelte`
|
||||||
|
- [ ] Panel opens from toolbar gear icon as a slide-out side panel (right side)
|
||||||
|
- [ ] Board properties: name, description, icon
|
||||||
|
- [ ] Theme settings: themeHue slider (0-360), themeSaturation slider (0-100)
|
||||||
|
- [ ] Background type selector: mesh, particles, aurora, wallpaper, none
|
||||||
|
- [ ] Wallpaper settings: upload, URL input, blur slider, overlay opacity slider
|
||||||
|
- [ ] Card size selector: compact, medium, large
|
||||||
|
- [ ] Custom CSS editor (textarea or code editor)
|
||||||
|
- [ ] Guest access toggle
|
||||||
|
- [ ] All changes preview live on the board behind the panel
|
||||||
|
- [ ] Changes stored in edit mode changeset (boardUpdates)
|
||||||
|
- [ ] Close panel button, Escape to close
|
||||||
|
- [ ] Panel has its own scroll if content overflows
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
- `src/lib/components/board/BoardPropertiesPanel.svelte` — new
|
||||||
|
- `src/lib/components/board/EditToolbar.svelte` — wire gear icon to open panel
|
||||||
|
- `src/routes/boards/[boardId]/+page.svelte` — mount panel, apply live preview overrides
|
||||||
|
- `src/lib/components/board/BoardThemeProvider.svelte` — support preview overrides
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- Gear icon opens the properties panel
|
||||||
|
- All board-level settings are editable
|
||||||
|
- Changes preview live on the board
|
||||||
|
- Wallpaper upload works with live preview
|
||||||
|
- Theme sliders update board colors in real-time
|
||||||
|
- Changes accumulate in changeset
|
||||||
|
- Panel closes on button click or Escape
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Live preview means overriding BoardThemeProvider props with unsaved values
|
||||||
|
- Wallpaper upload may need special handling (uploaded immediately to server, URL stored in changeset)
|
||||||
|
- Custom CSS injection should be sandboxed to board scope
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] All tasks completed
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No unintended side effects
|
||||||
|
- [ ] Build passes
|
||||||
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
<!-- Filled in after completing this phase -->
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import AppIconPicker from './AppIconPicker.svelte';
|
import AppIconPicker from './AppIconPicker.svelte';
|
||||||
import IntegrationConfigFields from './IntegrationConfigFields.svelte';
|
import IntegrationConfigFields from './IntegrationConfigFields.svelte';
|
||||||
import AppUrlPreview from './AppUrlPreview.svelte';
|
import AppUrlPreview from './AppUrlPreview.svelte';
|
||||||
|
import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte';
|
||||||
|
import TagsInput from '$lib/components/ui/TagsInput.svelte';
|
||||||
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
||||||
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
|
||||||
@@ -25,6 +27,21 @@
|
|||||||
|
|
||||||
let showAdvanced = $state(false);
|
let showAdvanced = $state(false);
|
||||||
let showIntegration = $state(false);
|
let showIntegration = $state(false);
|
||||||
|
let categorySuggestions = $state<string[]>([]);
|
||||||
|
let tagSuggestions = $state<string[]>([]);
|
||||||
|
|
||||||
|
// Fetch autocomplete suggestions
|
||||||
|
$effect(() => {
|
||||||
|
fetch('/api/apps/suggestions')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((json) => {
|
||||||
|
if (json.success) {
|
||||||
|
categorySuggestions = json.data?.categories ?? [];
|
||||||
|
tagSuggestions = json.data?.tags ?? [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
let availableIntegrations = $state<Array<{ id: string; name: string; icon: string; authConfigFields: any[]; extraConfigFields: any[] }>>([]);
|
let availableIntegrations = $state<Array<{ id: string; name: string; icon: string; authConfigFields: any[]; extraConfigFields: any[] }>>([]);
|
||||||
let integrationConfig = $state<Record<string, unknown>>({});
|
let integrationConfig = $state<Record<string, unknown>>({});
|
||||||
let testingConnection = $state(false);
|
let testingConnection = $state(false);
|
||||||
@@ -148,13 +165,13 @@
|
|||||||
<label for="category" class="mb-1 block text-sm font-medium text-card-foreground">
|
<label for="category" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||||
{$t('app.category')}
|
{$t('app.category')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<AutocompleteInput
|
||||||
id="category"
|
id="category"
|
||||||
name="category"
|
name="category"
|
||||||
type="text"
|
|
||||||
bind:value={$form.category}
|
bind:value={$form.category}
|
||||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
suggestions={categorySuggestions}
|
||||||
placeholder={$t('app.category_placeholder')}
|
placeholder={$t('app.category_placeholder')}
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,13 +179,13 @@
|
|||||||
<label for="tags" class="mb-1 block text-sm font-medium text-card-foreground">
|
<label for="tags" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||||
{$t('app.tags')}
|
{$t('app.tags')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<TagsInput
|
||||||
id="tags"
|
id="tags"
|
||||||
name="tags"
|
name="tags"
|
||||||
type="text"
|
|
||||||
bind:value={$form.tags}
|
bind:value={$form.tags}
|
||||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
suggestions={tagSuggestions}
|
||||||
placeholder={$t('app.tags_placeholder')}
|
placeholder={$t('app.tags_placeholder')}
|
||||||
|
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
|
||||||
|
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||||
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,6 +31,11 @@
|
|||||||
iconValue = target.value;
|
iconValue = target.value;
|
||||||
onchange?.(iconType, iconValue);
|
onchange?.(iconType, iconValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleIconPickerChange(value: string) {
|
||||||
|
iconValue = value;
|
||||||
|
onchange?.(iconType, iconValue);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -44,22 +51,36 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
{#if iconType === 'lucide'}
|
||||||
type="text"
|
<div class="flex flex-1 items-start">
|
||||||
value={iconValue}
|
<IconPickerButton
|
||||||
oninput={handleValueChange}
|
value={iconValue}
|
||||||
placeholder={iconType === 'lucide'
|
onchange={handleIconPickerChange}
|
||||||
? $t('app.icon_lucide_placeholder')
|
placeholder={$t('app.icon_lucide_placeholder') ?? 'Select icon'}
|
||||||
: iconType === 'simple'
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={iconValue}
|
||||||
|
oninput={handleValueChange}
|
||||||
|
placeholder={iconType === 'simple'
|
||||||
? $t('app.icon_simple_placeholder')
|
? $t('app.icon_simple_placeholder')
|
||||||
: iconType === 'url'
|
: iconType === 'url'
|
||||||
? $t('app.icon_url_placeholder')
|
? $t('app.icon_url_placeholder')
|
||||||
: $t('app.icon_emoji_placeholder')}
|
: $t('app.icon_emoji_placeholder')}
|
||||||
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if iconType === 'emoji' && iconValue}
|
<!-- Preview -->
|
||||||
|
{#if iconType === 'lucide' && iconValue}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<DynamicIcon name={iconValue} size={24} />
|
||||||
|
<span class="text-xs text-muted-foreground">{iconValue}</span>
|
||||||
|
</div>
|
||||||
|
{:else if iconType === 'emoji' && iconValue}
|
||||||
<div class="text-2xl">{iconValue}</div>
|
<div class="text-2xl">{iconValue}</div>
|
||||||
{:else if iconType === 'url' && iconValue}
|
{:else if iconType === 'url' && iconValue}
|
||||||
<img src={iconValue} alt={$t('app.icon_preview')} class="h-8 w-8 rounded object-contain" />
|
<img src={iconValue} alt={$t('app.icon_preview')} class="h-8 w-8 rounded object-contain" />
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||||
|
import AddSectionForm from './AddSectionForm.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { order }: Props = $props();
|
||||||
|
|
||||||
|
let showForm = $state(false);
|
||||||
|
|
||||||
|
function handleAdd(title: string, icon: string | null) {
|
||||||
|
editMode.addSection({
|
||||||
|
tempId: `temp-section-${crypto.randomUUID()}`,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
order,
|
||||||
|
isExpandedByDefault: true
|
||||||
|
});
|
||||||
|
showForm = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="group relative flex items-center justify-center py-1">
|
||||||
|
{#if showForm}
|
||||||
|
<AddSectionForm
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
onCancel={() => { showForm = false; }}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { showForm = true; }}
|
||||||
|
class="flex items-center gap-1.5 rounded-lg border border-dashed border-border px-3 py-1.5 text-xs text-muted-foreground opacity-0 transition-all hover:border-primary hover:text-primary group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
{$t('board.add_section') ?? 'Add Section'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSubmit: (title: string, icon: string | null) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSubmit, onCancel }: Props = $props();
|
||||||
|
|
||||||
|
let title = $state('');
|
||||||
|
let icon = $state('');
|
||||||
|
let inputEl: HTMLInputElement | undefined = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tick().then(() => inputEl?.focus());
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const trimmed = title.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onSubmit(trimmed, icon.trim() || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onCancel();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 rounded-xl border border-border bg-card p-3 shadow-sm">
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
placeholder={$t('board.section_title') ?? 'Section title...'}
|
||||||
|
class="flex-1 rounded-lg border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||||
|
/>
|
||||||
|
<IconPickerButton value={icon} onchange={(v) => { icon = v; }} size="sm" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleSubmit}
|
||||||
|
disabled={!title.trim()}
|
||||||
|
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{$t('common.add') ?? 'Add'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onCancel}
|
||||||
|
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
{$t('common.cancel') ?? 'Cancel'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import Section from '$lib/components/section/Section.svelte';
|
import Section from '$lib/components/section/Section.svelte';
|
||||||
|
import AddSectionDivider from '$lib/components/board/AddSectionDivider.svelte';
|
||||||
|
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||||
|
import { dndzone } from 'svelte-dnd-action';
|
||||||
|
import type { CardSize } from '$lib/utils/constants.js';
|
||||||
|
|
||||||
interface SectionData {
|
interface SectionData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,6 +12,7 @@
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
order: number;
|
order: number;
|
||||||
isExpandedByDefault: boolean;
|
isExpandedByDefault: boolean;
|
||||||
|
cardSize?: string | null;
|
||||||
widgets: Array<{
|
widgets: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -36,8 +41,6 @@
|
|||||||
statuses: Array<{ status: string; responseTime: number | null }>;
|
statuses: Array<{ status: string; responseTime: number | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { CardSize } from '$lib/utils/constants.js';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sections: SectionData[];
|
sections: SectionData[];
|
||||||
allApps?: AppData[];
|
allApps?: AppData[];
|
||||||
@@ -45,15 +48,122 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { sections, allApps = [], boardCardSize = 'medium' }: Props = $props();
|
let { sections, allApps = [], boardCardSize = 'medium' }: Props = $props();
|
||||||
|
|
||||||
|
const flipDurationMs = 200;
|
||||||
|
|
||||||
|
// Merge real sections with changeset modifications
|
||||||
|
const mergedSections = $derived.by(() => {
|
||||||
|
const base = sections.filter((s) => !editMode.changeset.sectionDeletes.has(s.id));
|
||||||
|
|
||||||
|
// Apply section updates from changeset
|
||||||
|
const updated = base.map((s) => {
|
||||||
|
const changes = editMode.changeset.sectionUpdates.get(s.id);
|
||||||
|
if (!changes) return s;
|
||||||
|
return { ...s, ...changes } as SectionData;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add temp sections
|
||||||
|
const tempSections: SectionData[] = editMode.changeset.sectionAdds.map((add) => ({
|
||||||
|
id: add.tempId,
|
||||||
|
title: add.title,
|
||||||
|
icon: add.icon,
|
||||||
|
order: add.order,
|
||||||
|
isExpandedByDefault: add.isExpandedByDefault,
|
||||||
|
cardSize: add.cardSize,
|
||||||
|
widgets: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Merge widgets: apply adds/deletes/updates per section
|
||||||
|
const allSections = [...updated, ...tempSections].map((s) => {
|
||||||
|
const addedWidgets = editMode.changeset.widgetAdds
|
||||||
|
.filter((w) => w.sectionId === s.id)
|
||||||
|
.map((w) => ({
|
||||||
|
id: w.tempId,
|
||||||
|
type: w.type,
|
||||||
|
order: w.order,
|
||||||
|
config: w.config,
|
||||||
|
appId: w.appId ?? null,
|
||||||
|
app: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
const existingWidgets = s.widgets
|
||||||
|
.filter((w) => !editMode.changeset.widgetDeletes.has(w.id))
|
||||||
|
.map((w) => {
|
||||||
|
const configUpdates = editMode.changeset.widgetUpdates.get(w.id);
|
||||||
|
if (!configUpdates) return w;
|
||||||
|
const currentConfig = JSON.parse(w.config || '{}');
|
||||||
|
const newConfig = { ...currentConfig, ...configUpdates };
|
||||||
|
return { ...w, config: JSON.stringify(newConfig) };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...s, widgets: [...existingWidgets, ...addedWidgets] };
|
||||||
|
});
|
||||||
|
|
||||||
|
return allSections.sort((a, b) => {
|
||||||
|
const orderA = editMode.changeset.sectionReorders.get(a.id) ?? a.order;
|
||||||
|
const orderB = editMode.changeset.sectionReorders.get(b.id) ?? b.order;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const displaySections = $derived(editMode.active ? mergedSections : sections);
|
||||||
|
|
||||||
|
// DnD state for section reordering
|
||||||
|
let dndSections = $state<SectionData[]>([]);
|
||||||
|
let dndActive = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!dndActive) {
|
||||||
|
dndSections = [...displaySections];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSectionConsider(e: CustomEvent<{ items: SectionData[] }>) {
|
||||||
|
dndActive = true;
|
||||||
|
dndSections = e.detail.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSectionFinalize(e: CustomEvent<{ items: SectionData[] }>) {
|
||||||
|
dndSections = e.detail.items;
|
||||||
|
// Store new order in changeset
|
||||||
|
dndSections.forEach((s, i) => {
|
||||||
|
editMode.reorderSection(s.id, i);
|
||||||
|
});
|
||||||
|
dndActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionsForRender = $derived(editMode.active ? dndSections : displaySections);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{#if sections.length === 0}
|
{#if sectionsForRender.length === 0 && !editMode.active}
|
||||||
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
|
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
|
||||||
<p class="text-muted-foreground">{$t('board.no_sections')}</p>
|
<p class="text-muted-foreground">{$t('board.no_sections')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if editMode.active}
|
||||||
|
<!-- Edit mode: DnD-enabled section list -->
|
||||||
|
{#if sectionsForRender.length > 0}
|
||||||
|
<div
|
||||||
|
use:dndzone={{ items: dndSections, flipDurationMs, dropTargetStyle: {} }}
|
||||||
|
onconsider={handleSectionConsider}
|
||||||
|
onfinalize={handleSectionFinalize}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
{#each dndSections as section (section.id)}
|
||||||
|
<div>
|
||||||
|
<Section {section} {allApps} {boardCardSize} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-xl border-2 border-dashed border-border bg-card/30 p-12 text-center">
|
||||||
|
<p class="text-muted-foreground">{$t('board.no_sections_edit') ?? 'No sections yet. Click "+" below to add one.'}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<AddSectionDivider order={sectionsForRender.length} />
|
||||||
{:else}
|
{:else}
|
||||||
{#each sections as section (section.id)}
|
<!-- View mode: simple list -->
|
||||||
|
{#each sectionsForRender as section (section.id)}
|
||||||
<Section {section} {allApps} {boardCardSize} />
|
<Section {section} {allApps} {boardCardSize} />
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||||
|
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -12,6 +13,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { name, description, icon, boardId, canEdit, onShare }: Props = $props();
|
let { name, description, icon, boardId, canEdit, onShare }: Props = $props();
|
||||||
|
|
||||||
|
function handleEditToggle() {
|
||||||
|
if (editMode.active) {
|
||||||
|
if (editMode.dirty) {
|
||||||
|
// Will be handled by the toolbar's discard flow
|
||||||
|
// For now, just exit
|
||||||
|
editMode.exitEditMode();
|
||||||
|
} else {
|
||||||
|
editMode.exitEditMode();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editMode.enterEditMode(boardId);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex items-start justify-between">
|
<div class="mb-6 flex items-start justify-between">
|
||||||
@@ -51,12 +66,27 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if canEdit}
|
{#if canEdit}
|
||||||
<a
|
<button
|
||||||
href="/boards/{boardId}/edit"
|
type="button"
|
||||||
class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
onclick={handleEditToggle}
|
||||||
|
class="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors {editMode.active
|
||||||
|
? 'bg-primary text-primary-foreground ring-2 ring-primary/30'
|
||||||
|
: 'bg-primary text-primary-foreground hover:bg-primary/90'}"
|
||||||
>
|
>
|
||||||
{$t('board.edit')}
|
{#if editMode.active}
|
||||||
</a>
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
{$t('board.editing') ?? 'Editing'}
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
{$t('board.edit')}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||||
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
|
||||||
|
|
||||||
|
interface BoardData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
description: string | null;
|
||||||
|
themeHue: number | null;
|
||||||
|
themeSaturation: number | null;
|
||||||
|
backgroundType: string | null;
|
||||||
|
cardSize: string | null;
|
||||||
|
wallpaperUrl: string | null;
|
||||||
|
wallpaperBlur: number | null;
|
||||||
|
wallpaperOverlay: number | null;
|
||||||
|
customCss: string | null;
|
||||||
|
isGuestAccessible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
board: BoardData;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { board, onClose }: Props = $props();
|
||||||
|
|
||||||
|
// Local state initialised from board + any pending changeset overrides
|
||||||
|
const pending = $derived(editMode.changeset.boardUpdates);
|
||||||
|
let name = $state((pending.name as string) ?? board.name);
|
||||||
|
let icon = $state((pending.icon as string) ?? board.icon ?? '');
|
||||||
|
let description = $state((pending.description as string) ?? board.description ?? '');
|
||||||
|
let themeHue = $state((pending.themeHue as number) ?? board.themeHue ?? 200);
|
||||||
|
let themeSaturation = $state((pending.themeSaturation as number) ?? board.themeSaturation ?? 50);
|
||||||
|
let backgroundType = $state((pending.backgroundType as string) ?? board.backgroundType ?? 'none');
|
||||||
|
let cardSize = $state((pending.cardSize as string) ?? board.cardSize ?? 'medium');
|
||||||
|
let wallpaperUrl = $state((pending.wallpaperUrl as string) ?? board.wallpaperUrl ?? '');
|
||||||
|
let wallpaperBlur = $state((pending.wallpaperBlur as number) ?? board.wallpaperBlur ?? 0);
|
||||||
|
let wallpaperOverlay = $state((pending.wallpaperOverlay as number) ?? board.wallpaperOverlay ?? 0.3);
|
||||||
|
let customCss = $state((pending.customCss as string) ?? board.customCss ?? '');
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
if (name !== board.name) updates.name = name;
|
||||||
|
if (icon !== (board.icon ?? '')) updates.icon = icon || null;
|
||||||
|
if (description !== (board.description ?? '')) updates.description = description || null;
|
||||||
|
if (themeHue !== (board.themeHue ?? 200)) updates.themeHue = themeHue;
|
||||||
|
if (themeSaturation !== (board.themeSaturation ?? 50)) updates.themeSaturation = themeSaturation;
|
||||||
|
if (backgroundType !== (board.backgroundType ?? 'none')) updates.backgroundType = backgroundType;
|
||||||
|
if (cardSize !== (board.cardSize ?? 'medium')) updates.cardSize = cardSize;
|
||||||
|
if (wallpaperUrl !== (board.wallpaperUrl ?? '')) updates.wallpaperUrl = wallpaperUrl || null;
|
||||||
|
if (wallpaperBlur !== (board.wallpaperBlur ?? 0)) updates.wallpaperBlur = wallpaperBlur;
|
||||||
|
if (wallpaperOverlay !== (board.wallpaperOverlay ?? 0.3)) updates.wallpaperOverlay = wallpaperOverlay;
|
||||||
|
if (customCss !== (board.customCss ?? '')) updates.customCss = customCss || null;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
editMode.updateBoard(updates);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={onClose}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && onClose()}
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Side panel -->
|
||||||
|
<div
|
||||||
|
class="fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-border bg-card shadow-2xl"
|
||||||
|
transition:fly={{ x: 400, duration: 250 }}
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
|
<h2 class="text-lg font-semibold text-foreground">{$t('board.settings') ?? 'Board Settings'}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable content -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- Name -->
|
||||||
|
<div>
|
||||||
|
<label for="bp-name" class="mb-1 block text-sm font-medium text-foreground">{$t('common.name') ?? 'Name'}</label>
|
||||||
|
<input id="bp-name" type="text" bind:value={name}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-foreground">{$t('app.icon') ?? 'Icon'}</label>
|
||||||
|
<IconPickerButton value={icon} onchange={(v) => { icon = v; }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label for="bp-desc" class="mb-1 block text-sm font-medium text-foreground">{$t('common.description') ?? 'Description'}</label>
|
||||||
|
<textarea id="bp-desc" rows="2" bind:value={description}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme Hue -->
|
||||||
|
<div>
|
||||||
|
<label for="bp-hue" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_hue') ?? 'Theme Hue'}</label>
|
||||||
|
<input id="bp-hue" type="range" min="0" max="360" bind:value={themeHue}
|
||||||
|
class="w-full accent-primary" />
|
||||||
|
<span class="text-xs text-muted-foreground">{themeHue}°</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme Saturation -->
|
||||||
|
<div>
|
||||||
|
<label for="bp-sat" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_saturation') ?? 'Saturation'}</label>
|
||||||
|
<input id="bp-sat" type="range" min="0" max="100" bind:value={themeSaturation}
|
||||||
|
class="w-full accent-primary" />
|
||||||
|
<span class="text-xs text-muted-foreground">{themeSaturation}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Background Type -->
|
||||||
|
<div>
|
||||||
|
<label for="bp-bg" class="mb-1 block text-sm font-medium text-foreground">{$t('board.background') ?? 'Background'}</label>
|
||||||
|
<select id="bp-bg" bind:value={backgroundType}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30">
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="mesh">Mesh Gradient</option>
|
||||||
|
<option value="particles">Particles</option>
|
||||||
|
<option value="aurora">Aurora</option>
|
||||||
|
<option value="wallpaper">Wallpaper</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wallpaper settings (conditional) -->
|
||||||
|
{#if backgroundType === 'wallpaper'}
|
||||||
|
<div class="space-y-3 rounded-lg border border-border bg-background/50 p-3">
|
||||||
|
<div>
|
||||||
|
<label for="bp-wp-url" class="mb-1 block text-sm font-medium text-foreground">Wallpaper URL</label>
|
||||||
|
<input id="bp-wp-url" type="text" bind:value={wallpaperUrl} placeholder="https://..."
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bp-wp-blur" class="mb-1 block text-sm font-medium text-foreground">Blur ({wallpaperBlur}px)</label>
|
||||||
|
<input id="bp-wp-blur" type="range" min="0" max="20" bind:value={wallpaperBlur} class="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bp-wp-overlay" class="mb-1 block text-sm font-medium text-foreground">Overlay ({Math.round(wallpaperOverlay * 100)}%)</label>
|
||||||
|
<input id="bp-wp-overlay" type="range" min="0" max="1" step="0.05" bind:value={wallpaperOverlay} class="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Card Size -->
|
||||||
|
<div>
|
||||||
|
<label for="bp-cardsize" class="mb-1 block text-sm font-medium text-foreground">{$t('board.card_size') ?? 'Card Size'}</label>
|
||||||
|
<select id="bp-cardsize" bind:value={cardSize}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30">
|
||||||
|
<option value="compact">Compact</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="large">Large</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<div>
|
||||||
|
<label for="bp-css" class="mb-1 block text-sm font-medium text-foreground">{$t('board.custom_css') ?? 'Custom CSS'}</label>
|
||||||
|
<textarea id="bp-css" rows="4" bind:value={customCss} placeholder={'.board { ... }'}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
{$t('common.cancel') ?? 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleSave}
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{$t('common.apply') ?? 'Apply'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||||
|
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onExit: () => void;
|
||||||
|
onAddSection: () => void;
|
||||||
|
onBoardSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSave, onDiscard, onExit, onAddSection, onBoardSettings }: Props = $props();
|
||||||
|
|
||||||
|
let showDiscardConfirm = $state(false);
|
||||||
|
|
||||||
|
function handleExit() {
|
||||||
|
if (editMode.dirty) {
|
||||||
|
showDiscardConfirm = true;
|
||||||
|
} else {
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
showDiscardConfirm = false;
|
||||||
|
onDiscard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscardCancel() {
|
||||||
|
showDiscardConfirm = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if editMode.active}
|
||||||
|
<div
|
||||||
|
class="fixed bottom-6 left-1/2 z-40 -translate-x-1/2"
|
||||||
|
transition:fly={{ y: 60, duration: 250 }}
|
||||||
|
>
|
||||||
|
<!-- Toolbar pill -->
|
||||||
|
<div class="flex items-center gap-1 rounded-2xl border border-border bg-card/95 px-2 py-1.5 shadow-xl backdrop-blur-sm">
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onSave}
|
||||||
|
disabled={!editMode.dirty}
|
||||||
|
class="relative flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors
|
||||||
|
{editMode.dirty
|
||||||
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
: 'text-muted-foreground opacity-50 cursor-not-allowed'}"
|
||||||
|
>
|
||||||
|
<!-- Check icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||||
|
<polyline points="17 21 17 13 7 13 7 21" />
|
||||||
|
<polyline points="7 3 7 8 15 8" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">{$t('common.save') ?? 'Save'}</span>
|
||||||
|
{#if editMode.changeCount > 0}
|
||||||
|
<span class="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
|
||||||
|
{editMode.changeCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mx-0.5 h-6 w-px bg-border"></div>
|
||||||
|
|
||||||
|
<!-- Add Section -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onAddSection}
|
||||||
|
class="flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
title={$t('board.add_section') ?? 'Add Section'}
|
||||||
|
>
|
||||||
|
<!-- Plus icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">{$t('board.add_section') ?? 'Section'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Board Settings -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onBoardSettings}
|
||||||
|
class="flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
title={$t('board.settings') ?? 'Board Settings'}
|
||||||
|
>
|
||||||
|
<!-- Settings icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">{$t('board.settings') ?? 'Settings'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mx-0.5 h-6 w-px bg-border"></div>
|
||||||
|
|
||||||
|
<!-- Discard -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleExit}
|
||||||
|
disabled={!editMode.dirty}
|
||||||
|
class="flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm transition-colors
|
||||||
|
{editMode.dirty
|
||||||
|
? 'text-destructive hover:bg-destructive/10'
|
||||||
|
: 'text-muted-foreground opacity-50 cursor-not-allowed'}"
|
||||||
|
title={$t('common.discard') ?? 'Discard Changes'}
|
||||||
|
>
|
||||||
|
<!-- Undo icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 7v6h6" />
|
||||||
|
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">{$t('common.discard') ?? 'Discard'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Exit Edit Mode -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleExit}
|
||||||
|
class="flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
title={$t('board.exit_edit') ?? 'Exit Edit Mode'}
|
||||||
|
>
|
||||||
|
<!-- X icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">{$t('board.exit_edit') ?? 'Done'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showDiscardConfirm}
|
||||||
|
<ConfirmDialog
|
||||||
|
title={$t('board.discard_title') ?? 'Discard Changes'}
|
||||||
|
message={$t('board.discard_confirm') ?? 'Are you sure you want to discard all unsaved changes?'}
|
||||||
|
confirmLabel={$t('common.discard') ?? 'Discard'}
|
||||||
|
onConfirm={handleDiscard}
|
||||||
|
onCancel={handleDiscardCancel}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
import SectionHeader from './SectionHeader.svelte';
|
import SectionHeader from './SectionHeader.svelte';
|
||||||
import SectionCollapsible from './SectionCollapsible.svelte';
|
import SectionCollapsible from './SectionCollapsible.svelte';
|
||||||
import WidgetGrid from '$lib/components/widget/WidgetGrid.svelte';
|
import WidgetGrid from '$lib/components/widget/WidgetGrid.svelte';
|
||||||
|
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||||
|
import type { CardSize } from '$lib/utils/constants.js';
|
||||||
|
|
||||||
interface WidgetData {
|
interface WidgetData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,8 +22,6 @@
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { CardSize } from '$lib/utils/constants.js';
|
|
||||||
|
|
||||||
interface SectionData {
|
interface SectionData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -58,17 +58,24 @@
|
|||||||
let expanded = $state(section.isExpandedByDefault);
|
let expanded = $state(section.isExpandedByDefault);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-xl border border-border bg-card/30 shadow-sm">
|
<div class="rounded-xl border border-border bg-card/30 shadow-sm {editMode.active ? 'ring-1 ring-primary/10' : ''}">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
|
sectionId={section.id}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
icon={section.icon}
|
icon={section.icon}
|
||||||
{expanded}
|
{expanded}
|
||||||
onToggle={() => (expanded = !expanded)}
|
onToggle={() => (expanded = !expanded)}
|
||||||
|
widgetCount={section.widgets.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionCollapsible {expanded}>
|
<SectionCollapsible {expanded}>
|
||||||
<div class="px-4 pb-4">
|
<div class="px-4 pb-4">
|
||||||
<WidgetGrid widgets={section.widgets} {allApps} cardSize={effectiveCardSize} />
|
<WidgetGrid
|
||||||
|
widgets={section.widgets}
|
||||||
|
sectionId={section.id}
|
||||||
|
{allApps}
|
||||||
|
cardSize={effectiveCardSize}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SectionCollapsible>
|
</SectionCollapsible>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,38 +1,183 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||||
|
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||||
|
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
sectionId: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
widgetCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { title, icon, expanded, onToggle }: Props = $props();
|
let { sectionId, title, icon, expanded, onToggle, widgetCount = 0 }: Props = $props();
|
||||||
|
|
||||||
|
let editingTitle = $state(false);
|
||||||
|
let editTitle = $state('');
|
||||||
|
let editIcon = $state('');
|
||||||
|
let showDeleteConfirm = $state(false);
|
||||||
|
let titleInput: HTMLInputElement | undefined = $state();
|
||||||
|
let editContainerEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
function getTitle() { return title; }
|
||||||
|
function getIcon() { return icon; }
|
||||||
|
|
||||||
|
function startEditTitle() {
|
||||||
|
editTitle = getTitle();
|
||||||
|
editIcon = getIcon() ?? '';
|
||||||
|
editingTitle = true;
|
||||||
|
// Focus after render
|
||||||
|
setTimeout(() => titleInput?.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTitleEdit() {
|
||||||
|
const trimmed = editTitle.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
if (trimmed !== getTitle()) updates.title = trimmed;
|
||||||
|
const newIcon = editIcon.trim() || null;
|
||||||
|
if (newIcon !== getIcon()) updates.icon = newIcon;
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
editMode.updateSection(sectionId, updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editingTitle = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditBlur(e: FocusEvent) {
|
||||||
|
// Don't close if focus moved to another input within the edit container
|
||||||
|
const related = e.relatedTarget as HTMLElement | null;
|
||||||
|
if (related && editContainerEl?.contains(related)) return;
|
||||||
|
saveTitleEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelTitleEdit() {
|
||||||
|
editingTitle = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveTitleEdit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
cancelTitleEdit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
editMode.deleteSection(sectionId);
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<div class="flex w-full items-center gap-2 rounded-t-xl px-4 py-3">
|
||||||
type="button"
|
{#if editMode.active}
|
||||||
onclick={onToggle}
|
<!-- Edit mode: drag handle -->
|
||||||
class="flex w-full items-center gap-2 rounded-t-xl px-4 py-3 text-left transition-colors hover:bg-accent/30"
|
<div class="cursor-grab text-muted-foreground" title="Drag to reorder">
|
||||||
>
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<svg
|
<circle cx="9" cy="5" r="1" /><circle cx="15" cy="5" r="1" />
|
||||||
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
|
<circle cx="9" cy="12" r="1" /><circle cx="15" cy="12" r="1" />
|
||||||
class:rotate-90={expanded}
|
<circle cx="9" cy="19" r="1" /><circle cx="15" cy="19" r="1" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</svg>
|
||||||
viewBox="0 0 24 24"
|
</div>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="9 18 15 12 9 6"></polyline>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{#if icon}
|
|
||||||
<DynamicIcon name={icon} size={18} />
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="font-medium text-foreground">{title}</span>
|
<!-- Expand/collapse toggle -->
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onToggle}
|
||||||
|
class="transition-colors hover:bg-accent/30 rounded p-0.5"
|
||||||
|
aria-label={expanded ? 'Collapse section' : 'Expand section'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
|
||||||
|
class:rotate-90={expanded}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if editMode.active && editingTitle}
|
||||||
|
<!-- Inline title editor -->
|
||||||
|
<div class="flex flex-1 items-center gap-1" bind:this={editContainerEl}>
|
||||||
|
<input
|
||||||
|
bind:this={titleInput}
|
||||||
|
type="text"
|
||||||
|
bind:value={editTitle}
|
||||||
|
onkeydown={handleTitleKeydown}
|
||||||
|
onblur={handleEditBlur}
|
||||||
|
class="flex-1 rounded border border-input bg-background px-2 py-0.5 text-sm font-medium text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
|
||||||
|
/>
|
||||||
|
<IconPickerButton
|
||||||
|
value={editIcon}
|
||||||
|
onchange={(v) => { editIcon = v; }}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Display title -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={editMode.active ? startEditTitle : onToggle}
|
||||||
|
class="flex flex-1 items-center gap-2 text-left transition-colors hover:bg-accent/30 rounded px-1"
|
||||||
|
>
|
||||||
|
{#if icon}
|
||||||
|
<DynamicIcon name={icon} size={18} />
|
||||||
|
{/if}
|
||||||
|
<span class="font-medium text-foreground">{title}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editMode.active}
|
||||||
|
<!-- Edit mode actions -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#if !editingTitle}
|
||||||
|
<!-- Edit title button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={startEditTitle}
|
||||||
|
class="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
title={$t('common.edit') ?? 'Edit'}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete section -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { showDeleteConfirm = true; }}
|
||||||
|
class="rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
title={$t('common.delete') ?? 'Delete'}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showDeleteConfirm}
|
||||||
|
<ConfirmDialog
|
||||||
|
title={$t('board.delete_section_title') ?? 'Delete Section'}
|
||||||
|
message={($t('board.delete_section_confirm') ?? 'Are you sure you want to delete this section and its {count} widgets?').replace('{count}', String(widgetCount))}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => { showDeleteConfirm = false; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
value: string;
|
||||||
|
suggestions: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
value = $bindable(),
|
||||||
|
suggestions,
|
||||||
|
placeholder = '',
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let highlightIdx = $state(-1);
|
||||||
|
let inputEl: HTMLInputElement | undefined = $state();
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const q = value.trim().toLowerCase();
|
||||||
|
if (!q) return suggestions;
|
||||||
|
return suggestions.filter((s) => s.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
open = true;
|
||||||
|
highlightIdx = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(item: string) {
|
||||||
|
value = item;
|
||||||
|
open = false;
|
||||||
|
inputEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!open || filtered.length === 0) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
open = true;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = (highlightIdx + 1) % filtered.length;
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = highlightIdx <= 0 ? filtered.length - 1 : highlightIdx - 1;
|
||||||
|
} else if (e.key === 'Enter' && highlightIdx >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectItem(filtered[highlightIdx]);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerEl && !containerEl.contains(e.target as Node)) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleClickOutside} />
|
||||||
|
|
||||||
|
<div class="relative" bind:this={containerEl}>
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
{name}
|
||||||
|
type="text"
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value
|
||||||
|
oninput={handleInput}
|
||||||
|
onfocus={handleFocus}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
{placeholder}
|
||||||
|
class={className}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if open && filtered.length > 0}
|
||||||
|
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
|
||||||
|
{#each filtered as item, i}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectItem(item)}
|
||||||
|
class="flex w-full items-center px-3 py-1.5 text-left text-sm text-foreground transition-colors
|
||||||
|
{i === highlightIdx ? 'bg-accent' : 'hover:bg-accent/50'}"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fade, scale } from 'svelte/transition';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel,
|
||||||
|
destructive = true,
|
||||||
|
onConfirm,
|
||||||
|
onCancel
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/30 backdrop-blur-sm"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={onCancel}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && onCancel()}
|
||||||
|
transition:fade={{ duration: 120 }}
|
||||||
|
>
|
||||||
|
<!-- Dialog -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="mx-4 w-full max-w-sm rounded-2xl border border-border bg-card p-6 shadow-2xl"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
transition:scale={{ start: 0.95, duration: 150 }}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-labelledby="confirm-dialog-title"
|
||||||
|
aria-describedby="confirm-dialog-message"
|
||||||
|
>
|
||||||
|
<h2 id="confirm-dialog-title" class="mb-2 text-base font-semibold text-foreground">{title}</h2>
|
||||||
|
<p id="confirm-dialog-message" class="mb-5 text-sm text-muted-foreground">{message}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onCancel}
|
||||||
|
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
{cancelLabel ?? ($t('common.cancel') ?? 'Cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onConfirm}
|
||||||
|
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors
|
||||||
|
{destructive
|
||||||
|
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||||
|
: 'bg-primary text-primary-foreground hover:bg-primary/90'}"
|
||||||
|
>
|
||||||
|
{confirmLabel ?? ($t('common.delete') ?? 'Delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import DynamicIcon from './DynamicIcon.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onchange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value = $bindable(), onchange, placeholder, size = 'md' }: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let query = $state('');
|
||||||
|
let searchInput: HTMLInputElement | undefined = $state();
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
// Common lucide icon names for quick selection
|
||||||
|
const commonIcons = [
|
||||||
|
'home', 'star', 'heart', 'bookmark', 'folder', 'file', 'settings', 'search',
|
||||||
|
'user', 'users', 'mail', 'phone', 'calendar', 'clock', 'camera', 'image',
|
||||||
|
'music', 'video', 'monitor', 'smartphone', 'tablet', 'laptop', 'server', 'database',
|
||||||
|
'cloud', 'globe', 'map', 'compass', 'navigation', 'flag', 'tag', 'hash',
|
||||||
|
'lock', 'unlock', 'shield', 'key', 'eye', 'bell', 'megaphone', 'message-circle',
|
||||||
|
'terminal', 'code', 'git-branch', 'git-commit', 'cpu', 'hard-drive', 'wifi', 'bluetooth',
|
||||||
|
'battery', 'zap', 'sun', 'moon', 'thermometer', 'droplet', 'wind', 'umbrella',
|
||||||
|
'shopping-cart', 'credit-card', 'dollar-sign', 'trending-up', 'bar-chart', 'pie-chart',
|
||||||
|
'activity', 'award', 'target', 'crosshair', 'layers', 'layout', 'grid', 'list',
|
||||||
|
'package', 'box', 'archive', 'trash', 'download', 'upload', 'link', 'external-link',
|
||||||
|
'check', 'x', 'plus', 'minus', 'alert-circle', 'info', 'help-circle', 'alert-triangle',
|
||||||
|
'play', 'pause', 'skip-forward', 'volume-2', 'headphones', 'radio', 'tv', 'film',
|
||||||
|
'book', 'book-open', 'clipboard', 'pen-tool', 'edit', 'scissors', 'copy', 'save',
|
||||||
|
'printer', 'share', 'send', 'inbox', 'paperclip', 'at-sign', 'rss', 'wifi',
|
||||||
|
'rocket', 'flame', 'sparkles', 'wand', 'palette', 'brush', 'pipette', 'ruler',
|
||||||
|
'truck', 'car', 'bike', 'plane', 'ship', 'train', 'bus', 'building',
|
||||||
|
'gamepad', 'puzzle', 'dice-1', 'trophy', 'medal', 'crown', 'gem', 'gift'
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredIcons = $derived(
|
||||||
|
query.trim()
|
||||||
|
? commonIcons.filter((name) => name.includes(query.toLowerCase()))
|
||||||
|
: commonIcons
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleOpen() {
|
||||||
|
open = !open;
|
||||||
|
if (open) {
|
||||||
|
query = '';
|
||||||
|
tick().then(() => searchInput?.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectIcon(name: string) {
|
||||||
|
value = name;
|
||||||
|
onchange(name);
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearIcon() {
|
||||||
|
value = '';
|
||||||
|
onchange('');
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (open && containerEl && !containerEl.contains(e.target as Node)) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="relative" bind:this={containerEl}>
|
||||||
|
<!-- Trigger button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleOpen}
|
||||||
|
class="flex items-center gap-1.5 rounded-lg border border-input bg-background transition-colors hover:bg-accent
|
||||||
|
{size === 'sm' ? 'px-2 py-1' : 'px-3 py-2'}"
|
||||||
|
title={$t('app.icon') ?? 'Select icon'}
|
||||||
|
>
|
||||||
|
{#if value}
|
||||||
|
<DynamicIcon name={value} size={size === 'sm' ? 14 : 18} />
|
||||||
|
<span class="text-xs text-muted-foreground">{value}</span>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size === 'sm' ? 14 : 18} height={size === 'sm' ? 14 : 18} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
|
||||||
|
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs text-muted-foreground">{placeholder ?? ($t('app.icon') ?? 'Icon')}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Popover rendered as fixed overlay to avoid layout overflow -->
|
||||||
|
{#if open}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50"
|
||||||
|
onclick={(e) => { if (e.target === e.currentTarget) open = false; }}
|
||||||
|
>
|
||||||
|
<div class="fixed left-1/2 top-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-3 shadow-2xl">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="relative mb-2">
|
||||||
|
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:this={searchInput}
|
||||||
|
type="text"
|
||||||
|
bind:value={query}
|
||||||
|
placeholder={$t('common.search') ?? 'Search icons...'}
|
||||||
|
class="w-full rounded-lg border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clear button -->
|
||||||
|
{#if value}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clearIcon}
|
||||||
|
class="mb-2 flex w-full items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||||
|
{$t('common.clear') ?? 'Clear icon'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Icon grid -->
|
||||||
|
<div class="max-h-48 overflow-y-auto">
|
||||||
|
{#if filteredIcons.length === 0}
|
||||||
|
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No matching icons'}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-8 gap-0.5">
|
||||||
|
{#each filteredIcons as iconName}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectIcon(iconName)}
|
||||||
|
class="flex items-center justify-center rounded-lg p-1.5 transition-colors hover:bg-accent
|
||||||
|
{value === iconName ? 'bg-primary/10 text-primary ring-1 ring-primary/30' : 'text-foreground'}"
|
||||||
|
title={iconName}
|
||||||
|
>
|
||||||
|
<DynamicIcon name={iconName} size={16} />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual input fallback -->
|
||||||
|
<div class="mt-2 border-t border-border pt-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
oninput={(e) => { const v = (e.target as HTMLInputElement).value; value = v; onchange(v); }}
|
||||||
|
placeholder={$t('app.icon_manual') ?? 'Or type icon name...'}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
value: string;
|
||||||
|
suggestions: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
value = $bindable(),
|
||||||
|
suggestions,
|
||||||
|
placeholder = '',
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let highlightIdx = $state(-1);
|
||||||
|
let inputEl: HTMLInputElement | undefined = $state();
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
// Parse comma-separated tags
|
||||||
|
const tags = $derived(value.split(',').map((t) => t.trim()).filter(Boolean));
|
||||||
|
|
||||||
|
// Get the current partial tag being typed (after last comma)
|
||||||
|
const currentPartial = $derived.by(() => {
|
||||||
|
const parts = value.split(',');
|
||||||
|
return parts[parts.length - 1]?.trim() ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter suggestions: exclude already-used tags, match partial
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const used = new Set(tags.map((t) => t.toLowerCase()));
|
||||||
|
const q = currentPartial.toLowerCase();
|
||||||
|
return suggestions
|
||||||
|
.filter((s) => !used.has(s.toLowerCase()))
|
||||||
|
.filter((s) => !q || s.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
open = true;
|
||||||
|
highlightIdx = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(item: string) {
|
||||||
|
// Replace the current partial with the selected tag
|
||||||
|
const parts = value.split(',').map((p) => p.trim());
|
||||||
|
parts[parts.length - 1] = item;
|
||||||
|
value = parts.join(',') + ',';
|
||||||
|
open = false;
|
||||||
|
inputEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tag: string) {
|
||||||
|
const newTags = tags.filter((t) => t !== tag);
|
||||||
|
value = newTags.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!open || filtered.length === 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
open = true;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = (highlightIdx + 1) % filtered.length;
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = highlightIdx <= 0 ? filtered.length - 1 : highlightIdx - 1;
|
||||||
|
} else if (e.key === 'Enter' && highlightIdx >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectItem(filtered[highlightIdx]);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerEl && !containerEl.contains(e.target as Node)) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleClickOutside} />
|
||||||
|
|
||||||
|
<div class="relative" bind:this={containerEl}>
|
||||||
|
<!-- Tag pills -->
|
||||||
|
{#if tags.length > 0}
|
||||||
|
<div class="mb-1.5 flex flex-wrap gap-1">
|
||||||
|
{#each tags as tag}
|
||||||
|
<span class="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeTag(tag)}
|
||||||
|
class="text-primary/60 hover:text-primary"
|
||||||
|
aria-label="Remove {tag}"
|
||||||
|
>×</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
{name}
|
||||||
|
type="text"
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value
|
||||||
|
oninput={handleInput}
|
||||||
|
onfocus={handleFocus}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
{placeholder}
|
||||||
|
class={className}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if open && filtered.length > 0}
|
||||||
|
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
|
||||||
|
{#each filtered as item, i}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectItem(item)}
|
||||||
|
class="flex w-full items-center px-3 py-1.5 text-left text-sm text-foreground transition-colors
|
||||||
|
{i === highlightIdx ? 'bg-accent' : 'hover:bg-accent/50'}"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
interface NoteConfig {
|
interface NoteConfig {
|
||||||
content: string;
|
content: string;
|
||||||
format: 'markdown' | 'text';
|
format: 'markdown' | 'text' | 'html';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -28,6 +28,9 @@
|
|||||||
.replace(/\n/g, '<br>')
|
.replace(/\n/g, '<br>')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (config.format === 'html') {
|
||||||
|
return DOMPurify.sanitize(config.content);
|
||||||
|
}
|
||||||
const raw = marked.parse(config.content, { async: false }) as string;
|
const raw = marked.parse(config.content, { async: false }) as string;
|
||||||
return DOMPurify.sanitize(raw);
|
return DOMPurify.sanitize(raw);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||||
|
|
||||||
|
interface AppInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string | null;
|
||||||
|
iconType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widgetType: string;
|
||||||
|
initialConfig?: Record<string, unknown>;
|
||||||
|
apps?: AppInfo[];
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
onSave: (config: Record<string, unknown>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { widgetType, initialConfig = {}, apps = [], mode, onSave, onCancel }: Props = $props();
|
||||||
|
|
||||||
|
// App search
|
||||||
|
let appSearchQuery = $state('');
|
||||||
|
const filteredApps = $derived(
|
||||||
|
appSearchQuery.trim()
|
||||||
|
? apps.filter((a) => a.name.toLowerCase().includes(appSearchQuery.toLowerCase()))
|
||||||
|
: apps
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Form fields initialised from config --
|
||||||
|
// App
|
||||||
|
let appId = $state((initialConfig.appId as string) ?? '');
|
||||||
|
|
||||||
|
// Bookmark
|
||||||
|
let bookmarkUrl = $state((initialConfig.url as string) ?? '');
|
||||||
|
let bookmarkLabel = $state((initialConfig.label as string) ?? '');
|
||||||
|
let bookmarkIcon = $state((initialConfig.icon as string) ?? '');
|
||||||
|
let bookmarkDescription = $state((initialConfig.description as string) ?? '');
|
||||||
|
|
||||||
|
// Note
|
||||||
|
let noteContent = $state((initialConfig.content as string) ?? '');
|
||||||
|
let noteFormat = $state<'markdown' | 'text' | 'html'>((initialConfig.format as 'markdown' | 'text' | 'html') ?? 'markdown');
|
||||||
|
|
||||||
|
// Embed
|
||||||
|
let embedUrl = $state((initialConfig.url as string) ?? '');
|
||||||
|
let embedHeight = $state((initialConfig.height as number) ?? 300);
|
||||||
|
let embedSandbox = $state((initialConfig.sandbox as string) ?? '');
|
||||||
|
|
||||||
|
// Status
|
||||||
|
let statusLabel = $state((initialConfig.label as string) ?? '');
|
||||||
|
let statusAppIds = $state<string[]>((initialConfig.appIds as string[]) ?? []);
|
||||||
|
|
||||||
|
// Clock
|
||||||
|
let clockTimezone = $state((initialConfig.timezone as string) ?? '');
|
||||||
|
let clockStyle = $state<string>((initialConfig.clockStyle as string) ?? 'digital');
|
||||||
|
let clockShowWeather = $state((initialConfig.showWeather as boolean) ?? false);
|
||||||
|
let clockLatitude = $state(String(initialConfig.latitude ?? ''));
|
||||||
|
let clockLongitude = $state(String(initialConfig.longitude ?? ''));
|
||||||
|
|
||||||
|
// System Stats
|
||||||
|
let sysStatsSourceUrl = $state((initialConfig.sourceUrl as string) ?? '');
|
||||||
|
let sysStatsSourceType = $state<string>((initialConfig.sourceType as string) ?? 'custom');
|
||||||
|
let sysStatsRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 30);
|
||||||
|
|
||||||
|
// RSS
|
||||||
|
let rssFeedUrl = $state((initialConfig.feedUrl as string) ?? '');
|
||||||
|
let rssMaxItems = $state((initialConfig.maxItems as number) ?? 10);
|
||||||
|
let rssShowSummary = $state((initialConfig.showSummary as boolean) ?? true);
|
||||||
|
|
||||||
|
// Calendar
|
||||||
|
let calendarUrlsRaw = $state((initialConfig.icalUrls as Array<{ url: string; color: string; label: string }>) ?? [{ url: '', color: '#6366f1', label: '' }]);
|
||||||
|
let calendarDaysAhead = $state((initialConfig.daysAhead as number) ?? 7);
|
||||||
|
|
||||||
|
// Markdown
|
||||||
|
let markdownContent = $state((initialConfig.content as string) ?? '');
|
||||||
|
|
||||||
|
// Metric
|
||||||
|
let metricLabel = $state((initialConfig.label as string) ?? '');
|
||||||
|
let metricSource = $state<string>((initialConfig.source as string) ?? 'static');
|
||||||
|
let metricValue = $state(String(initialConfig.value ?? ''));
|
||||||
|
let metricUrl = $state((initialConfig.url as string) ?? '');
|
||||||
|
let metricJsonPath = $state((initialConfig.jsonPath as string) ?? '');
|
||||||
|
let metricQuery = $state((initialConfig.query as string) ?? '');
|
||||||
|
let metricUnit = $state((initialConfig.unit as string) ?? '');
|
||||||
|
let metricRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 60);
|
||||||
|
|
||||||
|
// Link Group
|
||||||
|
let linkGroupLinks = $state<Array<{ label: string; url: string; icon: string }>>((initialConfig.links as Array<{ label: string; url: string; icon: string }>) ?? [{ label: '', url: '', icon: '' }]);
|
||||||
|
let linkGroupCollapsible = $state((initialConfig.collapsible as boolean) ?? false);
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
let cameraStreamUrl = $state((initialConfig.streamUrl as string) ?? '');
|
||||||
|
let cameraType = $state<string>((initialConfig.type as string) ?? 'image');
|
||||||
|
let cameraRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 10);
|
||||||
|
let cameraAspectRatio = $state((initialConfig.aspectRatio as string) ?? '16/9');
|
||||||
|
|
||||||
|
// Integration
|
||||||
|
let integrationAppId = $state((initialConfig.appId as string) ?? '');
|
||||||
|
let integrationEndpointId = $state((initialConfig.endpointId as string) ?? '');
|
||||||
|
let integrationRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 60);
|
||||||
|
|
||||||
|
function buildConfig(): Record<string, unknown> {
|
||||||
|
switch (widgetType) {
|
||||||
|
case 'app':
|
||||||
|
return { appId };
|
||||||
|
case 'bookmark':
|
||||||
|
return { url: bookmarkUrl, label: bookmarkLabel, icon: bookmarkIcon || undefined, description: bookmarkDescription || undefined };
|
||||||
|
case 'note':
|
||||||
|
return { content: noteContent, format: noteFormat };
|
||||||
|
case 'embed':
|
||||||
|
return { url: embedUrl, height: embedHeight, sandbox: embedSandbox || undefined };
|
||||||
|
case 'status':
|
||||||
|
return { appIds: statusAppIds, label: statusLabel || undefined };
|
||||||
|
case 'clock':
|
||||||
|
return { timezone: clockTimezone || undefined, clockStyle, showWeather: clockShowWeather, latitude: clockLatitude ? Number(clockLatitude) : undefined, longitude: clockLongitude ? Number(clockLongitude) : undefined };
|
||||||
|
case 'system_stats':
|
||||||
|
return { sourceUrl: sysStatsSourceUrl, sourceType: sysStatsSourceType, metrics: ['cpu', 'ram', 'disk'], refreshInterval: sysStatsRefreshInterval };
|
||||||
|
case 'rss':
|
||||||
|
return { feedUrl: rssFeedUrl, maxItems: rssMaxItems, showSummary: rssShowSummary };
|
||||||
|
case 'calendar':
|
||||||
|
return { icalUrls: calendarUrlsRaw.filter((u) => u.url.trim()), daysAhead: calendarDaysAhead };
|
||||||
|
case 'markdown':
|
||||||
|
return { content: markdownContent };
|
||||||
|
case 'metric':
|
||||||
|
return { label: metricLabel, source: metricSource, value: metricValue || undefined, url: metricUrl || undefined, jsonPath: metricJsonPath || undefined, query: metricQuery || undefined, unit: metricUnit || undefined, refreshInterval: metricRefreshInterval };
|
||||||
|
case 'link_group':
|
||||||
|
return { links: linkGroupLinks.filter((l) => l.url.trim()), collapsible: linkGroupCollapsible };
|
||||||
|
case 'camera':
|
||||||
|
return { streamUrl: cameraStreamUrl, type: cameraType, refreshInterval: cameraRefreshInterval, aspectRatio: cameraAspectRatio };
|
||||||
|
case 'integration':
|
||||||
|
return { appId: integrationAppId, endpointId: integrationEndpointId, refreshInterval: integrationRefreshInterval };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
onSave(buildConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLinkGroupLink() {
|
||||||
|
linkGroupLinks = [...linkGroupLinks, { label: '', url: '', icon: '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLinkGroupLink(index: number) {
|
||||||
|
linkGroupLinks = linkGroupLinks.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCalendarUrl() {
|
||||||
|
calendarUrlsRaw = [...calendarUrlsRaw, { url: '', color: '#6366f1', label: '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCalendarUrl(index: number) {
|
||||||
|
calendarUrlsRaw = calendarUrlsRaw.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for input styling
|
||||||
|
const inputClass = 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
|
||||||
|
const labelClass = 'mb-1 block text-sm font-medium text-foreground';
|
||||||
|
|
||||||
|
let firstInput: HTMLElement | undefined = $state();
|
||||||
|
$effect(() => { tick().then(() => firstInput?.focus()); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-border bg-card p-4 shadow-lg"
|
||||||
|
transition:fade={{ duration: 100 }}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground">
|
||||||
|
{mode === 'create' ? ($t('widget.add_widget') ?? 'Add Widget') : ($t('widget.edit_widget') ?? 'Edit Widget')}
|
||||||
|
<span class="ml-1 text-xs font-normal text-muted-foreground">({widgetType})</span>
|
||||||
|
</h3>
|
||||||
|
<button type="button" onclick={onCancel} class="rounded p-0.5 text-muted-foreground hover:text-foreground">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-80 space-y-3 overflow-y-auto">
|
||||||
|
{#if widgetType === 'app'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="relative mb-2">
|
||||||
|
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={appSearchQuery}
|
||||||
|
bind:this={firstInput}
|
||||||
|
placeholder={$t('common.search') ?? 'Search apps...'}
|
||||||
|
class="w-full rounded-lg border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- App grid -->
|
||||||
|
<div class="max-h-48 overflow-y-auto rounded-lg border border-input bg-background p-1">
|
||||||
|
{#if filteredApps.length === 0}
|
||||||
|
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No apps found'}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-2 gap-1">
|
||||||
|
{#each filteredApps as app}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { appId = app.id; }}
|
||||||
|
class="flex items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors
|
||||||
|
{appId === app.id
|
||||||
|
? 'bg-primary/10 text-primary ring-1 ring-primary/30'
|
||||||
|
: 'text-foreground hover:bg-accent'}"
|
||||||
|
>
|
||||||
|
{#if app.icon && app.iconType === 'lucide'}
|
||||||
|
<DynamicIcon name={app.icon} size={18} />
|
||||||
|
{:else if app.icon && app.iconType === 'url'}
|
||||||
|
<img src={app.icon} alt="" class="h-[18px] w-[18px] rounded object-contain" />
|
||||||
|
{:else if app.icon && app.iconType === 'simple'}
|
||||||
|
<img src="https://cdn.simpleicons.org/{app.icon.toLowerCase()}" alt="" class="h-[18px] w-[18px]" />
|
||||||
|
{:else if app.icon && app.iconType === 'emoji'}
|
||||||
|
<span class="text-base leading-none">{app.icon}</span>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="truncate">{app.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if widgetType === 'bookmark'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>URL</label>
|
||||||
|
<input type="url" bind:value={bookmarkUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
|
||||||
|
<input type="text" bind:value={bookmarkLabel} class={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('app.icon') ?? 'Icon'}</label>
|
||||||
|
<input type="text" bind:value={bookmarkIcon} placeholder="e.g. globe" class={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('common.description') ?? 'Description'}</label>
|
||||||
|
<input type="text" bind:value={bookmarkDescription} class={inputClass} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if widgetType === 'note'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('widget.content') ?? 'Content'}</label>
|
||||||
|
<textarea bind:value={noteContent} rows="4" class={inputClass} bind:this={firstInput}></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('widget.format') ?? 'Format'}</label>
|
||||||
|
<select bind:value={noteFormat} class={inputClass}>
|
||||||
|
<option value="markdown">Markdown</option>
|
||||||
|
<option value="text">Plain Text</option>
|
||||||
|
<option value="html">HTML</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if widgetType === 'embed'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>URL</label>
|
||||||
|
<input type="url" bind:value={embedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)</label>
|
||||||
|
<input type="range" min="100" max="800" bind:value={embedHeight} class="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Sandbox</label>
|
||||||
|
<input type="text" bind:value={embedSandbox} placeholder="allow-scripts allow-same-origin" class={inputClass} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if widgetType === 'status'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
|
||||||
|
<input type="text" bind:value={statusLabel} class={inputClass} bind:this={firstInput} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('widget.apps') ?? 'Apps'}</label>
|
||||||
|
<div class="space-y-1 rounded-lg border border-input bg-background p-2">
|
||||||
|
{#each apps as app}
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={statusAppIds.includes(app.id)}
|
||||||
|
onchange={() => {
|
||||||
|
if (statusAppIds.includes(app.id)) {
|
||||||
|
statusAppIds = statusAppIds.filter((id) => id !== app.id);
|
||||||
|
} else {
|
||||||
|
statusAppIds = [...statusAppIds, app.id];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="h-3.5 w-3.5 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
{app.name}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if widgetType === 'clock'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('widget.timezone') ?? 'Timezone'}</label>
|
||||||
|
<input type="text" bind:value={clockTimezone} placeholder="America/New_York" class={inputClass} bind:this={firstInput} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('widget.style') ?? 'Style'}</label>
|
||||||
|
<select bind:value={clockStyle} class={inputClass}>
|
||||||
|
<option value="digital">Digital</option>
|
||||||
|
<option value="analog">Analog</option>
|
||||||
|
<option value="24h">24h</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input type="checkbox" bind:checked={clockShowWeather} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||||
|
{$t('widget.show_weather') ?? 'Show Weather'}
|
||||||
|
</label>
|
||||||
|
{#if clockShowWeather}
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Latitude</label>
|
||||||
|
<input type="text" bind:value={clockLatitude} class={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Longitude</label>
|
||||||
|
<input type="text" bind:value={clockLongitude} class={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if widgetType === 'system_stats'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Source URL</label>
|
||||||
|
<input type="url" bind:value={sysStatsSourceUrl} placeholder="http://localhost:61208/api/3" class={inputClass} bind:this={firstInput} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Source Type</label>
|
||||||
|
<select bind:value={sysStatsSourceType} class={inputClass}>
|
||||||
|
<option value="glances">Glances</option>
|
||||||
|
<option value="prometheus">Prometheus</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)</label>
|
||||||
|
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if widgetType === 'rss'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Feed URL</label>
|
||||||
|
<input type="url" bind:value={rssFeedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Max Items ({rssMaxItems})</label>
|
||||||
|
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input type="checkbox" bind:checked={rssShowSummary} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||||
|
Show Summary
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{:else if widgetType === 'calendar'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>iCal URLs</label>
|
||||||
|
{#each calendarUrlsRaw as cal, i}
|
||||||
|
<div class="mb-1 flex items-center gap-1">
|
||||||
|
<input type="url" bind:value={cal.url} placeholder="https://..." class="{inputClass} flex-1" />
|
||||||
|
<input type="text" bind:value={cal.label} placeholder="Label" class="{inputClass} w-20" />
|
||||||
|
<input type="color" bind:value={cal.color} class="h-8 w-8 cursor-pointer rounded border-0" />
|
||||||
|
{#if calendarUrlsRaw.length > 1}
|
||||||
|
<button type="button" onclick={() => removeCalendarUrl(i)} class="text-destructive hover:text-destructive/80">×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Days Ahead ({calendarDaysAhead})</label>
|
||||||
|
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if widgetType === 'markdown'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('widget.content') ?? 'Content'}</label>
|
||||||
|
<textarea bind:value={markdownContent} rows="6" class="{inputClass} font-mono text-xs" bind:this={firstInput}></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if widgetType === 'metric'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
|
||||||
|
<input type="text" bind:value={metricLabel} class={inputClass} bind:this={firstInput} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Source</label>
|
||||||
|
<select bind:value={metricSource} class={inputClass}>
|
||||||
|
<option value="static">Static</option>
|
||||||
|
<option value="json">JSON Endpoint</option>
|
||||||
|
<option value="prometheus">Prometheus</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{#if metricSource === 'static'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Value</label>
|
||||||
|
<input type="text" bind:value={metricValue} class={inputClass} />
|
||||||
|
</div>
|
||||||
|
{:else if metricSource === 'json'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>URL</label>
|
||||||
|
<input type="url" bind:value={metricUrl} class={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>JSON Path</label>
|
||||||
|
<input type="text" bind:value={metricJsonPath} placeholder="$.data.value" class={inputClass} />
|
||||||
|
</div>
|
||||||
|
{:else if metricSource === 'prometheus'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>URL</label>
|
||||||
|
<input type="url" bind:value={metricUrl} class={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>PromQL Query</label>
|
||||||
|
<input type="text" bind:value={metricQuery} class={inputClass} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Unit</label>
|
||||||
|
<input type="text" bind:value={metricUnit} placeholder="%" class={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Refresh ({metricRefreshInterval}s)</label>
|
||||||
|
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if widgetType === 'link_group'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Links</label>
|
||||||
|
{#each linkGroupLinks as link, i}
|
||||||
|
<div class="mb-1 flex items-center gap-1">
|
||||||
|
<input type="text" bind:value={link.label} placeholder="Label" class="{inputClass} w-24" />
|
||||||
|
<input type="url" bind:value={link.url} placeholder="URL" class="{inputClass} flex-1" />
|
||||||
|
<input type="text" bind:value={link.icon} placeholder="Icon" class="{inputClass} w-16" />
|
||||||
|
{#if linkGroupLinks.length > 1}
|
||||||
|
<button type="button" onclick={() => removeLinkGroupLink(i)} class="text-destructive hover:text-destructive/80">×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<button type="button" onclick={addLinkGroupLink} class="text-xs text-primary hover:underline">+ Add Link</button>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input type="checkbox" bind:checked={linkGroupCollapsible} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||||
|
Collapsible
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{:else if widgetType === 'camera'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Stream URL</label>
|
||||||
|
<input type="url" bind:value={cameraStreamUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Type</label>
|
||||||
|
<select bind:value={cameraType} class={inputClass}>
|
||||||
|
<option value="image">Image</option>
|
||||||
|
<option value="mjpeg">MJPEG</option>
|
||||||
|
<option value="hls">HLS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Refresh ({cameraRefreshInterval}s)</label>
|
||||||
|
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Aspect Ratio</label>
|
||||||
|
<select bind:value={cameraAspectRatio} class={inputClass}>
|
||||||
|
<option value="16/9">16:9</option>
|
||||||
|
<option value="4/3">4:3</option>
|
||||||
|
<option value="1/1">1:1</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if widgetType === 'integration'}
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
||||||
|
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
|
||||||
|
<option value="">Select app...</option>
|
||||||
|
{#each apps as app}
|
||||||
|
<option value={app.id}>{app.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Endpoint ID</label>
|
||||||
|
<input type="text" bind:value={integrationEndpointId} class={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelClass}>Refresh ({integrationRefreshInterval}s)</label>
|
||||||
|
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="mt-3 flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||||
|
<button type="button" onclick={onCancel}
|
||||||
|
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent">
|
||||||
|
{$t('common.cancel') ?? 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={handleSave}
|
||||||
|
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
|
||||||
|
{mode === 'create' ? ($t('common.add') ?? 'Add') : ($t('common.save') ?? 'Save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -110,7 +110,8 @@
|
|||||||
|
|
||||||
const noteFormatItems: IconGridItem[] = [
|
const noteFormatItems: IconGridItem[] = [
|
||||||
{ value: 'markdown', icon: '📝', label: 'Markdown' },
|
{ value: 'markdown', icon: '📝', label: 'Markdown' },
|
||||||
{ value: 'text', icon: '📄', label: 'Plain Text' }
|
{ value: 'text', icon: '📄', label: 'Plain Text' },
|
||||||
|
{ value: 'html', icon: '🌐', label: 'HTML' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const clockStyleItems: IconGridItem[] = [
|
const clockStyleItems: IconGridItem[] = [
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widgetId: string;
|
||||||
|
onEdit: (widgetId: string) => void;
|
||||||
|
onDelete: (widgetId: string) => void;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { widgetId, onEdit, onDelete, children }: Props = $props();
|
||||||
|
|
||||||
|
let showDeleteConfirm = $state(false);
|
||||||
|
let hovered = $state(false);
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
onDelete(widgetId);
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="group relative"
|
||||||
|
role="group"
|
||||||
|
onmouseenter={() => { hovered = true; }}
|
||||||
|
onmouseleave={() => { hovered = false; }}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
|
||||||
|
<!-- Overlay controls -->
|
||||||
|
{#if hovered}
|
||||||
|
<div class="absolute inset-0 z-10 rounded-xl bg-black/5 transition-opacity">
|
||||||
|
<!-- Top-left: drag handle -->
|
||||||
|
<div class="absolute left-1.5 top-1.5">
|
||||||
|
<div class="cursor-grab rounded-md bg-card/90 p-1 text-muted-foreground shadow-sm backdrop-blur-sm" title="Drag to reorder">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="9" cy="5" r="1" /><circle cx="15" cy="5" r="1" />
|
||||||
|
<circle cx="9" cy="12" r="1" /><circle cx="15" cy="12" r="1" />
|
||||||
|
<circle cx="9" cy="19" r="1" /><circle cx="15" cy="19" r="1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top-right: edit + delete -->
|
||||||
|
<div class="absolute right-1.5 top-1.5 flex items-center gap-1">
|
||||||
|
<!-- Edit button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onEdit(widgetId)}
|
||||||
|
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-primary hover:text-primary-foreground"
|
||||||
|
title={$t('common.edit') ?? 'Edit'}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { showDeleteConfirm = true; }}
|
||||||
|
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
title={$t('common.delete') ?? 'Delete'}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showDeleteConfirm}
|
||||||
|
<ConfirmDialog
|
||||||
|
title={$t('widget.delete_title') ?? 'Delete Widget'}
|
||||||
|
message={$t('widget.delete_confirm') ?? 'Are you sure you want to delete this widget? This action will take effect when you save.'}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => { showDeleteConfirm = false; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import WidgetRenderer from './WidgetRenderer.svelte';
|
import WidgetRenderer from './WidgetRenderer.svelte';
|
||||||
import WidgetContainer from './WidgetContainer.svelte';
|
import WidgetContainer from './WidgetContainer.svelte';
|
||||||
|
import WidgetEditOverlay from './WidgetEditOverlay.svelte';
|
||||||
|
import WidgetConfigPanel from './WidgetConfigPanel.svelte';
|
||||||
|
import WidgetTypePicker from './WidgetTypePicker.svelte';
|
||||||
|
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||||
import type { CardSize } from '$lib/utils/constants.js';
|
import type { CardSize } from '$lib/utils/constants.js';
|
||||||
|
|
||||||
interface AppData {
|
interface AppData {
|
||||||
@@ -25,11 +29,17 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widgets: WidgetData[];
|
widgets: WidgetData[];
|
||||||
|
sectionId?: string;
|
||||||
allApps?: AppData[];
|
allApps?: AppData[];
|
||||||
cardSize?: CardSize;
|
cardSize?: CardSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { widgets, allApps = [], cardSize = 'medium' }: Props = $props();
|
let { widgets, sectionId, allApps = [], cardSize = 'medium' }: Props = $props();
|
||||||
|
|
||||||
|
// Edit mode state
|
||||||
|
let editingWidgetId = $state<string | null>(null);
|
||||||
|
let showTypePicker = $state(false);
|
||||||
|
let addingWidgetType = $state<string | null>(null);
|
||||||
|
|
||||||
// Widgets that should span full width
|
// Widgets that should span full width
|
||||||
const fullWidthTypes = new Set(['note', 'embed', 'status', 'system_stats', 'rss', 'calendar', 'markdown', 'camera']);
|
const fullWidthTypes = new Set(['note', 'embed', 'status', 'system_stats', 'rss', 'calendar', 'markdown', 'camera']);
|
||||||
@@ -56,19 +66,123 @@
|
|||||||
return 'col-span-2 sm:col-span-3 lg:col-span-4';
|
return 'col-span-2 sm:col-span-3 lg:col-span-4';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleEditWidget(widgetId: string) {
|
||||||
|
editingWidgetId = widgetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteWidget(widgetId: string) {
|
||||||
|
editMode.deleteWidget(widgetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveWidgetConfig(widgetId: string, config: Record<string, unknown>) {
|
||||||
|
editMode.updateWidget(widgetId, config);
|
||||||
|
editingWidgetId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTypeSelected(type: string) {
|
||||||
|
showTypePicker = false;
|
||||||
|
addingWidgetType = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNewWidgetSave(config: Record<string, unknown>) {
|
||||||
|
if (!sectionId || !addingWidgetType) return;
|
||||||
|
const tempId = `temp-widget-${crypto.randomUUID()}`;
|
||||||
|
|
||||||
|
editMode.addWidget({
|
||||||
|
tempId,
|
||||||
|
sectionId,
|
||||||
|
type: addingWidgetType,
|
||||||
|
config: JSON.stringify(config),
|
||||||
|
order: widgets.length,
|
||||||
|
appId: addingWidgetType === 'app' ? (config.appId as string) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
addingWidgetType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWidgetConfig(widget: WidgetData): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(widget.config || '{}');
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appsForPicker = $derived(allApps.map((a) => ({ id: a.id, name: a.name, icon: a.icon, iconType: a.iconType })));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if widgets.length === 0}
|
{#if widgets.length === 0 && !editMode.active}
|
||||||
<p class="text-sm text-muted-foreground">{$t('widget.no_widgets')}</p>
|
<p class="text-sm text-muted-foreground">{$t('widget.no_widgets')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class={gridClass}>
|
<div class={gridClass}>
|
||||||
{#each widgets as widget (widget.id)}
|
{#each widgets as widget (widget.id)}
|
||||||
{@const isFullWidth = fullWidthTypes.has(widget.type)}
|
{@const isFullWidth = fullWidthTypes.has(widget.type)}
|
||||||
<div class={isFullWidth ? fullWidthClass : ''}>
|
<div class={isFullWidth ? fullWidthClass : ''}>
|
||||||
<WidgetContainer>
|
{#if editMode.active}
|
||||||
<WidgetRenderer {widget} {allApps} {cardSize} />
|
{#if editingWidgetId === widget.id}
|
||||||
</WidgetContainer>
|
<!-- Inline config editor -->
|
||||||
|
<WidgetConfigPanel
|
||||||
|
widgetType={widget.type}
|
||||||
|
initialConfig={getWidgetConfig(widget)}
|
||||||
|
apps={appsForPicker}
|
||||||
|
mode="edit"
|
||||||
|
onSave={(config) => handleSaveWidgetConfig(widget.id, config)}
|
||||||
|
onCancel={() => { editingWidgetId = null; }}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<WidgetEditOverlay
|
||||||
|
widgetId={widget.id}
|
||||||
|
onEdit={handleEditWidget}
|
||||||
|
onDelete={handleDeleteWidget}
|
||||||
|
>
|
||||||
|
<WidgetContainer>
|
||||||
|
<WidgetRenderer {widget} {allApps} {cardSize} />
|
||||||
|
</WidgetContainer>
|
||||||
|
</WidgetEditOverlay>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<WidgetContainer>
|
||||||
|
<WidgetRenderer {widget} {allApps} {cardSize} />
|
||||||
|
</WidgetContainer>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Add Widget button (edit mode only) -->
|
||||||
|
{#if editMode.active}
|
||||||
|
{#if addingWidgetType}
|
||||||
|
<div class={fullWidthClass}>
|
||||||
|
<WidgetConfigPanel
|
||||||
|
widgetType={addingWidgetType}
|
||||||
|
apps={appsForPicker}
|
||||||
|
mode="create"
|
||||||
|
onSave={handleNewWidgetSave}
|
||||||
|
onCancel={() => { addingWidgetType = null; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { showTypePicker = true; }}
|
||||||
|
class="flex h-full min-h-[80px] w-full items-center justify-center rounded-xl border-2 border-dashed border-border bg-card/30 text-muted-foreground transition-all hover:border-primary hover:bg-primary/5 hover:text-primary"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs">{$t('widget.add_widget') ?? 'Add Widget'}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Type picker modal (rendered outside grid, fixed position) -->
|
||||||
|
{#if showTypePicker}
|
||||||
|
<WidgetTypePicker
|
||||||
|
onSelect={handleTypeSelected}
|
||||||
|
onClose={() => { showTypePicker = false; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fade, scale } from 'svelte/transition';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelect: (type: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSelect, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let filterQuery = $state('');
|
||||||
|
let searchInput: HTMLInputElement | undefined = $state();
|
||||||
|
|
||||||
|
$effect(() => { tick().then(() => searchInput?.focus()); });
|
||||||
|
|
||||||
|
const widgetTypes = [
|
||||||
|
{ value: 'app', label: 'App', description: 'Link to a registered application' },
|
||||||
|
{ value: 'bookmark', label: 'Bookmark', description: 'Quick link with icon and description' },
|
||||||
|
{ value: 'note', label: 'Note', description: 'Text or markdown content' },
|
||||||
|
{ value: 'embed', label: 'Embed', description: 'Embedded iframe content' },
|
||||||
|
{ value: 'status', label: 'Status', description: 'Monitor multiple app statuses' },
|
||||||
|
{ value: 'clock', label: 'Clock', description: 'Clock with optional weather' },
|
||||||
|
{ value: 'system_stats', label: 'System Stats', description: 'CPU, RAM, disk usage' },
|
||||||
|
{ value: 'rss', label: 'RSS Feed', description: 'RSS/Atom feed reader' },
|
||||||
|
{ value: 'calendar', label: 'Calendar', description: 'iCal calendar events' },
|
||||||
|
{ value: 'markdown', label: 'Markdown', description: 'Rich markdown document' },
|
||||||
|
{ value: 'metric', label: 'Metric', description: 'Single value from API or Prometheus' },
|
||||||
|
{ value: 'link_group', label: 'Link Group', description: 'Grouped collection of links' },
|
||||||
|
{ value: 'camera', label: 'Camera', description: 'Image, MJPEG, or HLS stream' },
|
||||||
|
{ value: 'integration', label: 'Integration', description: 'Custom app integration endpoint' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredTypes = $derived(
|
||||||
|
filterQuery.trim()
|
||||||
|
? widgetTypes.filter((wt) =>
|
||||||
|
wt.label.toLowerCase().includes(filterQuery.toLowerCase()) ||
|
||||||
|
wt.description.toLowerCase().includes(filterQuery.toLowerCase()) ||
|
||||||
|
wt.value.toLowerCase().includes(filterQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
: widgetTypes
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconFor(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
app: 'M2 3h20v14H2zM8 21h8M12 17v4',
|
||||||
|
bookmark: 'm19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z',
|
||||||
|
note: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|14 2 14 8 20 8|16 13 8 13|16 17 8 17|10 9 9 9 8 9',
|
||||||
|
embed: '16 18 22 12 16 6|8 6 2 12 8 18',
|
||||||
|
status: '22 12 18 12 15 21 9 3 6 12 2 12',
|
||||||
|
clock: 'M12 12m-10 0a10 10 0 1 0 20 0a10 10 0 1 0 -20 0|12 6 12 12 16 14',
|
||||||
|
system_stats: 'M4 4h16v16H4z|9 9h6v6H9z',
|
||||||
|
rss: 'M4 11a9 9 0 0 1 9 9|M4 4a16 16 0 0 1 16 16|M5 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0',
|
||||||
|
calendar: 'M3 4h18v18H3z|16 2v4|8 2v4|3 10h18',
|
||||||
|
markdown: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|14 2 14 8 20 8',
|
||||||
|
metric: '22 7 13.5 15.5 8.5 10.5 2 17|16 7 22 7 22 13',
|
||||||
|
link_group: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71|M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
||||||
|
camera: 'M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z|M12 13m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0',
|
||||||
|
integration: 'M12 2v10|M18.4 6.6 14.5 10.5|M22 12h-10|M18.4 17.4 14.5 13.5|M12 22v-10|M5.6 17.4 9.5 13.5|M2 12h10|M5.6 6.6 9.5 10.5'
|
||||||
|
};
|
||||||
|
return map[type] ?? '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={onClose}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && onClose()}
|
||||||
|
transition:fade={{ duration: 120 }}
|
||||||
|
>
|
||||||
|
<!-- Modal -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="mx-4 w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl lg:max-w-2xl"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
transition:scale={{ start: 0.95, duration: 150 }}
|
||||||
|
>
|
||||||
|
<!-- Header + Search -->
|
||||||
|
<div class="border-b border-border px-5 pb-3 pt-5">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h2 class="text-base font-semibold text-foreground">{$t('widget.add_widget') ?? 'Add Widget'}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:this={searchInput}
|
||||||
|
type="text"
|
||||||
|
bind:value={filterQuery}
|
||||||
|
placeholder={$t('widget.search_type') ?? 'Search widget types...'}
|
||||||
|
class="w-full rounded-lg border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid -->
|
||||||
|
<div class="max-h-80 overflow-y-auto p-3">
|
||||||
|
{#if filteredTypes.length === 0}
|
||||||
|
<p class="py-8 text-center text-sm text-muted-foreground">{$t('common.no_results') ?? 'No matching widget types'}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-2 gap-1.5">
|
||||||
|
{#each filteredTypes as wt}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onSelect(wt.value)}
|
||||||
|
class="flex items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div class="mt-0.5 shrink-0 rounded-lg bg-primary/10 p-2 text-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
{#each iconFor(wt.value).split('|') as segment}
|
||||||
|
{#if segment.includes('m') || segment.includes('M') || segment.includes('a') || segment.includes('z') || segment.includes('A') || segment.includes('c') || segment.includes('l') || segment.includes('v') || segment.includes('h') || segment.includes('V') || segment.includes('H')}
|
||||||
|
<path d={segment} />
|
||||||
|
{:else}
|
||||||
|
<polyline points={segment} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-foreground">{wt.label}</div>
|
||||||
|
<div class="text-xs leading-snug text-muted-foreground">{wt.description}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+44
-1
@@ -356,5 +356,48 @@
|
|||||||
"app.quick_add_description": "Review the details below and save to add this app to your launcher.",
|
"app.quick_add_description": "Review the details below and save to add this app to your launcher.",
|
||||||
"app.quick_add_success": "App added successfully!",
|
"app.quick_add_success": "App added successfully!",
|
||||||
"app.quick_add_view_apps": "View Apps",
|
"app.quick_add_view_apps": "View Apps",
|
||||||
"app.quick_add_close": "Close Window"
|
"app.quick_add_close": "Close Window",
|
||||||
|
|
||||||
|
"board.editing": "Editing",
|
||||||
|
"board.exit_edit": "Exit Edit Mode",
|
||||||
|
"board.settings": "Board Settings",
|
||||||
|
"board.add_section": "Add Section",
|
||||||
|
"board.section_title": "Section title...",
|
||||||
|
"board.no_sections_edit": "No sections yet. Click \"+\" below to add one.",
|
||||||
|
"board.discard_title": "Discard Changes",
|
||||||
|
"board.discard_confirm": "Are you sure you want to discard all unsaved changes?",
|
||||||
|
"board.delete_section_title": "Delete Section",
|
||||||
|
"board.delete_section_confirm": "Are you sure you want to delete this section and its {count} widgets?",
|
||||||
|
"board.try_inline_edit": "Try the new inline edit mode!",
|
||||||
|
"board.inline_edit_description": "Edit your board directly with live preview. Press Ctrl+E on the board page.",
|
||||||
|
"board.open_inline_edit": "Open Inline Edit",
|
||||||
|
"board.advanced": "Advanced",
|
||||||
|
"board.theme_hue": "Theme Hue",
|
||||||
|
"board.theme_saturation": "Saturation",
|
||||||
|
"board.background": "Background",
|
||||||
|
"board.card_size": "Card Size",
|
||||||
|
"board.custom_css": "Custom CSS",
|
||||||
|
|
||||||
|
"widget.add_widget": "Add Widget",
|
||||||
|
"widget.edit_widget": "Edit Widget",
|
||||||
|
"widget.select_type": "Select widget type",
|
||||||
|
"widget.search_type": "Search widget types...",
|
||||||
|
"widget.delete_title": "Delete Widget",
|
||||||
|
"widget.delete_confirm": "Are you sure you want to delete this widget? This action will take effect when you save.",
|
||||||
|
"widget.content": "Content",
|
||||||
|
"widget.format": "Format",
|
||||||
|
"widget.height": "Height",
|
||||||
|
"widget.apps": "Apps",
|
||||||
|
"widget.timezone": "Timezone",
|
||||||
|
"widget.style": "Style",
|
||||||
|
"widget.show_weather": "Show Weather",
|
||||||
|
"widget.app": "App",
|
||||||
|
|
||||||
|
"common.discard": "Discard",
|
||||||
|
"common.apply": "Apply",
|
||||||
|
"common.search": "Search...",
|
||||||
|
"common.clear": "Clear",
|
||||||
|
"common.label": "Label",
|
||||||
|
"common.no_results": "No results found",
|
||||||
|
"common.dismiss": "Dismiss"
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-1
@@ -332,5 +332,48 @@
|
|||||||
"install.title": "Установить приложение",
|
"install.title": "Установить приложение",
|
||||||
"install.description": "Добавьте Web App Launcher на главный экран для быстрого доступа.",
|
"install.description": "Добавьте Web App Launcher на главный экран для быстрого доступа.",
|
||||||
"install.button": "Установить",
|
"install.button": "Установить",
|
||||||
"install.dismiss": "Скрыть предложение установки"
|
"install.dismiss": "Скрыть предложение установки",
|
||||||
|
|
||||||
|
"board.editing": "Редактирование",
|
||||||
|
"board.exit_edit": "Выйти из режима редактирования",
|
||||||
|
"board.settings": "Настройки доски",
|
||||||
|
"board.add_section": "Добавить секцию",
|
||||||
|
"board.section_title": "Название секции...",
|
||||||
|
"board.no_sections_edit": "Секций пока нет. Нажмите «+» ниже, чтобы добавить.",
|
||||||
|
"board.discard_title": "Отменить изменения",
|
||||||
|
"board.discard_confirm": "Вы уверены, что хотите отменить все несохранённые изменения?",
|
||||||
|
"board.delete_section_title": "Удалить секцию",
|
||||||
|
"board.delete_section_confirm": "Вы уверены, что хотите удалить эту секцию и её {count} виджетов?",
|
||||||
|
"board.try_inline_edit": "Попробуйте новый режим редактирования!",
|
||||||
|
"board.inline_edit_description": "Редактируйте доску прямо на месте с предпросмотром. Нажмите Ctrl+E на странице доски.",
|
||||||
|
"board.open_inline_edit": "Открыть редактирование",
|
||||||
|
"board.advanced": "Расширенное",
|
||||||
|
"board.theme_hue": "Оттенок темы",
|
||||||
|
"board.theme_saturation": "Насыщенность",
|
||||||
|
"board.background": "Фон",
|
||||||
|
"board.card_size": "Размер карточек",
|
||||||
|
"board.custom_css": "Пользовательский CSS",
|
||||||
|
|
||||||
|
"widget.add_widget": "Добавить виджет",
|
||||||
|
"widget.edit_widget": "Редактировать виджет",
|
||||||
|
"widget.select_type": "Выберите тип виджета",
|
||||||
|
"widget.search_type": "Поиск типов виджетов...",
|
||||||
|
"widget.delete_title": "Удалить виджет",
|
||||||
|
"widget.delete_confirm": "Вы уверены, что хотите удалить этот виджет? Изменение вступит в силу при сохранении.",
|
||||||
|
"widget.content": "Содержимое",
|
||||||
|
"widget.format": "Формат",
|
||||||
|
"widget.height": "Высота",
|
||||||
|
"widget.apps": "Приложения",
|
||||||
|
"widget.timezone": "Часовой пояс",
|
||||||
|
"widget.style": "Стиль",
|
||||||
|
"widget.show_weather": "Показать погоду",
|
||||||
|
"widget.app": "Приложение",
|
||||||
|
|
||||||
|
"common.discard": "Отменить",
|
||||||
|
"common.apply": "Применить",
|
||||||
|
"common.search": "Поиск...",
|
||||||
|
"common.clear": "Очистить",
|
||||||
|
"common.label": "Метка",
|
||||||
|
"common.no_results": "Ничего не найдено",
|
||||||
|
"common.dismiss": "Закрыть"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,3 +306,24 @@ export async function getCategories() {
|
|||||||
});
|
});
|
||||||
return apps.map((a) => a.category).filter(Boolean) as string[];
|
return apps.map((a) => a.category).filter(Boolean) as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllTags(): Promise<string[]> {
|
||||||
|
// Collect from both the Tag model and the comma-separated tags field
|
||||||
|
const [tagModels, apps] = await Promise.all([
|
||||||
|
prisma.tag.findMany({ select: { name: true }, orderBy: { name: 'asc' } }),
|
||||||
|
prisma.app.findMany({
|
||||||
|
where: { tags: { not: '' } },
|
||||||
|
select: { tags: true }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tagSet = new Set<string>();
|
||||||
|
for (const t of tagModels) tagSet.add(t.name);
|
||||||
|
for (const a of apps) {
|
||||||
|
for (const tag of a.tags.split(',')) {
|
||||||
|
const trimmed = tag.trim();
|
||||||
|
if (trimmed) tagSet.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(tagSet).sort();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type { CardSize } from '$lib/utils/constants.js';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface WidgetAdd {
|
||||||
|
tempId: string;
|
||||||
|
sectionId: string;
|
||||||
|
type: string;
|
||||||
|
config: string;
|
||||||
|
order: number;
|
||||||
|
appId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetMove {
|
||||||
|
fromSectionId: string;
|
||||||
|
toSectionId: string;
|
||||||
|
newOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionAdd {
|
||||||
|
tempId: string;
|
||||||
|
title: string;
|
||||||
|
icon: string | null;
|
||||||
|
order: number;
|
||||||
|
cardSize?: CardSize | null;
|
||||||
|
isExpandedByDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Changeset {
|
||||||
|
widgetUpdates: Map<string, Record<string, unknown>>;
|
||||||
|
widgetAdds: WidgetAdd[];
|
||||||
|
widgetDeletes: Set<string>;
|
||||||
|
widgetMoves: Map<string, WidgetMove>;
|
||||||
|
sectionUpdates: Map<string, Record<string, unknown>>;
|
||||||
|
sectionAdds: SectionAdd[];
|
||||||
|
sectionDeletes: Set<string>;
|
||||||
|
sectionReorders: Map<string, number>;
|
||||||
|
boardUpdates: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyChangeset(): Changeset {
|
||||||
|
return {
|
||||||
|
widgetUpdates: new Map(),
|
||||||
|
widgetAdds: [],
|
||||||
|
widgetDeletes: new Set(),
|
||||||
|
widgetMoves: new Map(),
|
||||||
|
sectionUpdates: new Map(),
|
||||||
|
sectionAdds: [],
|
||||||
|
sectionDeletes: new Set(),
|
||||||
|
sectionReorders: new Map(),
|
||||||
|
boardUpdates: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Store ---
|
||||||
|
|
||||||
|
class EditModeStore {
|
||||||
|
active = $state(false);
|
||||||
|
boardId = $state<string | null>(null);
|
||||||
|
changeset = $state<Changeset>(createEmptyChangeset());
|
||||||
|
|
||||||
|
dirty = $derived(this.changeCount > 0);
|
||||||
|
|
||||||
|
changeCount = $derived.by(() => {
|
||||||
|
const cs = this.changeset;
|
||||||
|
return (
|
||||||
|
cs.widgetUpdates.size +
|
||||||
|
cs.widgetAdds.length +
|
||||||
|
cs.widgetDeletes.size +
|
||||||
|
cs.widgetMoves.size +
|
||||||
|
cs.sectionUpdates.size +
|
||||||
|
cs.sectionAdds.length +
|
||||||
|
cs.sectionDeletes.size +
|
||||||
|
cs.sectionReorders.size +
|
||||||
|
Object.keys(cs.boardUpdates).length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Mode control ---
|
||||||
|
|
||||||
|
enterEditMode(boardId: string): void {
|
||||||
|
this.active = true;
|
||||||
|
this.boardId = boardId;
|
||||||
|
this.changeset = createEmptyChangeset();
|
||||||
|
}
|
||||||
|
|
||||||
|
exitEditMode(): void {
|
||||||
|
this.active = false;
|
||||||
|
this.boardId = null;
|
||||||
|
this.changeset = createEmptyChangeset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Widget mutations ---
|
||||||
|
|
||||||
|
updateWidget(widgetId: string, config: Record<string, unknown>): void {
|
||||||
|
const existing = this.changeset.widgetUpdates.get(widgetId) ?? {};
|
||||||
|
this.changeset.widgetUpdates = new Map(this.changeset.widgetUpdates).set(widgetId, {
|
||||||
|
...existing,
|
||||||
|
...config
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addWidget(add: WidgetAdd): void {
|
||||||
|
this.changeset = { ...this.changeset, widgetAdds: [...this.changeset.widgetAdds, add] };
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteWidget(widgetId: string): void {
|
||||||
|
// If it's a temp widget, just remove from adds
|
||||||
|
const tempIndex = this.changeset.widgetAdds.findIndex((w) => w.tempId === widgetId);
|
||||||
|
if (tempIndex >= 0) {
|
||||||
|
const newAdds = this.changeset.widgetAdds.filter((_, i) => i !== tempIndex);
|
||||||
|
this.changeset = { ...this.changeset, widgetAdds: newAdds };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newDeletes = new Set(this.changeset.widgetDeletes);
|
||||||
|
newDeletes.add(widgetId);
|
||||||
|
this.changeset = { ...this.changeset, widgetDeletes: newDeletes };
|
||||||
|
// Remove any pending updates for this widget
|
||||||
|
const newUpdates = new Map(this.changeset.widgetUpdates);
|
||||||
|
newUpdates.delete(widgetId);
|
||||||
|
this.changeset = { ...this.changeset, widgetUpdates: newUpdates };
|
||||||
|
}
|
||||||
|
|
||||||
|
moveWidget(widgetId: string, move: WidgetMove): void {
|
||||||
|
this.changeset.widgetMoves = new Map(this.changeset.widgetMoves).set(widgetId, move);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Section mutations ---
|
||||||
|
|
||||||
|
updateSection(sectionId: string, data: Record<string, unknown>): void {
|
||||||
|
const existing = this.changeset.sectionUpdates.get(sectionId) ?? {};
|
||||||
|
this.changeset.sectionUpdates = new Map(this.changeset.sectionUpdates).set(sectionId, {
|
||||||
|
...existing,
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addSection(add: SectionAdd): void {
|
||||||
|
this.changeset = { ...this.changeset, sectionAdds: [...this.changeset.sectionAdds, add] };
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSection(sectionId: string): void {
|
||||||
|
// If it's a temp section, just remove from adds
|
||||||
|
const tempIndex = this.changeset.sectionAdds.findIndex((s) => s.tempId === sectionId);
|
||||||
|
if (tempIndex >= 0) {
|
||||||
|
const newAdds = this.changeset.sectionAdds.filter((_, i) => i !== tempIndex);
|
||||||
|
this.changeset = { ...this.changeset, sectionAdds: newAdds };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newDeletes = new Set(this.changeset.sectionDeletes);
|
||||||
|
newDeletes.add(sectionId);
|
||||||
|
this.changeset = { ...this.changeset, sectionDeletes: newDeletes };
|
||||||
|
// Remove any pending updates for this section
|
||||||
|
const newUpdates = new Map(this.changeset.sectionUpdates);
|
||||||
|
newUpdates.delete(sectionId);
|
||||||
|
this.changeset = { ...this.changeset, sectionUpdates: newUpdates };
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderSection(sectionId: string, newOrder: number): void {
|
||||||
|
this.changeset.sectionReorders = new Map(this.changeset.sectionReorders).set(
|
||||||
|
sectionId,
|
||||||
|
newOrder
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Board mutations ---
|
||||||
|
|
||||||
|
updateBoard(data: Record<string, unknown>): void {
|
||||||
|
this.changeset = {
|
||||||
|
...this.changeset,
|
||||||
|
boardUpdates: { ...this.changeset.boardUpdates, ...data }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reset ---
|
||||||
|
|
||||||
|
discardChanges(): void {
|
||||||
|
this.changeset = createEmptyChangeset();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAfterSave(): void {
|
||||||
|
this.changeset = createEmptyChangeset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editMode = new EditModeStore();
|
||||||
@@ -112,10 +112,18 @@ describe('Widget Config Validators', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts html format', () => {
|
||||||
|
const result = noteWidgetConfigSchema.safeParse({
|
||||||
|
content: '<h1>Hello</h1>',
|
||||||
|
format: 'html'
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid format', () => {
|
it('rejects invalid format', () => {
|
||||||
const result = noteWidgetConfigSchema.safeParse({
|
const result = noteWidgetConfigSchema.safeParse({
|
||||||
content: 'Some content',
|
content: 'Some content',
|
||||||
format: 'html'
|
format: 'invalid'
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const bookmarkWidgetConfigSchema = z.object({
|
|||||||
|
|
||||||
export const noteWidgetConfigSchema = z.object({
|
export const noteWidgetConfigSchema = z.object({
|
||||||
content: z.string().max(10000, 'Content too long'),
|
content: z.string().max(10000, 'Content too long'),
|
||||||
format: z.enum(['markdown', 'text']).default('markdown')
|
format: z.enum(['markdown', 'text', 'html']).default('markdown')
|
||||||
});
|
});
|
||||||
|
|
||||||
export const embedWidgetConfigSchema = z.object({
|
export const embedWidgetConfigSchema = z.object({
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import * as appService from '$lib/server/services/appService.js';
|
||||||
|
import { success } from '$lib/server/utils/response.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/apps/suggestions — Get categories and tags for autocomplete.
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
const [categories, tags] = await Promise.all([
|
||||||
|
appService.getCategories(),
|
||||||
|
appService.getAllTags()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return json(success({ categories, tags }));
|
||||||
|
};
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { prisma } from '$lib/server/prisma.js';
|
||||||
|
import * as permissionService from '$lib/server/services/permissionService.js';
|
||||||
|
import { success, error } from '$lib/server/utils/response.js';
|
||||||
|
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
|
||||||
|
import { updateBoardSchema } from '$lib/utils/validators.js';
|
||||||
|
|
||||||
|
interface WidgetAddPayload {
|
||||||
|
tempId: string;
|
||||||
|
sectionId: string;
|
||||||
|
type: string;
|
||||||
|
config: string;
|
||||||
|
order: number;
|
||||||
|
appId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WidgetMovePayload {
|
||||||
|
fromSectionId: string;
|
||||||
|
toSectionId: string;
|
||||||
|
newOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionAddPayload {
|
||||||
|
tempId: string;
|
||||||
|
title: string;
|
||||||
|
icon: string | null;
|
||||||
|
order: number;
|
||||||
|
isExpandedByDefault?: boolean;
|
||||||
|
cardSize?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchUpdatePayload {
|
||||||
|
boardUpdates?: Record<string, unknown>;
|
||||||
|
widgetUpdates?: Record<string, Record<string, unknown>>;
|
||||||
|
widgetAdds?: WidgetAddPayload[];
|
||||||
|
widgetDeletes?: string[];
|
||||||
|
widgetMoves?: Record<string, WidgetMovePayload>;
|
||||||
|
sectionUpdates?: Record<string, Record<string, unknown>>;
|
||||||
|
sectionAdds?: SectionAddPayload[];
|
||||||
|
sectionDeletes?: string[];
|
||||||
|
sectionReorders?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/boards/:id/batch-update
|
||||||
|
*
|
||||||
|
* Applies all edit-mode changes in a single Prisma transaction.
|
||||||
|
* Accepts a changeset with board updates, section CRUD, widget CRUD, and reorders.
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
const user = event.locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return json(error('Authentication required'), { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardId = event.params.id;
|
||||||
|
|
||||||
|
// Check edit permission
|
||||||
|
if (user.role !== UserRole.ADMIN) {
|
||||||
|
const result = await permissionService.checkPermission(
|
||||||
|
EntityType.BOARD,
|
||||||
|
boardId,
|
||||||
|
user.id,
|
||||||
|
PermissionLevel.EDIT
|
||||||
|
);
|
||||||
|
if (!result.hasPermission) {
|
||||||
|
return json(error('Insufficient permissions'), { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: BatchUpdatePayload;
|
||||||
|
try {
|
||||||
|
payload = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return json(error('Invalid JSON body'), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build a mapping of temp section IDs to real IDs (created during transaction)
|
||||||
|
const tempSectionIdMap = new Map<string, string>();
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Board updates
|
||||||
|
if (payload.boardUpdates && Object.keys(payload.boardUpdates).length > 0) {
|
||||||
|
const parsed = updateBoardSchema.safeParse(payload.boardUpdates);
|
||||||
|
if (parsed.success) {
|
||||||
|
await tx.board.update({
|
||||||
|
where: { id: boardId },
|
||||||
|
data: parsed.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete sections (cascades to widgets)
|
||||||
|
if (payload.sectionDeletes && payload.sectionDeletes.length > 0) {
|
||||||
|
await tx.section.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: { in: payload.sectionDeletes },
|
||||||
|
boardId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create new sections
|
||||||
|
if (payload.sectionAdds && payload.sectionAdds.length > 0) {
|
||||||
|
for (const add of payload.sectionAdds) {
|
||||||
|
const created = await tx.section.create({
|
||||||
|
data: {
|
||||||
|
boardId,
|
||||||
|
title: add.title,
|
||||||
|
icon: add.icon,
|
||||||
|
order: add.order,
|
||||||
|
isExpandedByDefault: add.isExpandedByDefault ?? true,
|
||||||
|
cardSize: add.cardSize ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tempSectionIdMap.set(add.tempId, created.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update existing sections
|
||||||
|
if (payload.sectionUpdates) {
|
||||||
|
for (const [sectionId, updates] of Object.entries(payload.sectionUpdates)) {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if ('title' in updates) data.title = updates.title;
|
||||||
|
if ('icon' in updates) data.icon = updates.icon;
|
||||||
|
if ('isExpandedByDefault' in updates) data.isExpandedByDefault = updates.isExpandedByDefault;
|
||||||
|
if ('cardSize' in updates) data.cardSize = updates.cardSize;
|
||||||
|
|
||||||
|
if (Object.keys(data).length > 0) {
|
||||||
|
await tx.section.update({
|
||||||
|
where: { id: sectionId },
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Reorder sections
|
||||||
|
if (payload.sectionReorders) {
|
||||||
|
for (const [sectionId, newOrder] of Object.entries(payload.sectionReorders)) {
|
||||||
|
const realId = tempSectionIdMap.get(sectionId) ?? sectionId;
|
||||||
|
await tx.section.update({
|
||||||
|
where: { id: realId },
|
||||||
|
data: { order: newOrder }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Delete widgets
|
||||||
|
if (payload.widgetDeletes && payload.widgetDeletes.length > 0) {
|
||||||
|
await tx.widget.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: { in: payload.widgetDeletes }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Create new widgets
|
||||||
|
if (payload.widgetAdds && payload.widgetAdds.length > 0) {
|
||||||
|
for (const add of payload.widgetAdds) {
|
||||||
|
// Resolve temp section IDs to real IDs
|
||||||
|
const realSectionId = tempSectionIdMap.get(add.sectionId) ?? add.sectionId;
|
||||||
|
|
||||||
|
await tx.widget.create({
|
||||||
|
data: {
|
||||||
|
sectionId: realSectionId,
|
||||||
|
type: add.type,
|
||||||
|
order: add.order,
|
||||||
|
config: add.config || '{}',
|
||||||
|
appId: add.appId || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Update existing widgets
|
||||||
|
if (payload.widgetUpdates) {
|
||||||
|
for (const [widgetId, configUpdates] of Object.entries(payload.widgetUpdates)) {
|
||||||
|
// Read current config, merge updates, write back
|
||||||
|
const widget = await tx.widget.findUnique({ where: { id: widgetId } });
|
||||||
|
if (!widget) continue;
|
||||||
|
|
||||||
|
let currentConfig: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
currentConfig = JSON.parse(widget.config || '{}');
|
||||||
|
} catch {
|
||||||
|
// keep empty
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedConfig = { ...currentConfig, ...configUpdates };
|
||||||
|
|
||||||
|
await tx.widget.update({
|
||||||
|
where: { id: widgetId },
|
||||||
|
data: { config: JSON.stringify(mergedConfig) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Move widgets between sections
|
||||||
|
if (payload.widgetMoves) {
|
||||||
|
for (const [widgetId, move] of Object.entries(payload.widgetMoves)) {
|
||||||
|
const realTargetSectionId = tempSectionIdMap.get(move.toSectionId) ?? move.toSectionId;
|
||||||
|
|
||||||
|
await tx.widget.update({
|
||||||
|
where: { id: widgetId },
|
||||||
|
data: {
|
||||||
|
sectionId: realTargetSectionId,
|
||||||
|
order: move.newOrder
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(success({ saved: true }));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to save changes';
|
||||||
|
return json(error(message), { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,8 +8,11 @@
|
|||||||
import BoardThemeProvider from '$lib/components/board/BoardThemeProvider.svelte';
|
import BoardThemeProvider from '$lib/components/board/BoardThemeProvider.svelte';
|
||||||
import CustomCssInjector from '$lib/components/layout/CustomCssInjector.svelte';
|
import CustomCssInjector from '$lib/components/layout/CustomCssInjector.svelte';
|
||||||
import WallpaperBackground from '$lib/components/background/WallpaperBackground.svelte';
|
import WallpaperBackground from '$lib/components/background/WallpaperBackground.svelte';
|
||||||
|
import EditToolbar from '$lib/components/board/EditToolbar.svelte';
|
||||||
|
import BoardPropertiesPanel from '$lib/components/board/BoardPropertiesPanel.svelte';
|
||||||
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
|
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
|
||||||
import { setContext } from 'svelte';
|
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||||
|
import { setContext, onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
@@ -19,8 +22,43 @@
|
|||||||
setContext('appHistories', data.appHistories ?? {});
|
setContext('appHistories', data.appHistories ?? {});
|
||||||
|
|
||||||
let showShareDialog = $state(false);
|
let showShareDialog = $state(false);
|
||||||
|
let showBoardProperties = $state(false);
|
||||||
let guestToggleError = $state('');
|
let guestToggleError = $state('');
|
||||||
|
|
||||||
|
// Auto-enter edit mode from ?edit=true query param
|
||||||
|
onMount(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('edit') === 'true' && data.canEdit) {
|
||||||
|
editMode.enterEditMode(data.board.id);
|
||||||
|
// Clean up URL without reload
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('edit');
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exit edit mode when navigating away
|
||||||
|
onDestroy(() => {
|
||||||
|
if (editMode.active) {
|
||||||
|
editMode.exitEditMode();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcut: Ctrl+E / Cmd+E to toggle edit mode
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'e' && data.canEdit) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (editMode.active) {
|
||||||
|
if (!editMode.dirty) {
|
||||||
|
editMode.exitEditMode();
|
||||||
|
}
|
||||||
|
// If dirty, let the toolbar handle discard confirmation
|
||||||
|
} else {
|
||||||
|
editMode.enterEditMode(data.board.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGuestToggle(value: boolean) {
|
async function handleGuestToggle(value: boolean) {
|
||||||
guestToggleError = '';
|
guestToggleError = '';
|
||||||
try {
|
try {
|
||||||
@@ -39,8 +77,55 @@
|
|||||||
guestToggleError = 'Network error updating guest access';
|
guestToggleError = 'Network error updating guest access';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveAll() {
|
||||||
|
const cs = editMode.changeset;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/boards/${data.board.id}/batch-update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
boardUpdates: cs.boardUpdates,
|
||||||
|
widgetUpdates: Object.fromEntries(cs.widgetUpdates),
|
||||||
|
widgetAdds: cs.widgetAdds,
|
||||||
|
widgetDeletes: Array.from(cs.widgetDeletes),
|
||||||
|
widgetMoves: Object.fromEntries(cs.widgetMoves),
|
||||||
|
sectionUpdates: Object.fromEntries(cs.sectionUpdates),
|
||||||
|
sectionAdds: cs.sectionAdds,
|
||||||
|
sectionDeletes: Array.from(cs.sectionDeletes),
|
||||||
|
sectionReorders: Object.fromEntries(cs.sectionReorders)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
editMode.clearAfterSave();
|
||||||
|
broadcastDataChange('board');
|
||||||
|
await invalidateAll();
|
||||||
|
} else {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
alert(`Save failed: ${json.error ?? 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('Network error saving changes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
editMode.discardChanges();
|
||||||
|
editMode.exitEditMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExitEditMode() {
|
||||||
|
if (editMode.dirty) {
|
||||||
|
// Don't exit — toolbar should show discard confirmation
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editMode.exitEditMode();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.board.name} — {$t('app_title')}</title>
|
<title>{data.board.name} — {$t('app_title')}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
@@ -60,7 +145,7 @@
|
|||||||
<CustomCssInjector css={data.board.customCss} />
|
<CustomCssInjector css={data.board.customCss} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6" class:edit-mode-active={editMode.active}>
|
||||||
<div class="mx-auto max-w-7xl">
|
<div class="mx-auto max-w-7xl">
|
||||||
<BoardHeader
|
<BoardHeader
|
||||||
name={data.board.name}
|
name={data.board.name}
|
||||||
@@ -75,9 +160,32 @@
|
|||||||
<p class="mb-2 text-sm text-destructive">{guestToggleError}</p>
|
<p class="mb-2 text-sm text-destructive">{guestToggleError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Board sections={data.board.sections} allApps={data.allApps} {boardCardSize} />
|
<Board
|
||||||
|
sections={data.board.sections}
|
||||||
|
allApps={data.allApps}
|
||||||
|
{boardCardSize}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit mode floating toolbar -->
|
||||||
|
{#if editMode.active}
|
||||||
|
<EditToolbar
|
||||||
|
onSave={handleSaveAll}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
onExit={handleExitEditMode}
|
||||||
|
onAddSection={() => {}}
|
||||||
|
onBoardSettings={() => { showBoardProperties = true; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Board properties side panel -->
|
||||||
|
{#if showBoardProperties && editMode.active}
|
||||||
|
<BoardPropertiesPanel
|
||||||
|
board={data.board}
|
||||||
|
onClose={() => { showBoardProperties = false; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</BoardThemeProvider>
|
</BoardThemeProvider>
|
||||||
|
|
||||||
{#if showShareDialog && data.canEdit}
|
{#if showShareDialog && data.canEdit}
|
||||||
@@ -91,3 +199,12 @@
|
|||||||
onGuestToggle={handleGuestToggle}
|
onGuestToggle={handleGuestToggle}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.edit-mode-active {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 0.3);
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: outline 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -186,8 +186,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Inline edit mode banner -->
|
||||||
|
<div class="mb-4 flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-primary">
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /><path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-foreground">{$t('board.try_inline_edit') ?? 'Try the new inline edit mode!'}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{$t('board.inline_edit_description') ?? 'Edit your board directly with live preview. Press Ctrl+E on the board page.'}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/boards/{data.board.id}?edit=true"
|
||||||
|
class="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{$t('board.open_inline_edit') ?? 'Open Inline Edit'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-foreground">{$t('board.edit_board')}</h1>
|
<h1 class="text-2xl font-bold text-foreground">{$t('board.edit_board')} <span class="text-sm font-normal text-muted-foreground">({$t('board.advanced') ?? 'Advanced'})</span></h1>
|
||||||
<a
|
<a
|
||||||
href="/boards/{data.board.id}"
|
href="/boards/{data.board.id}"
|
||||||
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
|||||||
Reference in New Issue
Block a user