feat(inline-edit): add WYSIWYG inline dashboard editing mode

Replace the disconnected board edit page with inline editing directly
on the board view. Toggle with Ctrl+E or the Edit button. Features:

- Edit mode store with changeset accumulation and batch save
- Floating toolbar (save, discard, add section, board settings, exit)
- Widget hover overlays with edit/delete/drag controls
- Type-specific widget config panels for all 14 widget types
- Section inline editing (title, icon picker, delete)
- "+" buttons for adding widgets and sections inline
- Section-level drag-and-drop reordering via svelte-dnd-action
- Batch save API endpoint (single Prisma transaction)
- Board properties side panel with live theme/wallpaper preview
- Modal widget type picker with search filtering
- Icon picker component with visual grid and search
- Confirmation dialog modal for all destructive actions
- HTML format support for Note widget (in addition to markdown/text)
- Full i18n support (en + ru) for all new UI strings
- Legacy edit page banner linking to new inline mode
This commit is contained in:
2026-04-03 00:01:29 +03:00
parent d8f89c65dc
commit a6b09aae9c
35 changed files with 3148 additions and 51 deletions
+59
View File
@@ -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)
+55
View File
@@ -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 -->
@@ -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>
+114 -4
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import { t } from 'svelte-i18n';
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 {
id: string;
@@ -8,6 +12,7 @@
icon: string | null;
order: number;
isExpandedByDefault: boolean;
cardSize?: string | null;
widgets: Array<{
id: string;
type: string;
@@ -36,8 +41,6 @@
statuses: Array<{ status: string; responseTime: number | null }>;
}
import type { CardSize } from '$lib/utils/constants.js';
interface Props {
sections: SectionData[];
allApps?: AppData[];
@@ -45,15 +48,122 @@
}
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>
<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">
<p class="text-muted-foreground">{$t('board.no_sections')}</p>
</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}
{#each sections as section (section.id)}
<!-- View mode: simple list -->
{#each sectionsForRender as section (section.id)}
<Section {section} {allApps} {boardCardSize} />
{/each}
{/if}
+35 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
import { editMode } from '$lib/stores/editMode.svelte.js';
interface Props {
name: string;
@@ -12,6 +13,20 @@
}
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>
<div class="mb-6 flex items-start justify-between">
@@ -51,12 +66,27 @@
</button>
{/if}
{#if canEdit}
<a
href="/boards/{boardId}/edit"
class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
<button
type="button"
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')}
</a>
{#if editMode.active}
<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}
</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>
+147
View File
@@ -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}
+11 -4
View File
@@ -2,6 +2,8 @@
import SectionHeader from './SectionHeader.svelte';
import SectionCollapsible from './SectionCollapsible.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 {
id: string;
@@ -20,8 +22,6 @@
} | null;
}
import type { CardSize } from '$lib/utils/constants.js';
interface SectionData {
id: string;
title: string;
@@ -58,17 +58,24 @@
let expanded = $state(section.isExpandedByDefault);
</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
sectionId={section.id}
title={section.title}
icon={section.icon}
{expanded}
onToggle={() => (expanded = !expanded)}
widgetCount={section.widgets.length}
/>
<SectionCollapsible {expanded}>
<div class="px-4 pb-4">
<WidgetGrid widgets={section.widgets} {allApps} cardSize={effectiveCardSize} />
<WidgetGrid
widgets={section.widgets}
sectionId={section.id}
{allApps}
cardSize={effectiveCardSize}
/>
</div>
</SectionCollapsible>
</div>
+169 -24
View File
@@ -1,38 +1,183 @@
<script lang="ts">
import { t } from 'svelte-i18n';
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 {
sectionId: string;
title: string;
icon: string | null;
expanded: boolean;
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>
<button
type="button"
onclick={onToggle}
class="flex w-full items-center gap-2 rounded-t-xl px-4 py-3 text-left transition-colors hover:bg-accent/30"
>
<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>
{#if icon}
<DynamicIcon name={icon} size={18} />
<div class="flex w-full items-center gap-2 rounded-t-xl px-4 py-3">
{#if editMode.active}
<!-- Edit mode: drag handle -->
<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">
<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>
{/if}
<span class="font-medium text-foreground">{title}</span>
</button>
<!-- Expand/collapse toggle -->
<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,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>
+4 -1
View File
@@ -4,7 +4,7 @@
interface NoteConfig {
content: string;
format: 'markdown' | 'text';
format: 'markdown' | 'text' | 'html';
}
interface Props {
@@ -28,6 +28,9 @@
.replace(/\n/g, '<br>')
);
}
if (config.format === 'html') {
return DOMPurify.sanitize(config.content);
}
const raw = marked.parse(config.content, { async: false }) as string;
return DOMPurify.sanitize(raw);
});
@@ -0,0 +1,472 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import { tick } from 'svelte';
interface Props {
widgetType: string;
initialConfig?: Record<string, unknown>;
apps?: Array<{ id: string; name: string }>;
mode: 'create' | 'edit';
onSave: (config: Record<string, unknown>) => void;
onCancel: () => void;
}
let { widgetType, initialConfig = {}, apps = [], mode, onSave, onCancel }: Props = $props();
// -- 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>
<select bind:value={appId} class={inputClass} bind:this={firstInput}>
<option value="">Select an app...</option>
{#each apps as app}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</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">&times;</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">&times;</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[] = [
{ 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[] = [
@@ -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}
+119 -5
View File
@@ -2,6 +2,10 @@
import { t } from 'svelte-i18n';
import WidgetRenderer from './WidgetRenderer.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';
interface AppData {
@@ -25,11 +29,17 @@
interface Props {
widgets: WidgetData[];
sectionId?: string;
allApps?: AppData[];
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
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';
}
});
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 })));
</script>
{#if widgets.length === 0}
{#if widgets.length === 0 && !editMode.active}
<p class="text-sm text-muted-foreground">{$t('widget.no_widgets')}</p>
{:else}
<div class={gridClass}>
{#each widgets as widget (widget.id)}
{@const isFullWidth = fullWidthTypes.has(widget.type)}
<div class={isFullWidth ? fullWidthClass : ''}>
<WidgetContainer>
<WidgetRenderer {widget} {allApps} {cardSize} />
</WidgetContainer>
{#if editMode.active}
{#if editingWidgetId === widget.id}
<!-- 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>
{/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>
{/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
View File
@@ -356,5 +356,48 @@
"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_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
View File
@@ -332,5 +332,48 @@
"install.title": "Установить приложение",
"install.description": "Добавьте Web App Launcher на главный экран для быстрого доступа.",
"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": "Закрыть"
}
+186
View File
@@ -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();
+1 -1
View File
@@ -162,7 +162,7 @@ export const bookmarkWidgetConfigSchema = z.object({
export const noteWidgetConfigSchema = z.object({
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({
@@ -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 });
}
};
+120 -3
View File
@@ -8,8 +8,11 @@
import BoardThemeProvider from '$lib/components/board/BoardThemeProvider.svelte';
import CustomCssInjector from '$lib/components/layout/CustomCssInjector.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 { setContext } from 'svelte';
import { editMode } from '$lib/stores/editMode.svelte.js';
import { setContext, onMount, onDestroy } from 'svelte';
let { data }: { data: PageData } = $props();
@@ -19,8 +22,43 @@
setContext('appHistories', data.appHistories ?? {});
let showShareDialog = $state(false);
let showBoardProperties = $state(false);
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) {
guestToggleError = '';
try {
@@ -39,8 +77,55 @@
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>
<svelte:window onkeydown={handleKeydown} />
<svelte:head>
<title>{data.board.name}{$t('app_title')}</title>
</svelte:head>
@@ -60,7 +145,7 @@
<CustomCssInjector css={data.board.customCss} />
{/if}
<div class="p-6">
<div class="p-6" class:edit-mode-active={editMode.active}>
<div class="mx-auto max-w-7xl">
<BoardHeader
name={data.board.name}
@@ -75,9 +160,32 @@
<p class="mb-2 text-sm text-destructive">{guestToggleError}</p>
{/if}
<Board sections={data.board.sections} allApps={data.allApps} {boardCardSize} />
<Board
sections={data.board.sections}
allApps={data.allApps}
{boardCardSize}
/>
</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>
{#if showShareDialog && data.canEdit}
@@ -91,3 +199,12 @@
onGuestToggle={handleGuestToggle}
/>
{/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>
+18 -1
View File
@@ -186,8 +186,25 @@
</div>
{/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">
<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
href="/boards/{data.board.id}"
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"