feat(todo): add Board View Editor UI for creating and editing views

- New ViewEditorModal with form fields for name, icon, groupBy, layout,
  and column configuration with inline color picker
- ViewSelector updated with "+" button to create views, three-dot menu
  and right-click to edit active/any view
- Auto-generated column presets for status, priority, dueDate groupBy
- Custom columns mode for manual task grouping
- Live preview showing column layout (kanban or grid)
- Two-step delete confirmation
- Glass-morphism styling, dark mode, responsive

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 17:35:43 +01:00
parent 22fe8077b6
commit 1104c0489d
4 changed files with 1423 additions and 5 deletions

File diff suppressed because it is too large Load diff

View file

@ -5,9 +5,15 @@
views: LocalBoardView[];
activeViewId: string | null;
onSelect: (viewId: string) => void;
onCreate?: () => void;
onEdit?: (view: LocalBoardView) => void;
}
let { views, activeViewId, onSelect }: Props = $props();
let { views, activeViewId, onSelect, onCreate, onEdit }: Props = $props();
// Context menu state
let contextMenuViewId = $state<string | null>(null);
let contextMenuPos = $state({ x: 0, y: 0 });
// Map icon names to simple SVG representations
const iconMap: Record<string, string> = {
@ -15,10 +21,48 @@
'grid-four': 'M3 3h8v8H3zM13 3h8v8h-8zM3 13h8v8H3zM13 13h8v8h-8z',
flag: 'M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7',
folders: 'M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z',
calendar: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
calendar:
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
star: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z',
tag: 'M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82zM7 7h.01',
clock: 'M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10zM12 6v6l4 2',
target:
'M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10zM12 18a6 6 0 100-12 6 6 0 000 12zM12 14a2 2 0 100-4 2 2 0 000 4z',
lightning: 'M13 2L3 14h9l-1 10 10-12h-9l1-10z',
heart:
'M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z',
};
function handleContextMenu(e: MouseEvent, view: LocalBoardView) {
e.preventDefault();
e.stopPropagation();
contextMenuViewId = view.id;
contextMenuPos = { x: e.clientX, y: e.clientY };
}
function handleMoreClick(e: MouseEvent, view: LocalBoardView) {
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
contextMenuViewId = view.id;
contextMenuPos = { x: rect.left, y: rect.bottom + 4 };
}
function handleEditClick() {
const view = views.find((v) => v.id === contextMenuViewId);
contextMenuViewId = null;
if (view && onEdit) {
onEdit(view);
}
}
function closeContextMenu() {
contextMenuViewId = null;
}
</script>
<svelte:window onclick={closeContextMenu} />
<div class="view-selector-container">
<div class="view-selector">
<div class="view-pills-scroll">
@ -28,6 +72,7 @@
class="view-pill"
class:active={activeViewId === view.id}
onclick={() => onSelect(view.id)}
oncontextmenu={(e) => handleContextMenu(e, view)}
>
{#if view.icon && iconMap[view.icon]}
<svg
@ -45,12 +90,60 @@
<span class="mr-1.5 text-sm">{view.icon}</span>
{/if}
<span class="view-name">{view.name}</span>
{#if activeViewId === view.id && onEdit}
<button
type="button"
class="more-btn"
onclick={(e) => handleMoreClick(e, view)}
aria-label="View-Optionen"
>
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
</button>
{/if}
</button>
{/each}
{#if onCreate}
<button type="button" class="view-pill add-pill" onclick={onCreate}>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
{/if}
</div>
</div>
</div>
<!-- Context Menu -->
{#if contextMenuViewId}
<div
class="context-menu"
style="left: {contextMenuPos.x}px; top: {contextMenuPos.y}px;"
role="menu"
>
<button type="button" class="context-item" role="menuitem" onclick={handleEditClick}>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Bearbeiten
</button>
</div>
{/if}
<style>
.view-selector-container {
padding: 0 1rem;
@ -157,4 +250,97 @@
overflow: hidden;
text-overflow: ellipsis;
}
/* ─── Add Pill ──────────────────────────────────────────── */
.add-pill {
padding: 0.5rem;
color: #9ca3af;
opacity: 0.7;
}
.add-pill:hover {
opacity: 1;
color: #8b5cf6;
background: rgba(139, 92, 246, 0.1);
}
:global(.dark) .add-pill {
color: #6b7280;
}
:global(.dark) .add-pill:hover {
color: #a78bfa;
background: rgba(139, 92, 246, 0.15);
}
/* ─── More Button (three dots) ──────────────────────────── */
.more-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
margin-left: 0.25rem;
border: none;
border-radius: 0.25rem;
background: transparent;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: all 0.15s;
padding: 0;
}
.more-btn:hover {
color: white;
background: rgba(255, 255, 255, 0.2);
}
/* ─── Context Menu ──────────────────────────────────────── */
.context-menu {
position: fixed;
z-index: 200;
min-width: 160px;
padding: 0.375rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.75rem;
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.15),
0 4px 6px -2px rgba(0, 0, 0, 0.1);
}
:global(.dark) .context-menu {
background: rgba(30, 30, 40, 0.95);
border-color: rgba(255, 255, 255, 0.12);
}
.context-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
background: transparent;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.1s;
}
.context-item:hover {
background: rgba(0, 0, 0, 0.06);
}
:global(.dark) .context-item {
color: #e5e7eb;
}
:global(.dark) .context-item:hover {
background: rgba(255, 255, 255, 0.1);
}
</style>

View file

@ -4,3 +4,4 @@ export { default as KanbanLayout } from './KanbanLayout.svelte';
export { default as GridLayout } from './GridLayout.svelte';
export { default as BoardViewRenderer } from './BoardViewRenderer.svelte';
export { default as ViewSelector } from './ViewSelector.svelte';
export { default as ViewEditorModal } from './ViewEditorModal.svelte';

View file

@ -1,8 +1,10 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { TaskPriority } from '@todo/shared';
import type { LocalBoardView } from '$lib/data/local-store';
import { useAllBoardViews } from '$lib/data/task-queries';
import { ViewSelector, BoardViewRenderer } from '$lib/components/board-views';
import { ViewSelector, BoardViewRenderer, ViewEditorModal } from '$lib/components/board-views';
import { boardViewsStore } from '$lib/stores/board-views.svelte';
import TaskFilters from '$lib/components/TaskFilters.svelte';
// Live query for board views
@ -26,7 +28,11 @@
activeViewId = boardViews.value[0].id;
}
// If stored view no longer exists, fall back to first
if (activeViewId && boardViews.value.length > 0 && !boardViews.value.find((v) => v.id === activeViewId)) {
if (
activeViewId &&
boardViews.value.length > 0 &&
!boardViews.value.find((v) => v.id === activeViewId)
) {
activeViewId = boardViews.value[0].id;
}
});
@ -40,6 +46,50 @@
localStorage.setItem(STORAGE_KEY, viewId);
}
// ─── Editor Modal ──────────────────────────────────────
let showEditor = $state(false);
let editingView = $state<LocalBoardView | null>(null);
function handleCreateView() {
editingView = null;
showEditor = true;
}
function handleEditView(view: LocalBoardView) {
editingView = view;
showEditor = true;
}
async function handleSaveView(data: Partial<LocalBoardView>) {
if (editingView) {
// Update existing view
await boardViewsStore.updateView(editingView.id, data);
} else {
// Create new view
const newView = await boardViewsStore.createView({
name: data.name ?? 'Neue View',
icon: data.icon ?? 'columns',
groupBy: data.groupBy ?? 'status',
layout: data.layout ?? 'kanban',
columns: data.columns ?? [],
order: boardViews.value.length,
});
// Auto-select the new view
if (newView?.id) {
handleSelectView(newView.id);
}
}
showEditor = false;
editingView = null;
}
async function handleDeleteView() {
if (!editingView) return;
await boardViewsStore.deleteView(editingView.id);
showEditor = false;
editingView = null;
}
// Filter state
let filterPriorities = $state<TaskPriority[]>([]);
let filterProjectId = $state<string | null>(null);
@ -91,6 +141,8 @@
views={boardViews.value}
{activeViewId}
onSelect={handleSelectView}
onCreate={handleCreateView}
onEdit={handleEditView}
/>
{/if}
@ -100,7 +152,10 @@
<button
type="button"
onclick={() => (showFilters = !showFilters)}
class="filter-button px-4 py-2 text-sm font-medium transition-all flex items-center gap-2 {showFilters || hasActiveFilters ? 'active' : ''}"
class="filter-button px-4 py-2 text-sm font-medium transition-all flex items-center gap-2 {showFilters ||
hasActiveFilters
? 'active'
: ''}"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
@ -157,11 +212,25 @@
views={boardViews.value}
{activeViewId}
onSelect={handleSelectView}
onCreate={handleCreateView}
onEdit={handleEditView}
/>
</div>
{/if}
</div>
<!-- View Editor Modal -->
<ViewEditorModal
open={showEditor}
view={editingView}
onSave={handleSaveView}
onDelete={handleDeleteView}
onClose={() => {
showEditor = false;
editingView = null;
}}
/>
<style>
.board-page {
display: flex;