refactor(todo): move ViewSelector behind Layout pill, simplify homepage

- Move board view management (ViewSelector, activeViewId, ViewEditorModal)
  from +page.svelte to +layout.svelte
- Layout pill in PillNav now toggles ViewSelector strip visibility
- +page.svelte reduced to minimal BoardViewRenderer with context-provided view
- Provide activeView via Svelte context from layout
- Fix broken import in TaskItem.svelte (linter artifact)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 12:41:28 +02:00
parent 504e7756a7
commit 59e535af94
3 changed files with 135 additions and 451 deletions

View file

@ -19,8 +19,8 @@
import { contactsStore } from '$lib/stores/contacts.svelte';
import { ContactAvatar, ContactSelector } from '@manacore/shared-ui';
import SubtaskList from './SubtaskList.svelte';
import {
import { Check, CheckSquare, DotsSixVertical } from '@manacore/shared-icons';
import {
PrioritySelector,
StorypointsSelector,
DurationPicker,

View file

@ -52,7 +52,15 @@
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { TodoEvents } from '@manacore/shared-utils/analytics';
import { todoStore, taskCollection } from '$lib/data/local-store';
import { useAllTasks, useAllProjects, getActiveProjects } from '$lib/data/task-queries';
import type { LocalBoardView } from '$lib/data/local-store';
import {
useAllTasks,
useAllProjects,
useAllBoardViews,
getActiveProjects,
} from '$lib/data/task-queries';
import { boardViewsStore } from '$lib/stores/board-views.svelte';
import { ViewSelector, ViewEditorModal } from '$lib/components/board-views';
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
import { List, X } from '@manacore/shared-icons';
@ -61,10 +69,90 @@
const allProjects = useAllProjects();
const allTags = useAllSharedTags();
// ─── Board View Management ──────────────────────────────
const boardViews = useAllBoardViews();
const ACTIVE_VIEW_KEY = 'todo:activeViewId';
let activeViewId = $state<string | null>(null);
// Auto-select first view when views load and nothing is selected
$effect(() => {
if (boardViews.value.length > 0 && !activeViewId) {
const stored =
typeof localStorage !== 'undefined' ? localStorage.getItem(ACTIVE_VIEW_KEY) : null;
activeViewId =
stored && boardViews.value.find((v) => v.id === stored) ? stored : boardViews.value[0].id;
}
if (
activeViewId &&
boardViews.value.length > 0 &&
!boardViews.value.find((v) => v.id === activeViewId)
) {
activeViewId = boardViews.value[0].id;
}
});
let activeView = $derived(boardViews.value.find((v) => v.id === activeViewId) ?? null);
function handleSelectView(viewId: string) {
activeViewId = viewId;
localStorage.setItem(ACTIVE_VIEW_KEY, viewId);
}
// ViewSelector visibility (toggled via Layout pill)
let isViewSelectorVisible = $state(false);
// View Editor Modal
let showViewEditor = $state(false);
let editingView = $state<LocalBoardView | null>(null);
function handleCreateView() {
editingView = null;
showViewEditor = true;
}
function handleEditView(view: LocalBoardView) {
editingView = view;
showViewEditor = true;
}
async function handleSaveView(data: Partial<LocalBoardView>) {
if (editingView) {
await boardViewsStore.updateView(editingView.id, data);
} else {
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,
});
if (newView?.id) handleSelectView(newView.id);
}
showViewEditor = false;
editingView = null;
}
async function handleDeleteView() {
if (!editingView) return;
await boardViewsStore.deleteView(editingView.id);
showViewEditor = false;
editingView = null;
}
async function handleReorderViews(viewIds: string[]) {
await boardViewsStore.reorderViews(viewIds);
}
// Provide data to child components via Svelte context
setContext('projects', allProjects);
setContext('tasks', allTasks);
setContext('tags', allTags);
setContext('activeView', {
get value() {
return activeView;
},
});
// Edit mode state — shared between layout (PillNav button) and page (editor)
let editMode = $state(false);
@ -249,12 +337,12 @@
// Keep navRoutes for keyboard shortcuts (Ctrl+1-3)
const viewRoutes: Record<string, string> = { fokus: '/', uebersicht: '/', matrix: '/' };
// Handle edit mode toggle
function handleEditToggle() {
editMode = !editMode;
// Handle view selector toggle (Layout pill)
function handleViewSelectorToggle() {
isViewSelectorVisible = !isViewSelectorVisible;
}
// Filter, Tags, and Edit stay as standalone pills (toggle behavior, not navigation)
// Filter, Tags, and Layout stay as standalone pills (toggle behavior, not navigation)
let baseNavItems = $derived<PillNavItem[]>([
{
href: '/',
@ -274,10 +362,10 @@
? [
{
href: '/',
label: editMode ? 'Fertig' : 'Layout',
icon: editMode ? 'check' : 'grid',
onClick: handleEditToggle,
active: editMode,
label: activeView?.name ?? 'Layout',
icon: 'grid',
onClick: handleViewSelectorToggle,
active: isViewSelectorVisible,
},
]
: []),
@ -462,6 +550,18 @@
ariaLabel="Hauptnavigation"
/>
<!-- ViewSelector strip (toggled via Layout pill) -->
{#if isViewSelectorVisible && ($page.url.pathname === '/' || $page.url.pathname === '')}
<ViewSelector
views={boardViews.value}
{activeViewId}
onSelect={handleSelectView}
onCreate={handleCreateView}
onEdit={handleEditView}
onReorder={handleReorderViews}
/>
{/if}
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
{#if isTagStripVisible}
<TagStrip
@ -509,8 +609,8 @@
{/if}
{/if}
<!-- Global Quick Input Bar - only on list and kanban views -->
{#if $page.url.pathname === '/' || $page.url.pathname === '/kanban' || $page.url.pathname === '/statistics'}
<!-- Global Quick Input Bar -->
{#if $page.url.pathname === '/' || $page.url.pathname === '/statistics'}
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
@ -590,6 +690,18 @@
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<!-- View Editor Modal -->
<ViewEditorModal
open={showViewEditor}
view={editingView}
onSave={handleSaveView}
onDelete={handleDeleteView}
onClose={() => {
showViewEditor = false;
editingView = null;
}}
/>
</AuthGate>
<style>

View file

@ -1,45 +1,11 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { TaskPriority } from '@todo/shared';
import { getContext } from 'svelte';
import type { LocalBoardView } from '$lib/data/local-store';
import { useAllBoardViews } from '$lib/data/task-queries';
import { ViewSelector, BoardViewRenderer, ViewEditorModal } from '$lib/components/board-views';
import { boardViewsStore } from '$lib/stores/board-views.svelte';
import { BoardViewRenderer } from '$lib/components/board-views';
import { todoSettings } from '$lib/stores/settings.svelte';
import TaskFilters from '$lib/components/TaskFilters.svelte';
import { Funnel, FloppyDisk } from '@manacore/shared-icons';
// Live query for board views
const boardViews = useAllBoardViews();
// Active view — persisted in localStorage
const STORAGE_KEY = 'todo:activeViewId';
let activeViewId = $state<string | null>(null);
onMount(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
activeViewId = stored;
}
});
// Auto-select first view when views load and nothing is selected
$effect(() => {
if (boardViews.value.length > 0 && !activeViewId) {
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)
) {
activeViewId = boardViews.value[0].id;
}
});
let activeView = $derived(boardViews.value.find((v) => v.id === activeViewId) ?? null);
let pageTitle = $derived(activeView?.name ?? 'Aufgaben');
// Get active view from layout context
const activeViewCtx: { readonly value: LocalBoardView | null } = getContext('activeView');
// Map layout mode to BoardViewRenderer layoutOverride
const LAYOUT_MAP = {
@ -49,121 +15,8 @@
} as const;
let layoutOverride = $derived(LAYOUT_MAP[todoSettings.activeLayoutMode]);
function handleSelectView(viewId: string) {
activeViewId = viewId;
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) {
await boardViewsStore.updateView(editingView.id, data);
} else {
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,
});
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;
}
async function handleReorderViews(viewIds: string[]) {
await boardViewsStore.reorderViews(viewIds);
}
// ─── Filter state ──────────────────────────────────────
let filterPriorities = $state<TaskPriority[]>([]);
let filterProjectId = $state<string | null>(null);
let filterLabelIds = $state<string[]>([]);
let filterSearchQuery = $state('');
let showFilters = $state(false);
let previousViewId = $state<string | null>(null);
$effect(() => {
if (activeView && activeView.id !== previousViewId) {
previousViewId = activeView.id;
if (activeView.filter) {
filterPriorities = (activeView.filter.priorities ?? []) as TaskPriority[];
filterProjectId = activeView.filter.projectId ?? null;
filterLabelIds = activeView.filter.tagIds ?? [];
} else {
filterPriorities = [];
filterProjectId = null;
filterLabelIds = [];
}
filterSearchQuery = '';
}
});
function clearFilters() {
filterPriorities = [];
filterProjectId = null;
filterLabelIds = [];
filterSearchQuery = '';
}
async function saveFiltersToView() {
if (!activeViewId) return;
const filter: { projectId?: string; tagIds?: string[]; priorities?: string[] } = {};
if (filterProjectId) filter.projectId = filterProjectId;
if (filterLabelIds.length > 0) filter.tagIds = filterLabelIds;
if (filterPriorities.length > 0) filter.priorities = filterPriorities;
const hasFilter = Object.keys(filter).length > 0;
await boardViewsStore.updateView(activeViewId, { filter: hasFilter ? filter : undefined });
}
let hasActiveFilters = $derived(
filterPriorities.length > 0 ||
filterProjectId !== null ||
filterLabelIds.length > 0 ||
filterSearchQuery.trim() !== ''
);
let isMobile = $state(false);
function checkMobile() {
isMobile = window.innerWidth < 768;
}
onMount(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
});
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', checkMobile);
}
});
let activeView = $derived(activeViewCtx.value);
let pageTitle = $derived(activeView?.name ?? 'Aufgaben');
</script>
<svelte:head>
@ -171,305 +24,24 @@
</svelte:head>
<div class="board-page">
<!-- View Selector - Top on Desktop -->
{#if !isMobile}
<ViewSelector
views={boardViews.value}
{activeViewId}
onSelect={handleSelectView}
onCreate={handleCreateView}
onEdit={handleEditView}
onReorder={handleReorderViews}
/>
{/if}
<!-- Header -->
<div class="mb-6 flex items-center justify-between px-4 sm:px-6 lg:px-8">
<h1 class="page-title">{pageTitle}</h1>
<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'
: ''}"
>
<Funnel size={16} />
Filter
{#if hasActiveFilters}
<span
class="ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-bold rounded-full bg-primary-foreground text-primary"
>
{filterPriorities.length +
(filterProjectId ? 1 : 0) +
filterLabelIds.length +
(filterSearchQuery ? 1 : 0)}
</span>
{/if}
</button>
</div>
<!-- Collapsible Filters -->
{#if showFilters}
<div class="mb-6 px-4 sm:px-6 lg:px-8 animate-in slide-in-from-top-2 duration-200">
<TaskFilters
variant="bar"
selectedPriorities={filterPriorities}
selectedProjectId={filterProjectId}
selectedLabelIds={filterLabelIds}
searchQuery={filterSearchQuery}
onPrioritiesChange={(priorities: TaskPriority[]) => (filterPriorities = priorities)}
onProjectChange={(projectId: string | null) => (filterProjectId = projectId)}
onLabelsChange={(labelIds: string[]) => (filterLabelIds = labelIds)}
onSearchChange={(query: string) => (filterSearchQuery = query)}
onClearFilters={clearFilters}
showSearch={true}
showLabels={true}
/>
{#if hasActiveFilters}
<div class="mt-2 flex items-center gap-2">
<button type="button" class="save-filter-btn" onclick={saveFiltersToView}>
<FloppyDisk size={14} />
Filter speichern
</button>
{#if activeView?.filter}
<button
type="button"
class="clear-saved-filter-btn"
onclick={async () => {
clearFilters();
if (activeViewId) {
await boardViewsStore.updateView(activeViewId, { filter: undefined });
}
}}
>
Gespeicherten Filter entfernen
</button>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Board Content -->
<div class="board-container" class:mobile-bottom-padding={isMobile}>
{#if activeView}
<BoardViewRenderer
view={{
...activeView,
filter: hasActiveFilters
? {
projectId: filterProjectId ?? undefined,
tagIds: filterLabelIds.length > 0 ? filterLabelIds : undefined,
priorities: filterPriorities.length > 0 ? filterPriorities : undefined,
}
: activeView.filter,
}}
{layoutOverride}
/>
{:else if boardViews.value.length === 0}
<div class="empty-state">
<p class="text-muted-foreground">Views werden geladen...</p>
</div>
{/if}
</div>
<!-- View Selector - Bottom on Mobile -->
{#if isMobile}
<div class="mobile-selector">
<ViewSelector
views={boardViews.value}
{activeViewId}
onSelect={handleSelectView}
onCreate={handleCreateView}
onEdit={handleEditView}
/>
{#if activeView}
<BoardViewRenderer view={activeView} {layoutOverride} />
{:else}
<div class="empty-state">
<p class="text-muted-foreground">Views werden geladen...</p>
</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;
flex-direction: column;
height: calc(100vh - 140px);
}
.page-title {
font-size: 1.875rem;
font-weight: 700;
color: var(--foreground);
margin: 0;
}
.board-container {
flex: 1;
min-height: 0;
overflow: hidden;
}
.board-container.mobile-bottom-padding {
padding-bottom: 70px;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
/* Mobile selector fixed at bottom */
.mobile-selector {
position: fixed;
bottom: 70px;
left: 0;
right: 0;
z-index: 40;
background: linear-gradient(to top, var(--background) 0%, transparent 100%);
padding-bottom: 0.75rem;
}
/* Glass-Pill filter button */
.filter-button {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
color: #6b7280;
}
:global(.dark) .filter-button {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #9ca3af;
}
.filter-button:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
color: #374151;
}
:global(.dark) .filter-button:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
color: #f3f4f6;
}
.filter-button.active {
background: #8b5cf6;
border-color: #8b5cf6;
color: white;
box-shadow:
0 4px 6px -1px rgba(139, 92, 246, 0.3),
0 2px 4px -1px rgba(139, 92, 246, 0.2);
}
.filter-button.active:hover {
background: #7c3aed;
border-color: #7c3aed;
color: white;
}
.save-filter-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: #8b5cf6;
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s;
}
.save-filter-btn:hover {
background: rgba(139, 92, 246, 0.2);
border-color: rgba(139, 92, 246, 0.3);
}
:global(.dark) .save-filter-btn {
color: #a78bfa;
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.25);
}
:global(.dark) .save-filter-btn:hover {
background: rgba(139, 92, 246, 0.25);
border-color: rgba(139, 92, 246, 0.35);
}
.clear-saved-filter-btn {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s;
}
.clear-saved-filter-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.2);
}
:global(.dark) .clear-saved-filter-btn {
color: #9ca3af;
border-color: rgba(255, 255, 255, 0.12);
}
:global(.dark) .clear-saved-filter-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.animate-in {
animation: animateIn 0.2s ease-out;
}
.slide-in-from-top-2 {
--tw-enter-translate-y: -0.5rem;
}
@keyframes animateIn {
from {
opacity: 0;
transform: translateY(var(--tw-enter-translate-y, 0));
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>