refactor(mana/web): consolidate ListView scaffolding into BaseListView

Every workbench-style module ListView reimplemented the same
liveQuery + filter + scroll-area + empty-state shell. Extract a
shared <BaseListView> in @mana/shared-ui (with toolbar/header/
listHeader/item/empty snippets) and migrate the 17 modules whose
list templates fit the workbench tailwind track.

While here:
- migrate DeckCard onto the existing (previously unused) shared
  Card atom from shared-ui/atoms.
- fix a latent type bug in times/ListView: it was reading .date /
  .startTime / .isRunning off LocalTimeEntry, which doesn't define
  them. Now uses the proper joined TimeEntry via toTimeEntry() like
  the rest of the times module.

Modules with their own scoped-CSS layout track (calendar, finance,
contacts, notes, places, todo, photos, habits, automations, dreams,
cycles) and outliers (calc, events, playground, zitare) are left
alone — migrating them would be a visual rewrite, not a structural
shell swap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 18:40:47 +02:00
parent d941ff2231
commit c3cb9dd533
21 changed files with 878 additions and 1044 deletions

View file

@ -67,7 +67,7 @@ export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './mol
export { ConfirmationPopover } from './molecules';
// Organisms
export { Modal, ConfirmationModal, FormModal, AppSlider } from './organisms';
export { Modal, ConfirmationModal, FormModal, AppSlider, BaseListView } from './organisms';
export type { AppItem } from './organisms';
// Network Graph

View file

@ -0,0 +1,101 @@
<script lang="ts" generics="T">
/**
* BaseListView — shared scaffolding for module ListView components.
*
* Encodes the workbench convention every Mana module's ListView shares:
* wrapper padding → optional stats header → scrollable item region → empty state.
*
* Per-item rendering and data fetching stay with the consumer:
* - Pass `items` (already filtered & decrypted via queries.ts).
* - Provide an `item` snippet that renders one row.
* - Provide an optional `header` snippet for stat counts or filters.
*
* @example
* ```svelte
* <BaseListView items={sorted} getKey={(q) => q.id} emptyTitle="Keine Fragen">
* {#snippet header()}
* <span>{questions.length} Fragen</span>
* {/snippet}
* {#snippet item(question)}
* <button onclick={() => navigate('detail', { id: question.id })}>
* {question.title}
* </button>
* {/snippet}
* </BaseListView>
* ```
*/
import type { Snippet } from 'svelte';
import { EmptyState } from '../molecules';
interface Props<TItem> {
/** Items to render. Should already be filtered (deletedAt) and decrypted. */
items: TItem[];
/** Stable key extractor for the {#each} block. */
getKey: (item: TItem) => string | number;
/** Snippet that renders a single item row. */
item: Snippet<[TItem, number]>;
/** Optional header snippet (e.g. stat counts, filters). */
header?: Snippet;
/** Optional snippet rendered above the items but inside the scroll area. */
listHeader?: Snippet;
/** Optional snippet rendered at the very top, outside the scroll area (toolbar, voice bar, ...). */
toolbar?: Snippet;
/** Empty-state title. */
emptyTitle?: string;
/** Empty-state message. */
emptyMessage?: string;
/** Custom empty-state icon snippet. */
emptyIcon?: Snippet;
/** Override the entire empty area. */
empty?: Snippet;
/** Optional outer class override. */
class?: string;
/** Optional class for the inner scroll/list area. Use this to switch to grid, etc. */
listClass?: string;
}
let {
items,
getKey,
item,
header,
listHeader,
toolbar,
emptyTitle = 'Nichts hier',
emptyMessage,
emptyIcon,
empty,
class: className = '',
listClass = '',
}: Props<T> = $props();
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4 {className}">
{#if toolbar}
{@render toolbar()}
{/if}
{#if header}
<div class="flex gap-3 text-xs text-white/40">
{@render header()}
</div>
{/if}
<div class="flex-1 overflow-auto {listClass}">
{#if listHeader}
{@render listHeader()}
{/if}
{#each items as entry, i (getKey(entry))}
{@render item(entry, i)}
{/each}
{#if items.length === 0}
{#if empty}
{@render empty()}
{:else}
<EmptyState variant="compact" title={emptyTitle} message={emptyMessage} icon={emptyIcon} />
{/if}
{/if}
</div>
</div>

View file

@ -2,6 +2,7 @@ export { default as Modal } from './Modal.svelte';
export { default as ConfirmationModal } from './ConfirmationModal.svelte';
export { default as FormModal } from './FormModal.svelte';
export { default as AppSlider } from './AppSlider.svelte';
export { default as BaseListView } from './BaseListView.svelte';
export type { AppItem } from './AppSlider.types';
// Network Graph