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:
@@ -0,0 +1,59 @@
|
||||
# Feature Context: Inline Dashboard Editing
|
||||
|
||||
## Configuration
|
||||
- **Development mode:** Automated
|
||||
- **Execution mode:** Direct
|
||||
- **Strategy:** Big Bang
|
||||
- **Build:** `npm run build`
|
||||
- **Test:** `npm run test`
|
||||
- **Lint:** `npm run lint`
|
||||
- **Dev server:** `npm run dev` (port: 5173)
|
||||
|
||||
## Current State
|
||||
Starting fresh. The board view page (`/boards/[boardId]`) is read-only.
|
||||
The edit page (`/boards/[boardId]/edit`) is a separate form-based page.
|
||||
|
||||
## Key Architecture Notes
|
||||
- SvelteKit 2 + Svelte 5 (runes: $state, $derived, $props)
|
||||
- Prisma ORM with SQLite
|
||||
- Tailwind CSS v4
|
||||
- `svelte-dnd-action` for drag-and-drop
|
||||
- `lucide-svelte` for icons
|
||||
- `bits-ui` for UI primitives
|
||||
- Widget configs stored as JSON strings in `Widget.config`
|
||||
- Each widget type has Zod validation in `src/lib/utils/validators.ts`
|
||||
- Existing form actions on edit page: ?/updateBoard, ?/addSection, ?/deleteSection, ?/updateSection, ?/addWidget, ?/deleteWidget
|
||||
- Board view components: Board.svelte → Section.svelte → WidgetGrid.svelte → WidgetRenderer.svelte → [TypeWidget].svelte
|
||||
|
||||
## Temporary Workarounds
|
||||
(none yet)
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
- Phase 3 (widget overlay) depends on Phase 1 (edit mode state)
|
||||
- Phase 4 (config panels) depends on Phase 3 (overlay triggers)
|
||||
- Phase 6 (add widget) depends on Phase 4 (config panel infrastructure)
|
||||
- Phase 7 (DnD) depends on Phase 1 (edit mode gate)
|
||||
- Phase 8 (batch save) depends on Phases 1-7 (all accumulated changes)
|
||||
- Phase 9 (board properties) depends on Phase 2 (toolbar trigger)
|
||||
- Phase 10 (migration) depends on all previous phases
|
||||
|
||||
## Deferred Work
|
||||
(none yet)
|
||||
|
||||
## Failed Approaches
|
||||
(none yet)
|
||||
|
||||
## Review Findings Log
|
||||
(none yet)
|
||||
|
||||
## Phase Execution Log
|
||||
| Phase | Agent Used | Test Writer | Parallel | Notes |
|
||||
|-------|-----------|-------------|----------|-------|
|
||||
| — | — | — | — | — |
|
||||
|
||||
## Environment & Runtime Notes
|
||||
- Windows 10, Git Bash
|
||||
- Node.js project with Vite dev server
|
||||
|
||||
## Implementation Notes
|
||||
(none yet)
|
||||
@@ -0,0 +1,55 @@
|
||||
# Feature: Inline Dashboard Editing (Edit Mode)
|
||||
|
||||
**Branch:** `feature/inline-dashboard-editing`
|
||||
**Base branch:** `master`
|
||||
**Created:** 2026-04-02
|
||||
**Status:** 🟡 In Progress
|
||||
**Strategy:** Big Bang
|
||||
**Mode:** Automated
|
||||
**Execution:** Direct
|
||||
|
||||
## Summary
|
||||
Replace the disconnected board edit page with a WYSIWYG inline editing experience.
|
||||
Users toggle edit mode directly on the board view — widgets show edit/delete overlays,
|
||||
"+" buttons appear for adding widgets and sections, drag-and-drop works across sections,
|
||||
and all changes accumulate as a batch save. The board looks exactly as it will when
|
||||
saved, at all times.
|
||||
|
||||
## Build & Test Commands
|
||||
- **Build:** `npm run build`
|
||||
- **Test:** `npm run test`
|
||||
- **Lint:** `npm run lint`
|
||||
|
||||
## Phases
|
||||
|
||||
- [ ] Phase 1: Edit Mode State Infrastructure [domain: frontend] → [subplan](./phase-1-edit-mode-state.md)
|
||||
- [ ] Phase 2: Floating Edit Toolbar [domain: frontend] → [subplan](./phase-2-floating-toolbar.md)
|
||||
- [ ] Phase 3: Widget Edit Overlay [domain: frontend] → [subplan](./phase-3-widget-overlay.md)
|
||||
- [ ] Phase 4: Inline Widget Configuration Panels [domain: frontend] → [subplan](./phase-4-widget-config-panels.md)
|
||||
- [ ] Phase 5: Section Inline Editing [domain: frontend] → [subplan](./phase-5-section-editing.md)
|
||||
- [ ] Phase 6: Add Widget Inline ("+" Buttons) [domain: frontend] → [subplan](./phase-6-add-widget-inline.md)
|
||||
- [ ] Phase 7: Drag-and-Drop Enhancements [domain: frontend] → [subplan](./phase-7-dnd-enhancements.md)
|
||||
- [ ] Phase 8: Optimistic Updates & Batch Save [domain: fullstack] → [subplan](./phase-8-batch-save.md)
|
||||
- [ ] Phase 9: Board Properties Quick Panel [domain: frontend] → [subplan](./phase-9-board-properties-panel.md)
|
||||
- [ ] Phase 10: Legacy Edit Page Migration & Polish [domain: fullstack] → [subplan](./phase-10-migration-polish.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: Edit Mode State | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 2: Floating Toolbar | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 3: Widget Overlay | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 4: Widget Config Panels | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 5: Section Editing | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 6: Add Widget Inline | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 7: DnD Enhancements | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 8: Batch Save | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 9: Board Properties Panel | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 10: Migration & Polish | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
- [ ] Comprehensive code review
|
||||
- [ ] Full build passes
|
||||
- [ ] Full test suite passes
|
||||
- [ ] Merged to `master`
|
||||
@@ -0,0 +1,47 @@
|
||||
# Phase 1: Edit Mode State Infrastructure
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Create the foundational edit mode state management and toggle mechanism that all subsequent phases build upon.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `src/lib/stores/editMode.svelte.ts` with state: `{ active, boardId, dirty, changeCount }`
|
||||
- [ ] Export functions: `enterEditMode(boardId)`, `exitEditMode()`, `markDirty()`, `resetDirty()`
|
||||
- [ ] Add "Edit Mode" toggle button to `BoardHeader.svelte` (replaces the current "Edit" link to `/boards/[id]/edit`)
|
||||
- [ ] When toggled ON: set edit mode active, show visual indicator (subtle board border glow or tint)
|
||||
- [ ] When toggled OFF: if dirty, show confirmation dialog "Discard unsaved changes?"
|
||||
- [ ] Register keyboard shortcut `Ctrl+E` / `Cmd+E` to toggle edit mode
|
||||
- [ ] Pass edit mode state as Svelte context from board view page
|
||||
- [ ] Add `editMode` context consumer helpers for child components
|
||||
- [ ] Visual indicator: board gets a subtle colored top-bar or border when in edit mode
|
||||
|
||||
## Files to Modify/Create
|
||||
- `src/lib/stores/editMode.svelte.ts` — new store
|
||||
- `src/lib/components/board/BoardHeader.svelte` — replace Edit link with toggle button
|
||||
- `src/routes/boards/[boardId]/+page.svelte` — provide edit mode context, visual indicators
|
||||
|
||||
## Acceptance Criteria
|
||||
- Clicking the toggle enters/exits edit mode
|
||||
- Ctrl+E toggles edit mode
|
||||
- Board view page visually indicates edit mode is active
|
||||
- Child components can read edit mode state via context
|
||||
- Dirty state tracking works (increments on markDirty, resets on save/discard)
|
||||
|
||||
## Notes
|
||||
- Use Svelte 5 runes ($state, $derived) for the store
|
||||
- The keyboard shortcut must not conflict with existing shortcuts
|
||||
- Guest users / users without edit permission must NOT see the toggle
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completing this phase -->
|
||||
@@ -0,0 +1,54 @@
|
||||
# Phase 10: Legacy Edit Page Migration & Polish
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Migrate remaining functionality from the legacy edit page, add polish, transitions, and ensure full accessibility.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Redirect `/boards/[id]/edit` to `/boards/[id]?edit=true` (auto-enters edit mode)
|
||||
- [ ] Handle `?edit=true` query param on board view page to auto-enter edit mode
|
||||
- [ ] Migrate permission management: add permissions editor accessible from edit mode (or link to legacy page as "Advanced Settings")
|
||||
- [ ] Add smooth transition animations between view and edit modes
|
||||
- [ ] Keyboard navigation: Tab through edit controls, Enter to confirm, Escape to cancel/close
|
||||
- [ ] ARIA labels on all edit controls (buttons, overlays, panels)
|
||||
- [ ] Focus management: auto-focus appropriate elements when panels open
|
||||
- [ ] Focus trap in modals/panels
|
||||
- [ ] Screen reader announcements for mode changes ("Edit mode enabled", "Widget deleted")
|
||||
- [ ] Ensure all existing edit page functionality is accessible through inline UI
|
||||
- [ ] Polish: loading states, error boundaries, edge case handling
|
||||
- [ ] Mobile responsiveness: touch-friendly edit controls, appropriate sizing
|
||||
- [ ] Verify no regressions with guest access (guests should never see edit controls)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `src/routes/boards/[boardId]/edit/+page.server.ts` — add redirect
|
||||
- `src/routes/boards/[boardId]/+page.svelte` — handle ?edit=true, transitions
|
||||
- Various components — accessibility attributes, animations
|
||||
- `src/lib/components/board/BoardAccessControl.svelte` — integrate or link from edit mode
|
||||
|
||||
## Acceptance Criteria
|
||||
- `/boards/[id]/edit` redirects to inline edit mode
|
||||
- All functionality from legacy edit page is accessible
|
||||
- Keyboard navigation works throughout edit mode
|
||||
- Screen readers can use edit mode
|
||||
- Transitions are smooth and non-jarring
|
||||
- Mobile experience is usable
|
||||
- Guest users cannot access edit features
|
||||
|
||||
## Notes
|
||||
- Consider keeping legacy edit page temporarily as "Advanced Edit" for power users
|
||||
- Permission management is complex — may be better as a dedicated panel than inline
|
||||
- Test with different board sizes (empty, 1 section, 10+ sections with many widgets)
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completing this phase -->
|
||||
@@ -0,0 +1,50 @@
|
||||
# Phase 2: Floating Edit Toolbar
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Create a sticky floating toolbar that appears when edit mode is active, providing quick access to common editing actions.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `src/lib/components/board/EditToolbar.svelte`
|
||||
- [ ] Toolbar actions: Save All, Discard, Add Section, Board Settings (gear), Exit Edit Mode
|
||||
- [ ] Show unsaved change count badge on Save button
|
||||
- [ ] Position: fixed at bottom-center of viewport (floating pill shape)
|
||||
- [ ] Entrance animation: slide up from bottom with fade
|
||||
- [ ] Exit animation: slide down with fade
|
||||
- [ ] Responsive: collapses to icon-only on small screens
|
||||
- [ ] Only renders when edit mode is active
|
||||
- [ ] Wire "Exit Edit Mode" to the store's exitEditMode()
|
||||
- [ ] Wire "Add Section" to emit event (handled in Phase 6)
|
||||
- [ ] Wire "Board Settings" to emit event (handled in Phase 9)
|
||||
- [ ] Wire "Save All" to emit event (handled in Phase 8)
|
||||
- [ ] Wire "Discard" to revert all changes and exit edit mode
|
||||
|
||||
## Files to Modify/Create
|
||||
- `src/lib/components/board/EditToolbar.svelte` — new component
|
||||
- `src/routes/boards/[boardId]/+page.svelte` — mount toolbar when edit mode active
|
||||
|
||||
## Acceptance Criteria
|
||||
- Toolbar appears/disappears smoothly with edit mode toggle
|
||||
- All buttons are present and visually clear
|
||||
- Change count badge updates reactively
|
||||
- Responsive layout works on mobile
|
||||
- Toolbar doesn't obscure board content (proper z-index, positioning)
|
||||
|
||||
## Notes
|
||||
- Use lucide-svelte icons for toolbar buttons
|
||||
- z-index must be above board content but below modals/dialogs
|
||||
- "Save" and "Discard" will be wired to real logic in Phase 8
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completing this phase -->
|
||||
@@ -0,0 +1,48 @@
|
||||
# Phase 3: Widget Edit Overlay
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Add hover overlays to every widget when in edit mode, showing edit/delete/drag controls without obscuring the widget content.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `src/lib/components/widget/WidgetEditOverlay.svelte`
|
||||
- [ ] Overlay appears on hover over a widget in edit mode
|
||||
- [ ] Controls: pencil icon (top-right), trash icon (top-right, secondary), drag handle (top-left)
|
||||
- [ ] Semi-transparent backdrop on hover (e.g., bg-black/5)
|
||||
- [ ] Pencil click emits `onEdit(widgetId)` event
|
||||
- [ ] Trash click shows inline confirmation ("Delete?" with Yes/No), then emits `onDelete(widgetId)`
|
||||
- [ ] Drag handle integrated with svelte-dnd-action (prepared for Phase 7)
|
||||
- [ ] Wrap each widget in WidgetEditOverlay in `WidgetGrid.svelte` when edit mode is active
|
||||
- [ ] Overlay transitions: fade in on hover, fade out on leave
|
||||
- [ ] Overlay does NOT block widget interaction when not hovered (pointer-events)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `src/lib/components/widget/WidgetEditOverlay.svelte` — new component
|
||||
- `src/lib/components/widget/WidgetGrid.svelte` — wrap widgets conditionally
|
||||
|
||||
## Acceptance Criteria
|
||||
- Hovering over a widget in edit mode shows the overlay
|
||||
- Overlay has pencil, trash, and drag handle controls
|
||||
- Controls are clickable and emit correct events
|
||||
- Overlay does not appear when NOT in edit mode
|
||||
- Widget content remains visible through the overlay
|
||||
|
||||
## Notes
|
||||
- Keep overlay minimal — don't overwhelm the widget visually
|
||||
- Trash confirmation should be inline (not a browser confirm dialog)
|
||||
- The actual edit panel opening (pencil) is Phase 4
|
||||
- The actual delete logic is Phase 8
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completing this phase -->
|
||||
@@ -0,0 +1,63 @@
|
||||
# Phase 4: Inline Widget Configuration Panels
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Create type-specific configuration panels that open inline when the user clicks the edit (pencil) button on a widget, allowing real-time config editing with live preview.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `src/lib/components/widget/WidgetConfigPanel.svelte` — container/router for type-specific panels
|
||||
- [ ] Create config panel components for each widget type (or a dynamic form approach):
|
||||
- App: app selector dropdown
|
||||
- Bookmark: url, label, icon, description
|
||||
- Note/Markdown: inline content editor
|
||||
- Embed: url, height, sandbox
|
||||
- Status: multi-app selector, label
|
||||
- Clock: timezone, style, weather toggle, coordinates
|
||||
- System Stats: source url/type, metrics, refresh interval
|
||||
- RSS: feed url, max items, show summary
|
||||
- Calendar: iCal URLs, days ahead
|
||||
- Metric: label, source, value/url/query, unit, refresh
|
||||
- Link Group: links array editor, collapsible toggle
|
||||
- Camera: stream url, type, refresh, aspect ratio
|
||||
- Integration: app selector, endpoint selector, refresh
|
||||
- [ ] Panel opens as a popover/slide-out anchored to the widget
|
||||
- [ ] Pre-populate fields with current widget config
|
||||
- [ ] Live preview: changes update the widget rendering in real-time (optimistic, local state)
|
||||
- [ ] Save/Cancel buttons per panel
|
||||
- [ ] Save stores changes in the edit mode changeset (not persisted until batch save in Phase 8)
|
||||
- [ ] Cancel reverts to original config
|
||||
- [ ] Auto-focus first field when panel opens
|
||||
- [ ] Close panel on Escape key
|
||||
|
||||
## Files to Modify/Create
|
||||
- `src/lib/components/widget/WidgetConfigPanel.svelte` — new panel router
|
||||
- `src/lib/components/widget/config/` — new directory for type-specific config forms
|
||||
- `src/lib/components/widget/WidgetEditOverlay.svelte` — wire pencil to open config panel
|
||||
- `src/lib/components/widget/WidgetRenderer.svelte` — support config override from edit state
|
||||
|
||||
## Acceptance Criteria
|
||||
- Clicking pencil on any widget type opens the correct config panel
|
||||
- Fields are pre-populated with current values
|
||||
- Changes preview live on the widget
|
||||
- Save adds to changeset, Cancel reverts
|
||||
- Panel closes on Save, Cancel, or Escape
|
||||
- All 14 widget types have config support
|
||||
|
||||
## Notes
|
||||
- Reuse Zod schemas from `src/lib/utils/validators.ts` for field validation
|
||||
- Consider a generic form approach for simple types (key-value pairs) vs custom for complex ones (link_group links array)
|
||||
- Panel positioning: use a popover that doesn't overflow viewport
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completing this phase -->
|
||||
@@ -0,0 +1,47 @@
|
||||
# Phase 5: Section Inline Editing
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Make section headers editable inline in edit mode — title, icon, card size, expand default, delete.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Modify `SectionHeader.svelte` to show edit controls when edit mode is active
|
||||
- [ ] Pencil icon on section header — click to toggle inline editing of title and icon
|
||||
- [ ] Inline title editing: click title text to replace with input field, Enter to confirm, Escape to cancel
|
||||
- [ ] Icon picker for section icon (reuse `AppIconPicker` or simplified version)
|
||||
- [ ] Card size dropdown override (compact/medium/large/inherit)
|
||||
- [ ] Toggle for `isExpandedByDefault`
|
||||
- [ ] Delete section button with confirmation ("Delete section 'X' and its N widgets?")
|
||||
- [ ] Drag handle for section reordering (left side of header, visible only in edit mode)
|
||||
- [ ] All changes stored in edit mode changeset
|
||||
|
||||
## Files to Modify/Create
|
||||
- `src/lib/components/section/SectionHeader.svelte` — add edit controls
|
||||
- `src/lib/components/section/Section.svelte` — pass edit mode state
|
||||
- `src/lib/components/section/SectionEditControls.svelte` — new, extracted edit controls
|
||||
|
||||
## Acceptance Criteria
|
||||
- Section title is editable inline in edit mode
|
||||
- Section icon is changeable via picker
|
||||
- Card size override works
|
||||
- Delete shows confirmation with widget count
|
||||
- Changes accumulate in changeset (not persisted until Save)
|
||||
- Controls hidden when not in edit mode
|
||||
|
||||
## Notes
|
||||
- Section drag-and-drop reorder is handled further in Phase 7
|
||||
- Delete confirmation should show actual widget count from current state
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completing this phase -->
|
||||
@@ -0,0 +1,51 @@
|
||||
# Phase 6: Add Widget Inline ("+" Buttons)
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Add prominent "+" buttons for adding widgets to sections and adding new sections, all inline on the board view.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `src/lib/components/widget/AddWidgetButton.svelte` — the "+" button shown at end of widget grid
|
||||
- [ ] Create `src/lib/components/board/AddSectionDivider.svelte` — subtle divider between sections with "+" button
|
||||
- [ ] Widget type picker: grid of icons with labels (App, Bookmark, Note, Embed, Status, Clock, etc.)
|
||||
- [ ] Clicking a type opens the config panel from Phase 4 for the new widget
|
||||
- [ ] New widget appears immediately in grid as a skeleton/placeholder while being configured
|
||||
- [ ] "Add Section" shows minimal inline form: title input + optional icon + confirm button
|
||||
- [ ] New section appears immediately in the board with empty widget grid
|
||||
- [ ] All additions tracked in edit mode changeset (temporary IDs until batch save)
|
||||
- [ ] "Add Section" button also available from the floating toolbar (Phase 2)
|
||||
|
||||
## Files to Modify/Create
|
||||
- `src/lib/components/widget/AddWidgetButton.svelte` — new
|
||||
- `src/lib/components/widget/WidgetTypePicker.svelte` — new, type selection grid
|
||||
- `src/lib/components/board/AddSectionDivider.svelte` — new
|
||||
- `src/lib/components/board/AddSectionForm.svelte` — new, inline section creation
|
||||
- `src/lib/components/widget/WidgetGrid.svelte` — append AddWidgetButton in edit mode
|
||||
- `src/lib/components/board/Board.svelte` — insert AddSectionDivider between sections
|
||||
|
||||
## Acceptance Criteria
|
||||
- "+" button visible at end of each section's widget grid in edit mode
|
||||
- "+" section divider visible between sections in edit mode
|
||||
- Type picker shows all available widget types with icons
|
||||
- Selecting a type opens config panel for new widget
|
||||
- New widgets/sections appear immediately (optimistic)
|
||||
- Hidden when not in edit mode
|
||||
|
||||
## Notes
|
||||
- Use temporary client-side IDs (e.g., `temp-${crypto.randomUUID()}`) for new items
|
||||
- Widget type icons should use lucide-svelte icons matching each type
|
||||
- Empty sections should still show the "+" add widget button
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completing this phase -->
|
||||
@@ -0,0 +1,48 @@
|
||||
# Phase 7: Drag-and-Drop Enhancements
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Enhance drag-and-drop to support cross-section widget moves, section reordering, and visual drop zone indicators — all gated behind edit mode.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Enable widget drag-and-drop ONLY in edit mode (disable in view mode)
|
||||
- [ ] Cross-section widget drag: allow dragging a widget from one section to another
|
||||
- [ ] Visual drop zones: highlight target section/position when dragging
|
||||
- [ ] Section-level drag-and-drop with visual indicators (reorder sections)
|
||||
- [ ] Drag handles only visible in edit mode
|
||||
- [ ] Track all reorder/move changes in edit mode changeset
|
||||
- [ ] Handle edge cases: dragging to empty sections, dragging last widget out of section
|
||||
- [ ] Smooth animations during drag operations
|
||||
|
||||
## Files to Modify/Create
|
||||
- `src/lib/components/widget/WidgetGrid.svelte` — enable DnD only in edit mode, cross-section support
|
||||
- `src/lib/components/board/Board.svelte` — section-level DnD in edit mode
|
||||
- `src/lib/components/section/Section.svelte` — drop zone indicators
|
||||
- `src/lib/components/widget/WidgetEditOverlay.svelte` — drag handle activation
|
||||
|
||||
## Acceptance Criteria
|
||||
- Widgets can be dragged between sections in edit mode
|
||||
- Sections can be reordered by dragging in edit mode
|
||||
- Drop zones highlight during drag
|
||||
- No drag-and-drop functionality in view mode
|
||||
- All moves tracked in changeset (not persisted until Save)
|
||||
- Animations are smooth
|
||||
|
||||
## Notes
|
||||
- `svelte-dnd-action` supports cross-container DnD via shared `dropTargetStyle`
|
||||
- Need to handle `sectionId` changes when widgets move between sections
|
||||
- Existing DraggableBoard/DraggableSection are used on edit page — may adapt or replace
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completing this phase -->
|
||||
@@ -0,0 +1,68 @@
|
||||
# Phase 8: Optimistic Updates & Batch Save
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Implement the changeset accumulation system and a batch API endpoint that persists all edit mode changes in a single transaction.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Design changeset data structure in editMode store:
|
||||
- `widgetUpdates: Map<id, configChanges>`
|
||||
- `widgetAdds: Array<{tempId, sectionId, type, config, order}>`
|
||||
- `widgetDeletes: Set<id>`
|
||||
- `widgetMoves: Map<id, {fromSectionId, toSectionId, newOrder}>`
|
||||
- `sectionUpdates: Map<id, changes>`
|
||||
- `sectionAdds: Array<{tempId, title, icon, order}>`
|
||||
- `sectionDeletes: Set<id>`
|
||||
- `sectionReorders: Array<{id, newOrder}>`
|
||||
- `boardUpdates: Partial<BoardProps>`
|
||||
- [ ] Create `POST /api/boards/[id]/batch-update` endpoint
|
||||
- [ ] Endpoint accepts the full changeset as JSON body
|
||||
- [ ] Server-side: validate all changes, execute in a single Prisma transaction
|
||||
- [ ] Server-side: handle temp IDs → real IDs mapping for new items
|
||||
- [ ] Server-side: authorization check (user must have edit permission)
|
||||
- [ ] Wire "Save All" toolbar button to serialize changeset and call batch endpoint
|
||||
- [ ] On success: clear changeset, reset dirty state, broadcast to other tabs, invalidateAll()
|
||||
- [ ] On failure: show error, keep changeset intact (no data loss)
|
||||
- [ ] Wire "Discard" toolbar button to reset changeset, revert optimistic UI, exit edit mode
|
||||
- [ ] Wire widget delete (from Phase 3 overlay) to add to changeset
|
||||
- [ ] Wire widget config save (from Phase 4) to add to changeset
|
||||
- [ ] Wire section changes (from Phase 5) to add to changeset
|
||||
- [ ] Wire new items (from Phase 6) to add to changeset
|
||||
- [ ] Wire DnD moves (from Phase 7) to add to changeset
|
||||
|
||||
## Files to Modify/Create
|
||||
- `src/lib/stores/editMode.svelte.ts` — add changeset state and mutation functions
|
||||
- `src/routes/api/boards/[id]/batch-update/+server.ts` — new batch API endpoint
|
||||
- `src/lib/components/board/EditToolbar.svelte` — wire Save/Discard to real logic
|
||||
- `src/lib/components/widget/WidgetEditOverlay.svelte` — wire delete to changeset
|
||||
- `src/lib/components/widget/WidgetConfigPanel.svelte` — wire save to changeset
|
||||
- Various components — connect to changeset mutations
|
||||
|
||||
## Acceptance Criteria
|
||||
- All changes from Phases 3-7 accumulate in the changeset
|
||||
- "Save All" sends one HTTP request with all changes
|
||||
- Server processes all changes in a single transaction
|
||||
- On success: board reloads with persisted state
|
||||
- On failure: changes are preserved, error is shown
|
||||
- "Discard" reverts everything to pre-edit state
|
||||
- Change count in toolbar updates reactively
|
||||
|
||||
## Notes
|
||||
- Batch endpoint must be idempotent-safe (temp IDs prevent double-creates)
|
||||
- Widget order values must be recalculated during batch save
|
||||
- Prisma `$transaction` for atomicity
|
||||
- Consider payload size limits for very large boards
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completing this phase -->
|
||||
@@ -0,0 +1,54 @@
|
||||
# Phase 9: Board Properties Quick Panel
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Create a side panel / modal accessible from the edit toolbar's gear icon for editing board-level properties with live preview.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `src/lib/components/board/BoardPropertiesPanel.svelte`
|
||||
- [ ] Panel opens from toolbar gear icon as a slide-out side panel (right side)
|
||||
- [ ] Board properties: name, description, icon
|
||||
- [ ] Theme settings: themeHue slider (0-360), themeSaturation slider (0-100)
|
||||
- [ ] Background type selector: mesh, particles, aurora, wallpaper, none
|
||||
- [ ] Wallpaper settings: upload, URL input, blur slider, overlay opacity slider
|
||||
- [ ] Card size selector: compact, medium, large
|
||||
- [ ] Custom CSS editor (textarea or code editor)
|
||||
- [ ] Guest access toggle
|
||||
- [ ] All changes preview live on the board behind the panel
|
||||
- [ ] Changes stored in edit mode changeset (boardUpdates)
|
||||
- [ ] Close panel button, Escape to close
|
||||
- [ ] Panel has its own scroll if content overflows
|
||||
|
||||
## Files to Modify/Create
|
||||
- `src/lib/components/board/BoardPropertiesPanel.svelte` — new
|
||||
- `src/lib/components/board/EditToolbar.svelte` — wire gear icon to open panel
|
||||
- `src/routes/boards/[boardId]/+page.svelte` — mount panel, apply live preview overrides
|
||||
- `src/lib/components/board/BoardThemeProvider.svelte` — support preview overrides
|
||||
|
||||
## Acceptance Criteria
|
||||
- Gear icon opens the properties panel
|
||||
- All board-level settings are editable
|
||||
- Changes preview live on the board
|
||||
- Wallpaper upload works with live preview
|
||||
- Theme sliders update board colors in real-time
|
||||
- Changes accumulate in changeset
|
||||
- Panel closes on button click or Escape
|
||||
|
||||
## Notes
|
||||
- Live preview means overriding BoardThemeProvider props with unsaved values
|
||||
- Wallpaper upload may need special handling (uploaded immediately to server, URL stored in changeset)
|
||||
- Custom CSS injection should be sandboxed to board scope
|
||||
|
||||
## Review Checklist
|
||||
- [ ] All tasks completed
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] No unintended side effects
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (new + existing)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Filled in after completing this phase -->
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||
import AddSectionForm from './AddSectionForm.svelte';
|
||||
|
||||
interface Props {
|
||||
order: number;
|
||||
}
|
||||
|
||||
let { order }: Props = $props();
|
||||
|
||||
let showForm = $state(false);
|
||||
|
||||
function handleAdd(title: string, icon: string | null) {
|
||||
editMode.addSection({
|
||||
tempId: `temp-section-${crypto.randomUUID()}`,
|
||||
title,
|
||||
icon,
|
||||
order,
|
||||
isExpandedByDefault: true
|
||||
});
|
||||
showForm = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="group relative flex items-center justify-center py-1">
|
||||
{#if showForm}
|
||||
<AddSectionForm
|
||||
onSubmit={handleAdd}
|
||||
onCancel={() => { showForm = false; }}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showForm = true; }}
|
||||
class="flex items-center gap-1.5 rounded-lg border border-dashed border-border px-3 py-1.5 text-xs text-muted-foreground opacity-0 transition-all hover:border-primary hover:text-primary group-hover:opacity-100"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
{$t('board.add_section') ?? 'Add Section'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { tick } from 'svelte';
|
||||
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
|
||||
|
||||
interface Props {
|
||||
onSubmit: (title: string, icon: string | null) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { onSubmit, onCancel }: Props = $props();
|
||||
|
||||
let title = $state('');
|
||||
let icon = $state('');
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
tick().then(() => inputEl?.focus());
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
onSubmit(trimmed, icon.trim() || null);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2 rounded-xl border border-border bg-card p-3 shadow-sm">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
bind:value={title}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={$t('board.section_title') ?? 'Section title...'}
|
||||
class="flex-1 rounded-lg border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
/>
|
||||
<IconPickerButton value={icon} onchange={(v) => { icon = v; }} size="sm" />
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSubmit}
|
||||
disabled={!title.trim()}
|
||||
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{$t('common.add') ?? 'Add'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{$t('common.cancel') ?? 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
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}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
onExit: () => void;
|
||||
onAddSection: () => void;
|
||||
onBoardSettings: () => void;
|
||||
}
|
||||
|
||||
let { onSave, onDiscard, onExit, onAddSection, onBoardSettings }: Props = $props();
|
||||
|
||||
let showDiscardConfirm = $state(false);
|
||||
|
||||
function handleExit() {
|
||||
if (editMode.dirty) {
|
||||
showDiscardConfirm = true;
|
||||
} else {
|
||||
onExit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
showDiscardConfirm = false;
|
||||
onDiscard();
|
||||
}
|
||||
|
||||
function handleDiscardCancel() {
|
||||
showDiscardConfirm = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if editMode.active}
|
||||
<div
|
||||
class="fixed bottom-6 left-1/2 z-40 -translate-x-1/2"
|
||||
transition:fly={{ y: 60, duration: 250 }}
|
||||
>
|
||||
<!-- Toolbar pill -->
|
||||
<div class="flex items-center gap-1 rounded-2xl border border-border bg-card/95 px-2 py-1.5 shadow-xl backdrop-blur-sm">
|
||||
<!-- Save -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onSave}
|
||||
disabled={!editMode.dirty}
|
||||
class="relative flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors
|
||||
{editMode.dirty
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'text-muted-foreground opacity-50 cursor-not-allowed'}"
|
||||
>
|
||||
<!-- Check icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{$t('common.save') ?? 'Save'}</span>
|
||||
{#if editMode.changeCount > 0}
|
||||
<span class="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
|
||||
{editMode.changeCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="mx-0.5 h-6 w-px bg-border"></div>
|
||||
|
||||
<!-- Add Section -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAddSection}
|
||||
class="flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
title={$t('board.add_section') ?? 'Add Section'}
|
||||
>
|
||||
<!-- Plus icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{$t('board.add_section') ?? 'Section'}</span>
|
||||
</button>
|
||||
|
||||
<!-- Board Settings -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onBoardSettings}
|
||||
class="flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
title={$t('board.settings') ?? 'Board Settings'}
|
||||
>
|
||||
<!-- Settings icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{$t('board.settings') ?? 'Settings'}</span>
|
||||
</button>
|
||||
|
||||
<div class="mx-0.5 h-6 w-px bg-border"></div>
|
||||
|
||||
<!-- Discard -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleExit}
|
||||
disabled={!editMode.dirty}
|
||||
class="flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm transition-colors
|
||||
{editMode.dirty
|
||||
? 'text-destructive hover:bg-destructive/10'
|
||||
: 'text-muted-foreground opacity-50 cursor-not-allowed'}"
|
||||
title={$t('common.discard') ?? 'Discard Changes'}
|
||||
>
|
||||
<!-- Undo icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7v6h6" />
|
||||
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{$t('common.discard') ?? 'Discard'}</span>
|
||||
</button>
|
||||
|
||||
<!-- Exit Edit Mode -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleExit}
|
||||
class="flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
title={$t('board.exit_edit') ?? 'Exit Edit Mode'}
|
||||
>
|
||||
<!-- X icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{$t('board.exit_edit') ?? 'Done'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showDiscardConfirm}
|
||||
<ConfirmDialog
|
||||
title={$t('board.discard_title') ?? 'Discard Changes'}
|
||||
message={$t('board.discard_confirm') ?? 'Are you sure you want to discard all unsaved changes?'}
|
||||
confirmLabel={$t('common.discard') ?? 'Discard'}
|
||||
onConfirm={handleDiscard}
|
||||
onCancel={handleDiscardCancel}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -2,6 +2,8 @@
|
||||
import SectionHeader from './SectionHeader.svelte';
|
||||
import 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>
|
||||
|
||||
@@ -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,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">×</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Days Ahead ({calendarDaysAhead})</label>
|
||||
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" />
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'markdown'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.content') ?? 'Content'}</label>
|
||||
<textarea bind:value={markdownContent} rows="6" class="{inputClass} font-mono text-xs" bind:this={firstInput}></textarea>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'metric'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
|
||||
<input type="text" bind:value={metricLabel} class={inputClass} bind:this={firstInput} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Source</label>
|
||||
<select bind:value={metricSource} class={inputClass}>
|
||||
<option value="static">Static</option>
|
||||
<option value="json">JSON Endpoint</option>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if metricSource === 'static'}
|
||||
<div>
|
||||
<label class={labelClass}>Value</label>
|
||||
<input type="text" bind:value={metricValue} class={inputClass} />
|
||||
</div>
|
||||
{:else if metricSource === 'json'}
|
||||
<div>
|
||||
<label class={labelClass}>URL</label>
|
||||
<input type="url" bind:value={metricUrl} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>JSON Path</label>
|
||||
<input type="text" bind:value={metricJsonPath} placeholder="$.data.value" class={inputClass} />
|
||||
</div>
|
||||
{:else if metricSource === 'prometheus'}
|
||||
<div>
|
||||
<label class={labelClass}>URL</label>
|
||||
<input type="url" bind:value={metricUrl} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>PromQL Query</label>
|
||||
<input type="text" bind:value={metricQuery} class={inputClass} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class={labelClass}>Unit</label>
|
||||
<input type="text" bind:value={metricUnit} placeholder="%" class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({metricRefreshInterval}s)</label>
|
||||
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'link_group'}
|
||||
<div>
|
||||
<label class={labelClass}>Links</label>
|
||||
{#each linkGroupLinks as link, i}
|
||||
<div class="mb-1 flex items-center gap-1">
|
||||
<input type="text" bind:value={link.label} placeholder="Label" class="{inputClass} w-24" />
|
||||
<input type="url" bind:value={link.url} placeholder="URL" class="{inputClass} flex-1" />
|
||||
<input type="text" bind:value={link.icon} placeholder="Icon" class="{inputClass} w-16" />
|
||||
{#if linkGroupLinks.length > 1}
|
||||
<button type="button" onclick={() => removeLinkGroupLink(i)} class="text-destructive hover:text-destructive/80">×</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" onclick={addLinkGroupLink} class="text-xs text-primary hover:underline">+ Add Link</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" bind:checked={linkGroupCollapsible} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||
Collapsible
|
||||
</label>
|
||||
|
||||
{:else if widgetType === 'camera'}
|
||||
<div>
|
||||
<label class={labelClass}>Stream URL</label>
|
||||
<input type="url" bind:value={cameraStreamUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Type</label>
|
||||
<select bind:value={cameraType} class={inputClass}>
|
||||
<option value="image">Image</option>
|
||||
<option value="mjpeg">MJPEG</option>
|
||||
<option value="hls">HLS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({cameraRefreshInterval}s)</label>
|
||||
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Aspect Ratio</label>
|
||||
<select bind:value={cameraAspectRatio} class={inputClass}>
|
||||
<option value="16/9">16:9</option>
|
||||
<option value="4/3">4:3</option>
|
||||
<option value="1/1">1:1</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'integration'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
||||
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
|
||||
<option value="">Select app...</option>
|
||||
{#each apps as app}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Endpoint ID</label>
|
||||
<input type="text" bind:value={integrationEndpointId} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({integrationRefreshInterval}s)</label>
|
||||
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-3 flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<button type="button" onclick={onCancel}
|
||||
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent">
|
||||
{$t('common.cancel') ?? 'Cancel'}
|
||||
</button>
|
||||
<button type="button" onclick={handleSave}
|
||||
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
|
||||
{mode === 'create' ? ($t('common.add') ?? 'Add') : ($t('common.save') ?? 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +110,8 @@
|
||||
|
||||
const noteFormatItems: IconGridItem[] = [
|
||||
{ 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}
|
||||
@@ -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
@@ -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
@@ -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": "Закрыть"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user