From a6b09aae9c1f1f50a92bc1dd1857aa5a59b20fbb Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 3 Apr 2026 00:01:29 +0300 Subject: [PATCH 1/3] 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 --- plans/inline-dashboard-editing/CONTEXT.md | 59 +++ plans/inline-dashboard-editing/PLAN.md | 55 ++ .../phase-1-edit-mode-state.md | 47 ++ .../phase-10-migration-polish.md | 54 ++ .../phase-2-floating-toolbar.md | 50 ++ .../phase-3-widget-overlay.md | 48 ++ .../phase-4-widget-config-panels.md | 63 +++ .../phase-5-section-editing.md | 47 ++ .../phase-6-add-widget-inline.md | 51 ++ .../phase-7-dnd-enhancements.md | 48 ++ .../phase-8-batch-save.md | 68 +++ .../phase-9-board-properties-panel.md | 54 ++ .../components/board/AddSectionDivider.svelte | 44 ++ .../components/board/AddSectionForm.svelte | 62 +++ src/lib/components/board/Board.svelte | 118 ++++- src/lib/components/board/BoardHeader.svelte | 40 +- .../board/BoardPropertiesPanel.svelte | 210 ++++++++ src/lib/components/board/EditToolbar.svelte | 147 ++++++ src/lib/components/section/Section.svelte | 15 +- .../components/section/SectionHeader.svelte | 193 ++++++- src/lib/components/ui/ConfirmDialog.svelte | 75 +++ src/lib/components/ui/IconPickerButton.svelte | 169 +++++++ src/lib/components/widget/NoteWidget.svelte | 5 +- .../widget/WidgetConfigPanel.svelte | 472 ++++++++++++++++++ .../widget/WidgetCreationForm.svelte | 3 +- .../widget/WidgetEditOverlay.svelte | 85 ++++ src/lib/components/widget/WidgetGrid.svelte | 124 ++++- .../components/widget/WidgetTypePicker.svelte | 151 ++++++ src/lib/i18n/en.json | 45 +- src/lib/i18n/ru.json | 45 +- src/lib/stores/editMode.svelte.ts | 186 +++++++ src/lib/utils/validators.ts | 2 +- .../api/boards/[id]/batch-update/+server.ts | 222 ++++++++ src/routes/boards/[boardId]/+page.svelte | 123 ++++- src/routes/boards/[boardId]/edit/+page.svelte | 19 +- 35 files changed, 3148 insertions(+), 51 deletions(-) create mode 100644 plans/inline-dashboard-editing/CONTEXT.md create mode 100644 plans/inline-dashboard-editing/PLAN.md create mode 100644 plans/inline-dashboard-editing/phase-1-edit-mode-state.md create mode 100644 plans/inline-dashboard-editing/phase-10-migration-polish.md create mode 100644 plans/inline-dashboard-editing/phase-2-floating-toolbar.md create mode 100644 plans/inline-dashboard-editing/phase-3-widget-overlay.md create mode 100644 plans/inline-dashboard-editing/phase-4-widget-config-panels.md create mode 100644 plans/inline-dashboard-editing/phase-5-section-editing.md create mode 100644 plans/inline-dashboard-editing/phase-6-add-widget-inline.md create mode 100644 plans/inline-dashboard-editing/phase-7-dnd-enhancements.md create mode 100644 plans/inline-dashboard-editing/phase-8-batch-save.md create mode 100644 plans/inline-dashboard-editing/phase-9-board-properties-panel.md create mode 100644 src/lib/components/board/AddSectionDivider.svelte create mode 100644 src/lib/components/board/AddSectionForm.svelte create mode 100644 src/lib/components/board/BoardPropertiesPanel.svelte create mode 100644 src/lib/components/board/EditToolbar.svelte create mode 100644 src/lib/components/ui/ConfirmDialog.svelte create mode 100644 src/lib/components/ui/IconPickerButton.svelte create mode 100644 src/lib/components/widget/WidgetConfigPanel.svelte create mode 100644 src/lib/components/widget/WidgetEditOverlay.svelte create mode 100644 src/lib/components/widget/WidgetTypePicker.svelte create mode 100644 src/lib/stores/editMode.svelte.ts create mode 100644 src/routes/api/boards/[id]/batch-update/+server.ts diff --git a/plans/inline-dashboard-editing/CONTEXT.md b/plans/inline-dashboard-editing/CONTEXT.md new file mode 100644 index 0000000..5c8507e --- /dev/null +++ b/plans/inline-dashboard-editing/CONTEXT.md @@ -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) diff --git a/plans/inline-dashboard-editing/PLAN.md b/plans/inline-dashboard-editing/PLAN.md new file mode 100644 index 0000000..23bcec9 --- /dev/null +++ b/plans/inline-dashboard-editing/PLAN.md @@ -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` diff --git a/plans/inline-dashboard-editing/phase-1-edit-mode-state.md b/plans/inline-dashboard-editing/phase-1-edit-mode-state.md new file mode 100644 index 0000000..e8c5dfb --- /dev/null +++ b/plans/inline-dashboard-editing/phase-1-edit-mode-state.md @@ -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 + diff --git a/plans/inline-dashboard-editing/phase-10-migration-polish.md b/plans/inline-dashboard-editing/phase-10-migration-polish.md new file mode 100644 index 0000000..3e0d2e2 --- /dev/null +++ b/plans/inline-dashboard-editing/phase-10-migration-polish.md @@ -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 + diff --git a/plans/inline-dashboard-editing/phase-2-floating-toolbar.md b/plans/inline-dashboard-editing/phase-2-floating-toolbar.md new file mode 100644 index 0000000..8087c9d --- /dev/null +++ b/plans/inline-dashboard-editing/phase-2-floating-toolbar.md @@ -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 + diff --git a/plans/inline-dashboard-editing/phase-3-widget-overlay.md b/plans/inline-dashboard-editing/phase-3-widget-overlay.md new file mode 100644 index 0000000..dd62961 --- /dev/null +++ b/plans/inline-dashboard-editing/phase-3-widget-overlay.md @@ -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 + diff --git a/plans/inline-dashboard-editing/phase-4-widget-config-panels.md b/plans/inline-dashboard-editing/phase-4-widget-config-panels.md new file mode 100644 index 0000000..426aefe --- /dev/null +++ b/plans/inline-dashboard-editing/phase-4-widget-config-panels.md @@ -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 + diff --git a/plans/inline-dashboard-editing/phase-5-section-editing.md b/plans/inline-dashboard-editing/phase-5-section-editing.md new file mode 100644 index 0000000..a4826f6 --- /dev/null +++ b/plans/inline-dashboard-editing/phase-5-section-editing.md @@ -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 + diff --git a/plans/inline-dashboard-editing/phase-6-add-widget-inline.md b/plans/inline-dashboard-editing/phase-6-add-widget-inline.md new file mode 100644 index 0000000..f64e926 --- /dev/null +++ b/plans/inline-dashboard-editing/phase-6-add-widget-inline.md @@ -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 + diff --git a/plans/inline-dashboard-editing/phase-7-dnd-enhancements.md b/plans/inline-dashboard-editing/phase-7-dnd-enhancements.md new file mode 100644 index 0000000..6f8cdd8 --- /dev/null +++ b/plans/inline-dashboard-editing/phase-7-dnd-enhancements.md @@ -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 + diff --git a/plans/inline-dashboard-editing/phase-8-batch-save.md b/plans/inline-dashboard-editing/phase-8-batch-save.md new file mode 100644 index 0000000..e043c28 --- /dev/null +++ b/plans/inline-dashboard-editing/phase-8-batch-save.md @@ -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` + - `widgetAdds: Array<{tempId, sectionId, type, config, order}>` + - `widgetDeletes: Set` + - `widgetMoves: Map` + - `sectionUpdates: Map` + - `sectionAdds: Array<{tempId, title, icon, order}>` + - `sectionDeletes: Set` + - `sectionReorders: Array<{id, newOrder}>` + - `boardUpdates: Partial` +- [ ] 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 + diff --git a/plans/inline-dashboard-editing/phase-9-board-properties-panel.md b/plans/inline-dashboard-editing/phase-9-board-properties-panel.md new file mode 100644 index 0000000..9675adb --- /dev/null +++ b/plans/inline-dashboard-editing/phase-9-board-properties-panel.md @@ -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 + diff --git a/src/lib/components/board/AddSectionDivider.svelte b/src/lib/components/board/AddSectionDivider.svelte new file mode 100644 index 0000000..c3034bb --- /dev/null +++ b/src/lib/components/board/AddSectionDivider.svelte @@ -0,0 +1,44 @@ + + +
+ {#if showForm} + { showForm = false; }} + /> + {:else} + + {/if} +
diff --git a/src/lib/components/board/AddSectionForm.svelte b/src/lib/components/board/AddSectionForm.svelte new file mode 100644 index 0000000..037675d --- /dev/null +++ b/src/lib/components/board/AddSectionForm.svelte @@ -0,0 +1,62 @@ + + +
+ + { icon = v; }} size="sm" /> + + +
diff --git a/src/lib/components/board/Board.svelte b/src/lib/components/board/Board.svelte index 16be69b..19120b8 100644 --- a/src/lib/components/board/Board.svelte +++ b/src/lib/components/board/Board.svelte @@ -1,6 +1,10 @@
- {#if sections.length === 0} + {#if sectionsForRender.length === 0 && !editMode.active}

{$t('board.no_sections')}

+ {:else if editMode.active} + + {#if sectionsForRender.length > 0} +
+ {#each dndSections as section (section.id)} +
+
+
+ {/each} +
+ {:else} +
+

{$t('board.no_sections_edit') ?? 'No sections yet. Click "+" below to add one.'}

+
+ {/if} + {:else} - {#each sections as section (section.id)} + + {#each sectionsForRender as section (section.id)}
{/each} {/if} diff --git a/src/lib/components/board/BoardHeader.svelte b/src/lib/components/board/BoardHeader.svelte index 8c1dd89..dd3419e 100644 --- a/src/lib/components/board/BoardHeader.svelte +++ b/src/lib/components/board/BoardHeader.svelte @@ -1,6 +1,7 @@
@@ -51,12 +66,27 @@ {/if} {#if canEdit} - - {$t('board.edit')} - + {#if editMode.active} + + + + + {$t('board.editing') ?? 'Editing'} + {:else} + + + + + {$t('board.edit')} + {/if} + {/if}
diff --git a/src/lib/components/board/BoardPropertiesPanel.svelte b/src/lib/components/board/BoardPropertiesPanel.svelte new file mode 100644 index 0000000..2ab7381 --- /dev/null +++ b/src/lib/components/board/BoardPropertiesPanel.svelte @@ -0,0 +1,210 @@ + + + + + +
e.key === 'Enter' && onClose()} + transition:fade={{ duration: 150 }} +>
+ + +
+ +
+

{$t('board.settings') ?? 'Board Settings'}

+ +
+ + +
+
+ +
+ + +
+ + +
+ + { icon = v; }} /> +
+ + +
+ + +
+ + +
+ + + {themeHue}° +
+ + +
+ + + {themeSaturation}% +
+ + +
+ + +
+ + + {#if backgroundType === 'wallpaper'} +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + + +
+ + +
+ + +
+ + +
+
+
+ + +
+ + +
+
diff --git a/src/lib/components/board/EditToolbar.svelte b/src/lib/components/board/EditToolbar.svelte new file mode 100644 index 0000000..92e6900 --- /dev/null +++ b/src/lib/components/board/EditToolbar.svelte @@ -0,0 +1,147 @@ + + +{#if editMode.active} +
+ +
+ + + +
+ + + + + + + +
+ + + + + + +
+
+ + {#if showDiscardConfirm} + + {/if} +{/if} diff --git a/src/lib/components/section/Section.svelte b/src/lib/components/section/Section.svelte index 76735b8..9bb017a 100644 --- a/src/lib/components/section/Section.svelte +++ b/src/lib/components/section/Section.svelte @@ -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); -
+
(expanded = !expanded)} + widgetCount={section.widgets.length} />
- +
diff --git a/src/lib/components/section/SectionHeader.svelte b/src/lib/components/section/SectionHeader.svelte index 39dc4d7..13c4de1 100644 --- a/src/lib/components/section/SectionHeader.svelte +++ b/src/lib/components/section/SectionHeader.svelte @@ -1,38 +1,183 @@ - + + + + {#if editMode.active && editingTitle} + +
+ + { editIcon = v; }} + size="sm" + /> +
+ {:else} + + + {/if} + + {#if editMode.active} + +
+ {#if !editingTitle} + + + {/if} + + + +
+ {/if} +
+ +{#if showDeleteConfirm} + { showDeleteConfirm = false; }} + /> +{/if} diff --git a/src/lib/components/ui/ConfirmDialog.svelte b/src/lib/components/ui/ConfirmDialog.svelte new file mode 100644 index 0000000..7c807ba --- /dev/null +++ b/src/lib/components/ui/ConfirmDialog.svelte @@ -0,0 +1,75 @@ + + + + + +
e.key === 'Enter' && onCancel()} + transition:fade={{ duration: 120 }} +> + + + +
e.stopPropagation()} + transition:scale={{ start: 0.95, duration: 150 }} + role="alertdialog" + aria-labelledby="confirm-dialog-title" + aria-describedby="confirm-dialog-message" + > +

{title}

+

{message}

+ +
+ + +
+
+
diff --git a/src/lib/components/ui/IconPickerButton.svelte b/src/lib/components/ui/IconPickerButton.svelte new file mode 100644 index 0000000..384bf2e --- /dev/null +++ b/src/lib/components/ui/IconPickerButton.svelte @@ -0,0 +1,169 @@ + + + + +
+ + + + + {#if open} + + +
{ if (e.target === e.currentTarget) open = false; }} + > +
+ +
+ + + + +
+ + + {#if value} + + {/if} + + +
+ {#if filteredIcons.length === 0} +

{$t('common.no_results') ?? 'No matching icons'}

+ {:else} +
+ {#each filteredIcons as iconName} + + {/each} +
+ {/if} +
+ + +
+ { 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" + /> +
+
+
+ {/if} +
diff --git a/src/lib/components/widget/NoteWidget.svelte b/src/lib/components/widget/NoteWidget.svelte index 6da83df..13524cf 100644 --- a/src/lib/components/widget/NoteWidget.svelte +++ b/src/lib/components/widget/NoteWidget.svelte @@ -4,7 +4,7 @@ interface NoteConfig { content: string; - format: 'markdown' | 'text'; + format: 'markdown' | 'text' | 'html'; } interface Props { @@ -28,6 +28,9 @@ .replace(/\n/g, '
') ); } + if (config.format === 'html') { + return DOMPurify.sanitize(config.content); + } const raw = marked.parse(config.content, { async: false }) as string; return DOMPurify.sanitize(raw); }); diff --git a/src/lib/components/widget/WidgetConfigPanel.svelte b/src/lib/components/widget/WidgetConfigPanel.svelte new file mode 100644 index 0000000..9b5aecc --- /dev/null +++ b/src/lib/components/widget/WidgetConfigPanel.svelte @@ -0,0 +1,472 @@ + + + diff --git a/src/lib/components/widget/WidgetCreationForm.svelte b/src/lib/components/widget/WidgetCreationForm.svelte index ba6cbf6..a50fce7 100644 --- a/src/lib/components/widget/WidgetCreationForm.svelte +++ b/src/lib/components/widget/WidgetCreationForm.svelte @@ -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[] = [ diff --git a/src/lib/components/widget/WidgetEditOverlay.svelte b/src/lib/components/widget/WidgetEditOverlay.svelte new file mode 100644 index 0000000..f3be218 --- /dev/null +++ b/src/lib/components/widget/WidgetEditOverlay.svelte @@ -0,0 +1,85 @@ + + +
{ hovered = true; }} + onmouseleave={() => { hovered = false; }} +> + {@render children()} + + + {#if hovered} +
+ +
+
+ + + + + +
+
+ + +
+ + + + + +
+
+ {/if} +
+ +{#if showDeleteConfirm} + { showDeleteConfirm = false; }} + /> +{/if} diff --git a/src/lib/components/widget/WidgetGrid.svelte b/src/lib/components/widget/WidgetGrid.svelte index 5f758ae..cd7df91 100644 --- a/src/lib/components/widget/WidgetGrid.svelte +++ b/src/lib/components/widget/WidgetGrid.svelte @@ -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(null); + let showTypePicker = $state(false); + let addingWidgetType = $state(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) { + editMode.updateWidget(widgetId, config); + editingWidgetId = null; + } + + function handleTypeSelected(type: string) { + showTypePicker = false; + addingWidgetType = type; + } + + function handleNewWidgetSave(config: Record) { + 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 { + try { + return JSON.parse(widget.config || '{}'); + } catch { + return {}; + } + } + + const appsForPicker = $derived(allApps.map((a) => ({ id: a.id, name: a.name }))); -{#if widgets.length === 0} +{#if widgets.length === 0 && !editMode.active}

{$t('widget.no_widgets')}

{:else}
{#each widgets as widget (widget.id)} {@const isFullWidth = fullWidthTypes.has(widget.type)}
- - - + {#if editMode.active} + {#if editingWidgetId === widget.id} + + handleSaveWidgetConfig(widget.id, config)} + onCancel={() => { editingWidgetId = null; }} + /> + {:else} + + + + + + {/if} + {:else} + + + + {/if}
{/each} + + + {#if editMode.active} + {#if addingWidgetType} +
+ { addingWidgetType = null; }} + /> +
+ {:else} + + {/if} + {/if}
{/if} + + +{#if showTypePicker} + { showTypePicker = false; }} + /> +{/if} diff --git a/src/lib/components/widget/WidgetTypePicker.svelte b/src/lib/components/widget/WidgetTypePicker.svelte new file mode 100644 index 0000000..8c793ce --- /dev/null +++ b/src/lib/components/widget/WidgetTypePicker.svelte @@ -0,0 +1,151 @@ + + + + + +
e.key === 'Enter' && onClose()} + transition:fade={{ duration: 120 }} +> + + + +
e.stopPropagation()} + transition:scale={{ start: 0.95, duration: 150 }} + > + +
+
+

{$t('widget.add_widget') ?? 'Add Widget'}

+ +
+
+ + + + +
+
+ + +
+ {#if filteredTypes.length === 0} +

{$t('common.no_results') ?? 'No matching widget types'}

+ {:else} +
+ {#each filteredTypes as wt} + + {/each} +
+ {/if} +
+
+
diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 29e1c9c..350f694 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -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" } diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json index 3324e8b..37460d1 100644 --- a/src/lib/i18n/ru.json +++ b/src/lib/i18n/ru.json @@ -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": "Закрыть" } diff --git a/src/lib/stores/editMode.svelte.ts b/src/lib/stores/editMode.svelte.ts new file mode 100644 index 0000000..4e105ed --- /dev/null +++ b/src/lib/stores/editMode.svelte.ts @@ -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>; + widgetAdds: WidgetAdd[]; + widgetDeletes: Set; + widgetMoves: Map; + sectionUpdates: Map>; + sectionAdds: SectionAdd[]; + sectionDeletes: Set; + sectionReorders: Map; + boardUpdates: Record; +} + +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(null); + changeset = $state(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): 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): 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): 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(); diff --git a/src/lib/utils/validators.ts b/src/lib/utils/validators.ts index cb5965d..b65f11e 100644 --- a/src/lib/utils/validators.ts +++ b/src/lib/utils/validators.ts @@ -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({ diff --git a/src/routes/api/boards/[id]/batch-update/+server.ts b/src/routes/api/boards/[id]/batch-update/+server.ts new file mode 100644 index 0000000..2039061 --- /dev/null +++ b/src/routes/api/boards/[id]/batch-update/+server.ts @@ -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; + widgetUpdates?: Record>; + widgetAdds?: WidgetAddPayload[]; + widgetDeletes?: string[]; + widgetMoves?: Record; + sectionUpdates?: Record>; + sectionAdds?: SectionAddPayload[]; + sectionDeletes?: string[]; + sectionReorders?: Record; +} + +/** + * 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(); + + 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 = {}; + 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 = {}; + 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 }); + } +}; diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte index c8176e8..a87e8d0 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -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(); + } + + {data.board.name} — {$t('app_title')} @@ -60,7 +145,7 @@ {/if} -
+
{guestToggleError}

{/if} - +
+ + + {#if editMode.active} + {}} + onBoardSettings={() => { showBoardProperties = true; }} + /> + {/if} + + + {#if showBoardProperties && editMode.active} + { showBoardProperties = false; }} + /> + {/if} {#if showShareDialog && data.canEdit} @@ -91,3 +199,12 @@ onGuestToggle={handleGuestToggle} /> {/if} + + diff --git a/src/routes/boards/[boardId]/edit/+page.svelte b/src/routes/boards/[boardId]/edit/+page.svelte index 0e24e89..2246632 100644 --- a/src/routes/boards/[boardId]/edit/+page.svelte +++ b/src/routes/boards/[boardId]/edit/+page.svelte @@ -186,8 +186,25 @@
{/if} + +
+ + + +
+

{$t('board.try_inline_edit') ?? 'Try the new inline edit mode!'}

+

{$t('board.inline_edit_description') ?? 'Edit your board directly with live preview. Press Ctrl+E on the board page.'}

+
+ + {$t('board.open_inline_edit') ?? 'Open Inline Edit'} + +
+
-

{$t('board.edit_board')}

+

{$t('board.edit_board')} ({$t('board.advanced') ?? 'Advanced'})

Date: Fri, 3 Apr 2026 00:24:08 +0300 Subject: [PATCH 2/3] feat(app-form): icon picker, tag/category autocomplete, typography - Replace AppIconPicker text input with visual IconPickerButton for lucide icons (grid with search) - Add AutocompleteInput component for category field with existing category suggestions - Add TagsInput component for tags field with tag pills, autocomplete from existing tags, and keyboard navigation - Add GET /api/apps/suggestions endpoint returning all categories/tags - Add getAllTags() to appService (merges Tag model + comma-separated) - Install @tailwindcss/typography plugin to fix prose rendering (headings, lists, blockquotes now render in Note/Markdown widgets) - Fix note widget validator test for new html format --- package-lock.json | 59 +++++-- package.json | 1 + src/app.css | 1 + src/lib/components/app/AppForm.svelte | 29 +++- src/lib/components/app/AppIconPicker.svelte | 41 +++-- .../components/ui/AutocompleteInput.svelte | 109 +++++++++++++ src/lib/components/ui/TagsInput.svelte | 146 ++++++++++++++++++ src/lib/server/services/appService.ts | 21 +++ .../utils/__tests__/widgetValidators.test.ts | 10 +- src/routes/api/apps/suggestions/+server.ts | 16 ++ 10 files changed, 405 insertions(+), 28 deletions(-) create mode 100644 src/lib/components/ui/AutocompleteInput.svelte create mode 100644 src/lib/components/ui/TagsInput.svelte create mode 100644 src/routes/api/apps/suggestions/+server.ts diff --git a/package-lock.json b/package-lock.json index 7fb7eeb..aa6be20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@sveltejs/adapter-node": "^5.2.0", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/typography": "^0.5.19", "bcryptjs": "^2.4.3", "bits-ui": "^1.3.0", "clsx": "^2.1.0", @@ -1890,6 +1891,29 @@ "node": ">= 20" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", @@ -2956,7 +2980,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "bin": { "cssesc": "bin/cssesc" }, @@ -6140,8 +6163,7 @@ "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==" }, "node_modules/tapable": { "version": "2.3.2", @@ -6879,8 +6901,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/uuid": { "version": "8.3.2", @@ -8290,6 +8311,25 @@ "dev": true, "optional": true }, + "@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "requires": { + "postcss-selector-parser": "6.0.10" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + } + } + }, "@tailwindcss/vite": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", @@ -9056,8 +9096,7 @@ "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" }, "d": { "version": "1.0.2", @@ -11045,8 +11084,7 @@ "tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==" }, "tapable": { "version": "2.3.2", @@ -11456,8 +11494,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "uuid": { "version": "8.3.2", diff --git a/package.json b/package.json index 7168172..d98fe3c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@sveltejs/adapter-node": "^5.2.0", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/typography": "^0.5.19", "bcryptjs": "^2.4.3", "bits-ui": "^1.3.0", "clsx": "^2.1.0", diff --git a/src/app.css b/src/app.css index 35c5f58..96983bd 100644 --- a/src/app.css +++ b/src/app.css @@ -1,5 +1,6 @@ @import 'tailwindcss'; @import 'tw-animate-css'; +@plugin '@tailwindcss/typography'; @custom-variant dark (&:is(.dark *)); diff --git a/src/lib/components/app/AppForm.svelte b/src/lib/components/app/AppForm.svelte index 275478f..42930df 100644 --- a/src/lib/components/app/AppForm.svelte +++ b/src/lib/components/app/AppForm.svelte @@ -6,6 +6,8 @@ import AppIconPicker from './AppIconPicker.svelte'; import IntegrationConfigFields from './IntegrationConfigFields.svelte'; import AppUrlPreview from './AppUrlPreview.svelte'; + import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte'; + import TagsInput from '$lib/components/ui/TagsInput.svelte'; import IconGrid from '$lib/components/ui/IconGrid.svelte'; import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte'; @@ -25,6 +27,21 @@ let showAdvanced = $state(false); let showIntegration = $state(false); + let categorySuggestions = $state([]); + let tagSuggestions = $state([]); + + // Fetch autocomplete suggestions + $effect(() => { + fetch('/api/apps/suggestions') + .then((r) => r.json()) + .then((json) => { + if (json.success) { + categorySuggestions = json.data?.categories ?? []; + tagSuggestions = json.data?.tags ?? []; + } + }) + .catch(() => {}); + }); let availableIntegrations = $state>([]); let integrationConfig = $state>({}); let testingConnection = $state(false); @@ -148,13 +165,13 @@ -
@@ -162,13 +179,13 @@ - diff --git a/src/lib/components/app/AppIconPicker.svelte b/src/lib/components/app/AppIconPicker.svelte index 664af2a..2200cf5 100644 --- a/src/lib/components/app/AppIconPicker.svelte +++ b/src/lib/components/app/AppIconPicker.svelte @@ -1,6 +1,8 @@
@@ -44,22 +51,36 @@ />
- + + + {:else} + + class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> + {/if} - {#if iconType === 'emoji' && iconValue} + + {#if iconType === 'lucide' && iconValue} +
+ + {iconValue} +
+ {:else if iconType === 'emoji' && iconValue}
{iconValue}
{:else if iconType === 'url' && iconValue} {$t('app.icon_preview')} diff --git a/src/lib/components/ui/AutocompleteInput.svelte b/src/lib/components/ui/AutocompleteInput.svelte new file mode 100644 index 0000000..27c327d --- /dev/null +++ b/src/lib/components/ui/AutocompleteInput.svelte @@ -0,0 +1,109 @@ + + + + +
+ + + {#if open && filtered.length > 0} +
+ {#each filtered as item, i} + + {/each} +
+ {/if} +
diff --git a/src/lib/components/ui/TagsInput.svelte b/src/lib/components/ui/TagsInput.svelte new file mode 100644 index 0000000..a388636 --- /dev/null +++ b/src/lib/components/ui/TagsInput.svelte @@ -0,0 +1,146 @@ + + + + +
+ + {#if tags.length > 0} +
+ {#each tags as tag} + + {tag} + + + {/each} +
+ {/if} + + + + {#if open && filtered.length > 0} +
+ {#each filtered as item, i} + + {/each} +
+ {/if} +
diff --git a/src/lib/server/services/appService.ts b/src/lib/server/services/appService.ts index 2269de5..c9f6857 100644 --- a/src/lib/server/services/appService.ts +++ b/src/lib/server/services/appService.ts @@ -306,3 +306,24 @@ export async function getCategories() { }); return apps.map((a) => a.category).filter(Boolean) as string[]; } + +export async function getAllTags(): Promise { + // Collect from both the Tag model and the comma-separated tags field + const [tagModels, apps] = await Promise.all([ + prisma.tag.findMany({ select: { name: true }, orderBy: { name: 'asc' } }), + prisma.app.findMany({ + where: { tags: { not: '' } }, + select: { tags: true } + }) + ]); + + const tagSet = new Set(); + for (const t of tagModels) tagSet.add(t.name); + for (const a of apps) { + for (const tag of a.tags.split(',')) { + const trimmed = tag.trim(); + if (trimmed) tagSet.add(trimmed); + } + } + return Array.from(tagSet).sort(); +} diff --git a/src/lib/utils/__tests__/widgetValidators.test.ts b/src/lib/utils/__tests__/widgetValidators.test.ts index 89cb91e..157a709 100644 --- a/src/lib/utils/__tests__/widgetValidators.test.ts +++ b/src/lib/utils/__tests__/widgetValidators.test.ts @@ -112,10 +112,18 @@ describe('Widget Config Validators', () => { } }); + it('accepts html format', () => { + const result = noteWidgetConfigSchema.safeParse({ + content: '

Hello

', + format: 'html' + }); + expect(result.success).toBe(true); + }); + it('rejects invalid format', () => { const result = noteWidgetConfigSchema.safeParse({ content: 'Some content', - format: 'html' + format: 'invalid' }); expect(result.success).toBe(false); }); diff --git a/src/routes/api/apps/suggestions/+server.ts b/src/routes/api/apps/suggestions/+server.ts new file mode 100644 index 0000000..87f67ea --- /dev/null +++ b/src/routes/api/apps/suggestions/+server.ts @@ -0,0 +1,16 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import * as appService from '$lib/server/services/appService.js'; +import { success } from '$lib/server/utils/response.js'; + +/** + * GET /api/apps/suggestions — Get categories and tags for autocomplete. + */ +export const GET: RequestHandler = async () => { + const [categories, tags] = await Promise.all([ + appService.getCategories(), + appService.getAllTags() + ]); + + return json(success({ categories, tags })); +}; From 17c8407c07f44d891de528ee60d77d6920022e0a Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 3 Apr 2026 00:32:45 +0300 Subject: [PATCH 3/3] feat(widget-config): visual app selector grid with search and icons Replace plain select dropdown with a searchable 2-column grid showing app icons for the app widget type in the inline config panel. --- .../widget/WidgetConfigPanel.svelte | 71 +++++++++++++++++-- src/lib/components/widget/WidgetGrid.svelte | 2 +- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/lib/components/widget/WidgetConfigPanel.svelte b/src/lib/components/widget/WidgetConfigPanel.svelte index 9b5aecc..d863703 100644 --- a/src/lib/components/widget/WidgetConfigPanel.svelte +++ b/src/lib/components/widget/WidgetConfigPanel.svelte @@ -2,11 +2,19 @@ import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import { tick } from 'svelte'; + import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte'; + + interface AppInfo { + id: string; + name: string; + icon?: string | null; + iconType?: string; + } interface Props { widgetType: string; initialConfig?: Record; - apps?: Array<{ id: string; name: string }>; + apps?: AppInfo[]; mode: 'create' | 'edit'; onSave: (config: Record) => void; onCancel: () => void; @@ -14,6 +22,14 @@ let { widgetType, initialConfig = {}, apps = [], mode, onSave, onCancel }: Props = $props(); + // App search + let appSearchQuery = $state(''); + const filteredApps = $derived( + appSearchQuery.trim() + ? apps.filter((a) => a.name.toLowerCase().includes(appSearchQuery.toLowerCase())) + : apps + ); + // -- Form fields initialised from config -- // App let appId = $state((initialConfig.appId as string) ?? ''); @@ -173,12 +189,53 @@ {#if widgetType === 'app'}
- + +
+ + + + +
+ +
+ {#if filteredApps.length === 0} +

{$t('common.no_results') ?? 'No apps found'}

+ {:else} +
+ {#each filteredApps as app} + + {/each} +
+ {/if} +
{:else if widgetType === 'bookmark'} diff --git a/src/lib/components/widget/WidgetGrid.svelte b/src/lib/components/widget/WidgetGrid.svelte index cd7df91..fc135d8 100644 --- a/src/lib/components/widget/WidgetGrid.svelte +++ b/src/lib/components/widget/WidgetGrid.svelte @@ -109,7 +109,7 @@ } } - const appsForPicker = $derived(allApps.map((a) => ({ id: a.id, name: a.name }))); + const appsForPicker = $derived(allApps.map((a) => ({ id: a.id, name: a.name, icon: a.icon, iconType: a.iconType }))); {#if widgets.length === 0 && !editMode.active}