refactor(mana/web): consolidate DetailView scaffolding into DetailViewShell + useDetailEntity

Every module's inline-editable DetailView reimplemented the same
plumbing: liveQuery → optional decryptRecord → reset on id change
→ focused/confirmDelete state → save-on-blur → deleteWithUndo via
toastStore. Plus ~150 LOC of duplicated scoped CSS for the
.detail-view / .title-input / .properties / .prop-row / .section /
.danger-zone style track.

Extract two pieces:

  - useDetailEntity (svelte runes module, $lib/data/detail-entity.svelte.ts)
    handles the JS plumbing: liveQuery + optional decrypt + reset
    on id change + focused/confirmDelete state + deleteWithUndo.
    Supports a custom `loader` for cross-table joins (events+timeBlocks,
    timeEntries+timeBlocks, tasks+timeBlocks).

  - DetailViewShell ($lib/components/DetailViewShell.svelte)
    handles the visual scaffold: outer flex column with scroll,
    loading/not-found state, body snippet, danger zone with confirm
    flow. Exports the shared field/property/section/meta classes as
    :global so consumer snippets can use them without redefining.

Migrated 16 of the 18 DetailViews. Skipped:
  - zitare: no DB entity (quotes from bundled @zitare/content),
    no edit/delete flow.
  - events: different page shape (centered max-width, edit/view
    modes, eventId via direct prop instead of params, nested guest
    list / RSVP sections).

Side wins:
  - 6 encrypted modules (storage, uload, music, questions, calendar,
    todo) now route their decrypt logic through one path instead of
    six separate `liveQuery + decryptRecord({ ...raw })` variations.
  - times/views/DetailView had the same latent type bug as the
    ListView (reading .date / .startTime / .endTime / .source off
    LocalTimeEntry, which doesn't define them). Now uses toTimeEntry()
    via the loader option for the joined TimeEntry shape.

Net impact: ~3640 LOC removed across the 16 files (~49% reduction),
~510 LOC added for shell + helper. Net ~3130 LOC saved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 22:10:42 +02:00
parent c3cb9dd533
commit 30787e36d2
18 changed files with 1472 additions and 4572 deletions

View file

@ -0,0 +1,374 @@
<!--
DetailViewShell — shared visual scaffold for module DetailView screens.
Encodes the workbench DetailView convention every Mana module shares:
outer flex column with scroll → loading/not-found state →
consumer body (title input, property rows, sections) → danger zone with
confirm-delete flow.
All inner classes (`.title-input`, `.properties`, `.prop-row`,
`.section`, `.section-label`, `.description-input`, `.prop-input`,
`.prop-value`, `.prop-label`, `.meta`, `.toggle-btn`, `.fav-btn`) are
exported as `:global` so consumers can use them inside the snippet body
without redefining the styles.
Pair with `useDetailEntity` from `$lib/data/detail-entity.svelte` for the
matching JS plumbing (livequery + decrypt + focused/confirmDelete state +
delete-with-undo).
-->
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { Trash } from '@mana/shared-icons';
interface Props {
entity: T | null;
loading?: boolean;
notFoundLabel?: string;
confirmDelete: boolean;
onAskDelete: () => void;
onCancelDelete: () => void;
onConfirmDelete: () => void;
confirmDeleteLabel?: string;
confirmDeleteAction?: string;
body: Snippet<[T]>;
/** Optional snippet rendered inside the body slot when entity is null but not loading. */
notFound?: Snippet;
}
let {
entity,
loading = false,
notFoundLabel = 'Nicht gefunden',
confirmDelete,
onAskDelete,
onCancelDelete,
onConfirmDelete,
confirmDeleteLabel = 'Wirklich löschen?',
confirmDeleteAction = 'Löschen',
body,
notFound,
}: Props = $props();
</script>
<div class="detail-view">
{#if loading && !entity}
<p class="empty">Lade…</p>
{:else if !entity}
{#if notFound}
{@render notFound()}
{:else}
<p class="empty">{notFoundLabel}</p>
{/if}
{:else}
{@render body(entity)}
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">{confirmDeleteLabel}</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={onConfirmDelete}>{confirmDeleteAction}</button>
<button class="action-btn" onclick={onCancelDelete}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={onAskDelete}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title input — used by consumer body via :global */
:global(.detail-view .title-input) {
font-size: 1.125rem;
font-weight: 600;
border: 1px solid transparent;
background: transparent;
outline: none;
color: #374151;
padding: 0.125rem 0;
border-radius: 0.25rem;
transition: border-color 0.15s;
width: 100%;
}
:global(.detail-view .title-input:hover),
:global(.detail-view .title-input:focus) {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark .detail-view .title-input) {
color: #f3f4f6;
}
:global(.dark .detail-view .title-input:hover),
:global(.dark .detail-view .title-input:focus) {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.detail-view .title-row) {
display: flex;
align-items: center;
gap: 0.5rem;
}
:global(.detail-view .title-icon) {
font-size: 1.25rem;
}
/* Properties */
:global(.detail-view .properties) {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
:global(.detail-view .prop-row) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
gap: 0.5rem;
}
:global(.detail-view .prop-label) {
font-size: 0.75rem;
color: #9ca3af;
}
:global(.detail-view .prop-value) {
font-size: 0.8125rem;
color: #374151;
max-width: 60%;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.detail-view .prop-value.mono) {
font-family: monospace;
font-size: 0.75rem;
}
:global(.dark .detail-view .prop-value) {
color: #e5e7eb;
}
:global(.detail-view .prop-input) {
flex: 1;
min-width: 0;
max-width: 60%;
text-align: right;
font-size: 0.8125rem;
color: #374151;
background: transparent;
border: 1px solid transparent;
outline: none;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
transition: border-color 0.15s;
}
:global(.detail-view .prop-input:hover),
:global(.detail-view .prop-input:focus) {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark .detail-view .prop-input) {
color: #f3f4f6;
}
:global(.dark .detail-view .prop-input:hover),
:global(.dark .detail-view .prop-input:focus) {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.detail-view .prop-select) {
flex: 1;
min-width: 0;
max-width: 60%;
text-align: right;
font-size: 0.8125rem;
color: #374151;
background: transparent;
border: 1px solid transparent;
outline: none;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
transition: border-color 0.15s;
}
:global(.detail-view .prop-select:hover),
:global(.detail-view .prop-select:focus) {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark .detail-view .prop-select) {
color: #f3f4f6;
}
:global(.dark .detail-view .prop-select:hover),
:global(.dark .detail-view .prop-select:focus) {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.detail-view .color-input) {
width: 28px;
height: 24px;
border: 1px solid transparent;
border-radius: 0.25rem;
padding: 0;
cursor: pointer;
}
:global(.detail-view .color-input:hover) {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark .detail-view .color-input:hover) {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.detail-view .toggle-btn) {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.1);
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
:global(.detail-view .toggle-btn.active) {
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
border-color: rgba(99, 102, 241, 0.3);
}
:global(.dark .detail-view .toggle-btn) {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.detail-view .fav-btn) {
border: none;
background: transparent;
cursor: pointer;
padding: 0.125rem;
color: #9ca3af;
display: flex;
align-items: center;
transition: color 0.15s;
}
:global(.detail-view .fav-btn:hover),
:global(.detail-view .fav-btn.active) {
color: #ef4444;
}
/* Sections (description, etc.) */
:global(.detail-view .section) {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
:global(.detail-view .section-label) {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
:global(.detail-view .description-input) {
width: 100%;
font-size: 0.8125rem;
color: #374151;
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.08);
outline: none;
padding: 0.5rem;
border-radius: 0.375rem;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
:global(.detail-view .description-input:focus) {
border-color: rgba(0, 0, 0, 0.15);
}
:global(.dark .detail-view .description-input) {
color: #f3f4f6;
border-color: rgba(255, 255, 255, 0.08);
}
:global(.dark .detail-view .description-input:focus) {
border-color: rgba(255, 255, 255, 0.15);
}
/* Meta footer */
:global(.detail-view .meta) {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark .detail-view .meta) {
border-color: rgba(255, 255, 255, 0.06);
}
/* Danger zone */
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.action-btn {
min-height: 44px;
}
}
</style>

View file

@ -0,0 +1,187 @@
/**
* useDetailEntity shared plumbing for inline-editable DetailView screens.
*
* Encodes the boilerplate every Mana module's DetailView shares:
* liveQuery optional decrypt reset on id change focused/confirmDelete state
* delete-with-undo via toastStore.
*
* The consumer keeps its own per-field `$state` variables and form template;
* this helper just removes the ~50 lines of repeated wiring.
*
* @example
* ```svelte
* <script lang="ts">
* import { useDetailEntity } from '$lib/data/detail-entity.svelte';
*
* let { params, goBack }: ViewProps = $props();
* let editName = $state('');
*
* const detail = useDetailEntity<LocalFile>({
* id: () => params.fileId as string,
* table: 'files',
* decrypt: true,
* onLoad: (file) => { editName = file.name; },
* });
*
* async function saveField() {
* detail.blur();
* await filesStore.renameFile(detail.entity!.id, editName.trim());
* }
* </script>
*
* <input bind:value={editName} onfocus={detail.focus} onblur={saveField} />
* <button onclick={() => detail.deleteWithUndo({
* label: 'Datei gelöscht',
* delete: () => filesStore.deleteFile(detail.entity!.id),
* goBack,
* })}>Löschen</button>
* ```
*/
import { liveQuery } from 'dexie';
import { onDestroy } from 'svelte';
import { db } from './database';
import { decryptRecord } from './crypto';
import { toastStore } from '@mana/shared-ui/toast';
export interface DetailEntityOptions<T> {
/** Reactive getter for the entity id (driven by `params.someId`). */
id: () => string | undefined;
/** Dexie table name. Required unless `loader` is provided. */
table?: string;
/** When true, the loaded record is run through `decryptRecord` (only used with `table`). */
decrypt?: boolean;
/**
* Custom loader for cross-table joins or other non-trivial fetches.
* If provided, takes precedence over `table` + `decrypt`.
* Receives the current id; should return the assembled record (or null).
*/
loader?: (id: string) => Promise<T | null>;
/**
* Called whenever a fresh entity is loaded AND no input is currently
* focused. Use this to populate per-field `$state` variables.
*/
onLoad?: (entity: T) => void;
}
export interface DeleteWithUndoOptions {
/** Toast label, e.g. "Datei gelöscht". */
label: string;
/** Performs the soft-delete (typically a store call). */
delete: () => Promise<void>;
/** Navigation back, called after the delete resolves. */
goBack: () => void;
}
export interface DetailEntityHandle<T> {
readonly entity: T | null;
readonly loading: boolean;
readonly focused: boolean;
readonly confirmDelete: boolean;
focus: () => void;
blur: () => void;
askDelete: () => void;
cancelDelete: () => void;
deleteWithUndo: (opts: DeleteWithUndoOptions) => Promise<void>;
}
export function useDetailEntity<T extends { id?: string }>(
opts: DetailEntityOptions<T>
): DetailEntityHandle<T> {
let entity = $state<T | null>(null);
let loading = $state(true);
let focused = $state(false);
let confirmDelete = $state(false);
let unsubscribe: (() => void) | null = null;
$effect(() => {
const id = opts.id();
// Reset transient UI state on every id change.
confirmDelete = false;
focused = false;
entity = null;
loading = true;
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
if (!id) {
loading = false;
return;
}
const obs = liveQuery(async () => {
if (opts.loader) {
return await opts.loader(id);
}
if (!opts.table) {
throw new Error('useDetailEntity requires either `table` or `loader`');
}
const raw = await db.table<T>(opts.table).get(id);
if (!raw) return null;
if (opts.decrypt) {
// clone before decrypt so the IDB-cached row stays ciphertext
return (await decryptRecord(opts.table, { ...raw })) as T;
}
return raw;
});
const sub = obs.subscribe((val) => {
entity = val ?? null;
loading = false;
if (val && !focused) {
opts.onLoad?.(val);
}
});
unsubscribe = () => sub.unsubscribe();
});
onDestroy(() => {
if (unsubscribe) unsubscribe();
});
return {
get entity() {
return entity;
},
get loading() {
return loading;
},
get focused() {
return focused;
},
get confirmDelete() {
return confirmDelete;
},
focus: () => {
focused = true;
},
blur: () => {
focused = false;
},
askDelete: () => {
confirmDelete = true;
},
cancelDelete: () => {
confirmDelete = false;
},
async deleteWithUndo({ label, delete: doDelete, goBack }: DeleteWithUndoOptions) {
const id = opts.id();
if (!id) return;
await doDelete();
goBack();
if (opts.table) {
toastStore.undo(label, () => {
db.table(opts.table!).update(id, {
deletedAt: undefined,
updatedAt: new Date().toISOString(),
});
});
} else {
// Custom loader: consumer must provide its own undo via the toast directly.
toastStore.undo(label, () => {});
}
},
};
}

View file

@ -3,11 +3,12 @@
All fields are always editable. Changes auto-save on blur. All fields are always editable. Changes auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { decryptRecord } from '$lib/data/crypto'; import { decryptRecord } from '$lib/data/crypto';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { eventsStore } from '../stores/events.svelte'; import { eventsStore } from '../stores/events.svelte';
import { Trash, MapPin, Clock, X } from '@mana/shared-icons'; import { MapPin, Clock, X } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalEvent } from '../types'; import type { LocalEvent } from '../types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
@ -15,12 +16,10 @@
import LinkedItems from '$lib/components/links/LinkedItems.svelte'; import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { toastStore } from '@mana/shared-ui/toast'; import { toastStore } from '@mana/shared-ui/toast';
let { navigate, goBack, params }: ViewProps = $props(); let { navigate, params, goBack }: ViewProps = $props();
let eventId = $derived(params.eventId as string); let eventId = $derived(params.eventId as string);
let event = $state<LocalEvent | null>(null); type EventBundle = LocalEvent & { _block: LocalTimeBlock | null };
let timeBlock = $state<LocalTimeBlock | null>(null);
let confirmDelete = $state(false);
let editTitle = $state(''); let editTitle = $state('');
let editDate = $state(''); let editDate = $state('');
@ -30,14 +29,46 @@
let editDescription = $state(''); let editDescription = $state('');
let editAllDay = $state(false); let editAllDay = $state(false);
let focused = $state(false);
const tagsQuery = useAllTags(); const tagsQuery = useAllTags();
let allTags = $derived(tagsQuery.value ?? []); let allTags = $derived(tagsQuery.value ?? []);
let eventTags = $derived(getTagsByIds(allTags, event?.tagIds ?? []));
const detail = useDetailEntity<EventBundle>({
id: () => eventId,
loader: async (id) => {
const ev = await db.table<LocalEvent>('events').get(id);
if (!ev) return null;
const block = ev.timeBlockId
? await db.table<LocalTimeBlock>('timeBlocks').get(ev.timeBlockId)
: null;
// Both rows carry encrypted title/description (events also encrypts
// location). Decrypt clones so the inline editor binds to plaintext.
const decryptedEvent = (await decryptRecord('events', { ...ev })) as LocalEvent;
const decryptedBlock = block
? ((await decryptRecord('timeBlocks', { ...block })) as LocalTimeBlock)
: null;
return { ...decryptedEvent, _block: decryptedBlock } as EventBundle;
},
onLoad: (bundle) => {
const tb = bundle._block;
editTitle = bundle.title;
if (tb) {
editDate = tb.startDate.split('T')[0];
editStartTime = tb.startDate.includes('T')
? (tb.startDate.split('T')[1]?.substring(0, 5) ?? '')
: '';
const endStr = tb.endDate ?? tb.startDate;
editEndTime = endStr.includes('T') ? (endStr.split('T')[1]?.substring(0, 5) ?? '') : '';
editAllDay = tb.allDay;
}
editLocation = bundle.location ?? '';
editDescription = bundle.description ?? '';
},
});
let eventTags = $derived(getTagsByIds(allTags, detail.entity?.tagIds ?? []));
async function removeTag(tagId: string) { async function removeTag(tagId: string) {
const current = event?.tagIds ?? []; const current = detail.entity?.tagIds ?? [];
const removed = current.filter((id) => id !== tagId); const removed = current.filter((id) => id !== tagId);
await eventsStore.updateTagIds(eventId, removed); await eventsStore.updateTagIds(eventId, removed);
toastStore.undo('Tag entfernt', () => { toastStore.undo('Tag entfernt', () => {
@ -45,51 +76,12 @@
}); });
} }
$effect(() => {
eventId; // track
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(async () => {
const ev = await db.table<LocalEvent>('events').get(eventId);
if (!ev) return { event: null, block: null };
const block = ev.timeBlockId
? await db.table<LocalTimeBlock>('timeBlocks').get(ev.timeBlockId)
: null;
// Both rows carry encrypted title/description (events also encrypts
// location). Decrypt clones so the inline editor binds to plaintext.
const decryptedEvent = await decryptRecord('events', { ...ev });
const decryptedBlock = block ? await decryptRecord('timeBlocks', { ...block }) : null;
return { event: decryptedEvent, block: decryptedBlock };
}).subscribe((val) => {
event = val?.event ?? null;
timeBlock = val?.block ?? null;
if (val?.event && val?.block && !focused) {
const tb = val.block;
editTitle = val.event.title;
editDate = tb.startDate.split('T')[0];
editStartTime = tb.startDate.includes('T')
? tb.startDate.split('T')[1]?.substring(0, 5)
: '';
const endStr = tb.endDate ?? tb.startDate;
editEndTime = endStr.includes('T') ? endStr.split('T')[1]?.substring(0, 5) : '';
editLocation = val.event.location ?? '';
editDescription = val.event.description ?? '';
editAllDay = tb.allDay;
}
});
return () => sub.unsubscribe();
});
async function saveField() { async function saveField() {
focused = false; detail.blur();
const startTime = editAllDay ? `${editDate}T00:00:00` : `${editDate}T${editStartTime}:00`; const startTime = editAllDay ? `${editDate}T00:00:00` : `${editDate}T${editStartTime}:00`;
const endTime = editAllDay ? `${editDate}T23:59:59` : `${editDate}T${editEndTime}:00`; const endTime = editAllDay ? `${editDate}T23:59:59` : `${editDate}T${editEndTime}:00`;
await eventsStore.updateEvent(eventId, { await eventsStore.updateEvent(eventId, {
title: editTitle.trim() || event?.title || 'Untitled', title: editTitle.trim() || detail.entity?.title || 'Untitled',
startTime, startTime,
endTime, endTime,
isAllDay: editAllDay, isAllDay: editAllDay,
@ -112,20 +104,25 @@
} }
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !event} entity={detail.entity}
<p class="empty">Termin nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Termin nicht gefunden"
<!-- Title --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Termin wirklich löschen?"
onConfirmDelete={deleteEvent}
>
{#snippet body(event)}
<input <input
class="title-input" class="title-input"
bind:value={editTitle} bind:value={editTitle}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Titel..." placeholder="Titel..."
/> />
<!-- Time -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-icon"><Clock size={14} /></span> <span class="prop-icon"><Clock size={14} /></span>
@ -134,7 +131,7 @@
type="date" type="date"
class="prop-input" class="prop-input"
bind:value={editDate} bind:value={editDate}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
{#if !editAllDay} {#if !editAllDay}
@ -143,7 +140,7 @@
type="time" type="time"
class="prop-input" class="prop-input"
bind:value={editStartTime} bind:value={editStartTime}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
<span class="time-sep"></span> <span class="time-sep"></span>
@ -151,7 +148,7 @@
type="time" type="time"
class="prop-input" class="prop-input"
bind:value={editEndTime} bind:value={editEndTime}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
</div> </div>
@ -168,21 +165,20 @@
<input <input
class="prop-input wide" class="prop-input wide"
bind:value={editLocation} bind:value={editLocation}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Ort hinzufügen..." placeholder="Ort hinzufügen..."
/> />
</div> </div>
{#if timeBlock?.recurrenceRule} {#if event._block?.recurrenceRule}
<div class="prop-row"> <div class="prop-row">
<span class="prop-icon">🔁</span> <span class="prop-icon">🔁</span>
<span class="prop-value recurrence">{timeBlock.recurrenceRule}</span> <span class="prop-value recurrence">{event._block.recurrenceRule}</span>
</div> </div>
{/if} {/if}
</div> </div>
<!-- Tags -->
{#if eventTags.length > 0} {#if eventTags.length > 0}
<div class="section"> <div class="section">
<span class="section-label">Tags</span> <span class="section-label">Tags</span>
@ -202,23 +198,20 @@
</div> </div>
{/if} {/if}
<!-- Links -->
<LinkedItems recordRef={{ app: 'calendar', collection: 'events', id: eventId }} {navigate} /> <LinkedItems recordRef={{ app: 'calendar', collection: 'events', id: eventId }} {navigate} />
<!-- Description -->
<div class="section"> <div class="section">
<span class="section-label">Beschreibung</span> <span class="section-label">Beschreibung</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editDescription} bind:value={editDescription}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Beschreibung hinzufügen..." placeholder="Beschreibung hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
{#if event.createdAt} {#if event.createdAt}
<span>Erstellt: {new Date(event.createdAt).toLocaleDateString('de')}</span> <span>Erstellt: {new Date(event.createdAt).toLocaleDateString('de')}</span>
@ -227,118 +220,27 @@
<span>Bearbeitet: {new Date(event.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(event.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Termin wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteEvent}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style> <style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
.title-input {
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.properties {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.prop-row {
display: flex;
align-items: flex-start;
gap: 0.625rem;
}
.prop-icon { .prop-icon {
color: #9ca3af;
display: flex; display: flex;
margin-top: 0.25rem; align-items: center;
color: #9ca3af;
flex-shrink: 0; flex-shrink: 0;
} width: 1rem;
.prop-value.recurrence {
font-size: 0.75rem;
color: #6b7280;
}
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-input:hover,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.prop-input.wide {
flex: 1;
}
.prop-input::placeholder {
color: #c0bfba;
}
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-input:hover,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .prop-input::placeholder {
color: #4b5563;
} }
.time-fields { .time-fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.375rem; gap: 0.375rem;
flex: 1;
} }
.time-range { .time-range {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.25rem;
} }
.time-sep { .time-sep {
color: #9ca3af; color: #9ca3af;
@ -347,16 +249,16 @@
.allday-label { .allday-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.375rem;
font-size: 0.75rem; font-size: 0.75rem;
color: #6b7280; color: #9ca3af;
cursor: pointer; cursor: pointer;
} }
:global(.detail-view .prop-input.wide) {
.section { flex: 1;
display: flex; min-width: 0;
flex-direction: column; max-width: none;
gap: 0.375rem; text-align: left;
} }
.tags-list { .tags-list {
display: flex; display: flex;
@ -369,138 +271,26 @@
gap: 0.25rem; gap: 0.25rem;
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 9999px; border-radius: 9999px;
border: none;
background: color-mix(in srgb, var(--tag-color) 12%, transparent); background: color-mix(in srgb, var(--tag-color) 12%, transparent);
border: none;
font-size: 0.6875rem; font-size: 0.6875rem;
color: #6b7280; color: #6b7280;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: opacity 0.15s;
} }
.tag-pill:hover { .tag-pill:hover {
background: color-mix(in srgb, var(--tag-color) 20%, transparent); opacity: 0.8;
color: #ef4444;
}
:global(.dark) .tag-pill {
background: color-mix(in srgb, var(--tag-color) 18%, transparent);
color: #9ca3af;
}
:global(.dark) .tag-pill:hover {
background: color-mix(in srgb, var(--tag-color) 28%, transparent);
color: #ef4444;
} }
.tag-dot { .tag-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 9999px; border-radius: 9999px;
flex-shrink: 0;
} }
.section-label { :global(.dark) .tag-pill {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af; color: #9ca3af;
} }
.description-input { .recurrence {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem; font-size: 0.75rem;
color: #6b7280; color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.action-btn,
.tag-pill {
min-height: 44px;
}
.prop-input,
.allday-label {
min-height: 44px;
}
} }
</style> </style>

View file

@ -5,64 +5,50 @@
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { deckStore } from '../stores/decks.svelte'; import { deckStore } from '../stores/decks.svelte';
import { toastStore } from '@mana/shared-ui/toast';
import { Trash } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalDeck, LocalCard } from '../types'; import type { LocalDeck, LocalCard } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let deckId = $derived(params.deckId as string); let deckId = $derived(params.deckId as string);
let deck = $state<LocalDeck | null>(null);
let cardCount = $state(0);
let confirmDelete = $state(false);
// Edit fields
let editName = $state(''); let editName = $state('');
let editDescription = $state(''); let editDescription = $state('');
let editColor = $state('#6366f1'); let editColor = $state('#6366f1');
let editIsPublic = $state(false); let editIsPublic = $state(false);
let focused = $state(false); const detail = useDetailEntity<LocalDeck>({
id: () => deckId,
$effect(() => { table: 'decks',
deckId; onLoad: (val) => {
confirmDelete = false; editName = val.name;
focused = false; editDescription = val.description ?? '';
editColor = val.color ?? '#6366f1';
editIsPublic = val.isPublic;
},
}); });
let cardCount = $state(0);
$effect(() => { $effect(() => {
const sub = liveQuery(() => db.table<LocalDeck>('decks').get(deckId)).subscribe((val) => { const sub = liveQuery(async () =>
deck = val ?? null; db
if (val && !focused) {
editName = val.name;
editDescription = val.description ?? '';
editColor = val.color ?? '#6366f1';
editIsPublic = val.isPublic;
}
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalCard>('cards') .table<LocalCard>('cards')
.where('deckId') .where('deckId')
.equals(deckId) .equals(deckId)
.filter((c) => !c.deletedAt) .filter((c) => !c.deletedAt)
.count(); .count()
}).subscribe((val) => { ).subscribe((val) => {
cardCount = val ?? 0; cardCount = val ?? 0;
}); });
return () => sub.unsubscribe(); return () => sub.unsubscribe();
}); });
async function saveField() { async function saveField() {
focused = false; detail.blur();
await deckStore.updateDeck(deckId, { await deckStore.updateDeck(deckId, {
title: editName.trim() || deck?.name || 'Unbenannt', title: editName.trim() || detail.entity?.name || 'Unbenannt',
description: editDescription.trim() || undefined, description: editDescription.trim() || undefined,
isPublic: editIsPublic, isPublic: editIsPublic,
}); });
@ -77,31 +63,32 @@
editIsPublic = !editIsPublic; editIsPublic = !editIsPublic;
await deckStore.updateDeck(deckId, { isPublic: editIsPublic }); await deckStore.updateDeck(deckId, { isPublic: editIsPublic });
} }
async function deleteDeck() {
const id = deckId;
await deckStore.deleteDeck(id);
goBack();
toastStore.undo('Deck gelöscht', () => {
db.table('decks').update(id, { deletedAt: undefined, updatedAt: new Date().toISOString() });
});
}
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !deck} entity={detail.entity}
<p class="empty">Deck nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Deck nicht gefunden"
<!-- Name --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Deck wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Deck gelöscht',
delete: () => deckStore.deleteDeck(deckId),
goBack,
})}
>
{#snippet body(deck)}
<input <input
class="title-input" class="title-input"
bind:value={editName} bind:value={editName}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Deck-Name..." placeholder="Deck-Name..."
/> />
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Farbe</span> <span class="prop-label">Farbe</span>
@ -109,7 +96,7 @@
type="color" type="color"
class="color-input" class="color-input"
bind:value={editColor} bind:value={editColor}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
</div> </div>
@ -134,259 +121,23 @@
{/if} {/if}
</div> </div>
<!-- Description -->
<div class="section"> <div class="section">
<span class="section-label">Beschreibung</span> <span class="section-label">Beschreibung</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editDescription} bind:value={editDescription}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Beschreibung hinzufügen..." placeholder="Beschreibung hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(deck.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(deck.createdAt ?? '').toLocaleDateString('de')}</span>
{#if deck.updatedAt} {#if deck.updatedAt}
<span>Bearbeitet: {new Date(deck.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(deck.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Deck wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteDeck}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title */
.title-input {
font-size: 1.125rem;
font-weight: 600;
border: 1px solid transparent;
background: transparent;
outline: none;
color: #374151;
padding: 0.125rem 0;
border-radius: 0.25rem;
transition: border-color 0.15s;
}
.title-input:hover,
.title-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:hover,
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-value {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.color-input {
width: 28px;
height: 24px;
border: 1px solid transparent;
border-radius: 0.25rem;
padding: 0;
cursor: pointer;
background: transparent;
}
.color-input:hover {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .color-input:hover {
border-color: rgba(255, 255, 255, 0.1);
}
.toggle-btn {
font-size: 0.8125rem;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.toggle-btn:hover {
border-color: rgba(0, 0, 0, 0.1);
}
.toggle-btn.active {
color: #22c55e;
}
:global(.dark) .toggle-btn {
color: #6b7280;
}
:global(.dark) .toggle-btn:hover {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .toggle-btn.active {
color: #22c55e;
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.action-btn,
.toggle-btn,
.color-input {
min-height: 44px;
}
}
</style>

View file

@ -5,49 +5,34 @@
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { favoritesStore } from '../stores/favorites.svelte'; import { favoritesStore } from '../stores/favorites.svelte';
import { toastStore } from '@mana/shared-ui/toast'; import { Star } from '@mana/shared-icons';
import { Star, Trash } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalLocation, LocalFavorite } from '../types'; import type { LocalLocation, LocalFavorite } from '../types';
import { CATEGORY_COLORS } from '../types'; import { CATEGORY_COLORS } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let locationId = $derived(params.locationId as string); let locationId = $derived(params.locationId as string);
let location = $state<LocalLocation | null>(null);
let isFavorite = $state(false);
let confirmDelete = $state(false);
// Edit fields
let editName = $state(''); let editName = $state('');
let editCategory = $state<LocalLocation['category']>('sight'); let editCategory = $state<LocalLocation['category']>('sight');
let editDescription = $state(''); let editDescription = $state('');
let editAddress = $state(''); let editAddress = $state('');
let focused = $state(false); const detail = useDetailEntity<LocalLocation>({
id: () => locationId,
$effect(() => { table: 'ccLocations',
locationId; onLoad: (val) => {
confirmDelete = false; editName = val.name;
focused = false; editCategory = val.category;
}); editDescription = val.description ?? '';
editAddress = val.address ?? '';
$effect(() => { },
const sub = liveQuery(() => db.table<LocalLocation>('ccLocations').get(locationId)).subscribe(
(val) => {
location = val ?? null;
if (val && !focused) {
editName = val.name;
editCategory = val.category;
editDescription = val.description ?? '';
editAddress = val.address ?? '';
}
}
);
return () => sub.unsubscribe();
}); });
let isFavorite = $state(false);
$effect(() => { $effect(() => {
const sub = liveQuery(async () => { const sub = liveQuery(async () => {
const all = await db.table<LocalFavorite>('ccFavorites').toArray(); const all = await db.table<LocalFavorite>('ccFavorites').toArray();
@ -59,9 +44,9 @@
}); });
async function saveField() { async function saveField() {
focused = false; detail.blur();
await db.table('ccLocations').update(locationId, { await db.table('ccLocations').update(locationId, {
name: editName.trim() || location?.name || 'Ohne Name', name: editName.trim() || detail.entity?.name || 'Ohne Name',
category: editCategory, category: editCategory,
description: editDescription.trim() || undefined, description: editDescription.trim() || undefined,
address: editAddress.trim() || undefined, address: editAddress.trim() || undefined,
@ -81,18 +66,10 @@
} }
async function deleteLocation() { async function deleteLocation() {
const id = locationId; await db.table('ccLocations').update(locationId, {
await db.table('ccLocations').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
goBack();
toastStore.undo('Ort gelöscht', () => {
db.table('ccLocations').update(id, {
deletedAt: undefined,
updatedAt: new Date().toISOString(),
});
});
} }
const categoryLabels: Record<LocalLocation['category'], string> = { const categoryLabels: Record<LocalLocation['category'], string> = {
@ -110,25 +87,35 @@
}; };
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !location} entity={detail.entity}
<p class="empty">Ort nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Ort nicht gefunden"
<!-- Title row with favorite --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Ort wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Ort gelöscht',
delete: deleteLocation,
goBack,
})}
>
{#snippet body(location)}
<div class="title-row"> <div class="title-row">
<input <input
class="title-input" class="title-input"
bind:value={editName} bind:value={editName}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Name..." placeholder="Name..."
/> />
<button class="fav-btn" onclick={toggleFavorite}> <button class="fav-btn" class:active={isFavorite} onclick={toggleFavorite}>
<Star size={18} weight={isFavorite ? 'fill' : 'regular'} /> <Star size={18} weight={isFavorite ? 'fill' : 'regular'} />
</button> </button>
</div> </div>
<!-- Category dot -->
<div class="category-row"> <div class="category-row">
<div class="category-dot" style="background: {CATEGORY_COLORS[editCategory] ?? '#666'}"></div> <div class="category-dot" style="background: {CATEGORY_COLORS[editCategory] ?? '#666'}"></div>
<select class="prop-select" bind:value={editCategory} onchange={handleCategoryChange}> <select class="prop-select" bind:value={editCategory} onchange={handleCategoryChange}>
@ -138,113 +125,41 @@
</select> </select>
</div> </div>
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Adresse</span> <span class="prop-label">Adresse</span>
<input <input
class="prop-input address-input" class="prop-input"
bind:value={editAddress} bind:value={editAddress}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Adresse..." placeholder="Adresse..."
/> />
</div> </div>
</div> </div>
<!-- Description -->
<div class="section"> <div class="section">
<span class="section-label">Beschreibung</span> <span class="section-label">Beschreibung</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editDescription} bind:value={editDescription}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Beschreibung hinzufügen..." placeholder="Beschreibung hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(location.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(location.createdAt ?? '').toLocaleDateString('de')}</span>
{#if location.updatedAt} {#if location.updatedAt}
<span>Bearbeitet: {new Date(location.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(location.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Ort wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteLocation}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style> <style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title row */
.title-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.title-input {
flex: 1;
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.fav-btn {
border: none;
background: transparent;
cursor: pointer;
padding: 0.125rem;
color: #eab308;
flex-shrink: 0;
transition: transform 0.15s;
}
.fav-btn:hover {
transform: scale(1.15);
}
/* Category row */
.category-row { .category-row {
display: flex; display: flex;
align-items: center; align-items: center;
@ -256,171 +171,4 @@
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-select,
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-select:hover,
.prop-input:hover,
.prop-select:focus,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.address-input {
flex: 1;
min-width: 0;
margin-left: 0.5rem;
text-align: right;
}
:global(.dark) .prop-select,
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-select:hover,
:global(.dark) .prop-input:hover,
:global(.dark) .prop-select:focus,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.fav-btn,
.action-btn {
min-height: 44px;
}
.prop-select,
.prop-input {
min-height: 44px;
}
}
</style> </style>

View file

@ -3,32 +3,19 @@
All fields are always editable. Changes auto-save on blur. All fields are always editable. Changes auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie'; import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { db } from '$lib/data/database'; import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { contactsStore } from '../stores/contacts.svelte'; import { contactsStore } from '../stores/contacts.svelte';
import { import { Star, EnvelopeSimple, Phone, MapPin, Briefcase, Globe, X } from '@mana/shared-icons';
Trash,
Star,
EnvelopeSimple,
Phone,
MapPin,
Briefcase,
Globe,
X,
} from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalContact } from '../types'; import type { LocalContact } from '../types';
import { useAllTags, getTagsByIds } from '@mana/shared-stores'; import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import LinkedItems from '$lib/components/links/LinkedItems.svelte'; import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { toastStore } from '@mana/shared-ui/toast'; import { toastStore } from '@mana/shared-ui/toast';
let { navigate, goBack, params }: ViewProps = $props(); let { navigate, params, goBack }: ViewProps = $props();
let contactId = $derived(params.contactId as string); let contactId = $derived(params.contactId as string);
let contact = $state<LocalContact | null>(null);
let confirmDelete = $state(false);
let focused = $state(false);
let editFirstName = $state(''); let editFirstName = $state('');
let editLastName = $state(''); let editLastName = $state('');
let editEmail = $state(''); let editEmail = $state('');
@ -46,10 +33,32 @@
const tagsQuery = useAllTags(); const tagsQuery = useAllTags();
let allTags = $derived(tagsQuery.value ?? []); let allTags = $derived(tagsQuery.value ?? []);
let contactTags = $derived(getTagsByIds(allTags, contact?.tagIds ?? []));
const detail = useDetailEntity<LocalContact>({
id: () => contactId,
table: 'contacts',
onLoad: (c) => {
editFirstName = c.firstName ?? '';
editLastName = c.lastName ?? '';
editEmail = c.email ?? '';
editPhone = c.phone ?? '';
editMobile = c.mobile ?? '';
editCompany = c.company ?? '';
editJobTitle = c.jobTitle ?? '';
editStreet = c.street ?? '';
editCity = c.city ?? '';
editPostalCode = c.postalCode ?? '';
editCountry = c.country ?? '';
editBirthday = c.birthday ?? '';
editWebsite = c.website ?? '';
editNotes = c.notes ?? '';
},
});
let contactTags = $derived(getTagsByIds(allTags, detail.entity?.tagIds ?? []));
async function removeTag(tagId: string) { async function removeTag(tagId: string) {
const current = contact?.tagIds ?? []; const current = detail.entity?.tagIds ?? [];
const removed = current.filter((id) => id !== tagId); const removed = current.filter((id) => id !== tagId);
await contactsStore.updateTagIds(contactId, removed); await contactsStore.updateTagIds(contactId, removed);
toastStore.undo('Tag entfernt', () => { toastStore.undo('Tag entfernt', () => {
@ -57,39 +66,6 @@
}); });
} }
$effect(() => {
contactId; // track
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalContact>('contacts').get(contactId)).subscribe(
(val) => {
contact = val ?? null;
if (val && !focused) syncFields(val);
}
);
return () => sub.unsubscribe();
});
function syncFields(c: LocalContact) {
editFirstName = c.firstName ?? '';
editLastName = c.lastName ?? '';
editEmail = c.email ?? '';
editPhone = c.phone ?? '';
editMobile = c.mobile ?? '';
editCompany = c.company ?? '';
editJobTitle = c.jobTitle ?? '';
editStreet = c.street ?? '';
editCity = c.city ?? '';
editPostalCode = c.postalCode ?? '';
editCountry = c.country ?? '';
editBirthday = c.birthday ?? '';
editWebsite = c.website ?? '';
editNotes = c.notes ?? '';
}
function initials(c: LocalContact): string { function initials(c: LocalContact): string {
const f = c.firstName?.[0] ?? ''; const f = c.firstName?.[0] ?? '';
const l = c.lastName?.[0] ?? ''; const l = c.lastName?.[0] ?? '';
@ -97,7 +73,7 @@
} }
async function saveField() { async function saveField() {
focused = false; detail.blur();
await contactsStore.updateContact(contactId, { await contactsStore.updateContact(contactId, {
firstName: editFirstName.trim() || null, firstName: editFirstName.trim() || null,
lastName: editLastName.trim() || null, lastName: editLastName.trim() || null,
@ -119,39 +95,38 @@
async function toggleFavorite() { async function toggleFavorite() {
await contactsStore.toggleFavorite(contactId); await contactsStore.toggleFavorite(contactId);
} }
async function deleteContact() {
const id = contactId;
await contactsStore.deleteContact(id);
goBack();
toastStore.undo('Kontakt gelöscht', () => {
db.table('contacts').update(id, {
deletedAt: undefined,
updatedAt: new Date().toISOString(),
});
});
}
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !contact} entity={detail.entity}
<p class="empty">Kontakt nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Kontakt nicht gefunden"
<!-- Profile header --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Kontakt wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Kontakt gelöscht',
delete: () => contactsStore.deleteContact(contactId),
goBack,
})}
>
{#snippet body(contact)}
<div class="profile-header"> <div class="profile-header">
<div class="avatar-large">{initials(contact)}</div> <div class="avatar-large">{initials(contact)}</div>
<div class="name-fields"> <div class="name-fields">
<input <input
class="name-input" class="name-input"
bind:value={editFirstName} bind:value={editFirstName}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Vorname" placeholder="Vorname"
/> />
<input <input
class="name-input" class="name-input"
bind:value={editLastName} bind:value={editLastName}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Nachname" placeholder="Nachname"
/> />
@ -161,14 +136,13 @@
</button> </button>
</div> </div>
<!-- Contact fields -->
<div class="fields"> <div class="fields">
<div class="field-row"> <div class="field-row">
<span class="field-icon"><EnvelopeSimple size={14} /></span> <span class="field-icon"><EnvelopeSimple size={14} /></span>
<input <input
class="field-input" class="field-input"
bind:value={editEmail} bind:value={editEmail}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="E-Mail" placeholder="E-Mail"
type="email" type="email"
@ -180,7 +154,7 @@
<input <input
class="field-input" class="field-input"
bind:value={editPhone} bind:value={editPhone}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Telefon" placeholder="Telefon"
type="tel" type="tel"
@ -192,7 +166,7 @@
<input <input
class="field-input" class="field-input"
bind:value={editMobile} bind:value={editMobile}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Mobil" placeholder="Mobil"
type="tel" type="tel"
@ -205,14 +179,14 @@
<input <input
class="field-input" class="field-input"
bind:value={editCompany} bind:value={editCompany}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Firma" placeholder="Firma"
/> />
<input <input
class="field-input" class="field-input"
bind:value={editJobTitle} bind:value={editJobTitle}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Position" placeholder="Position"
/> />
@ -225,7 +199,7 @@
<input <input
class="field-input" class="field-input"
bind:value={editStreet} bind:value={editStreet}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Straße" placeholder="Straße"
/> />
@ -233,14 +207,14 @@
<input <input
class="field-input small" class="field-input small"
bind:value={editPostalCode} bind:value={editPostalCode}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="PLZ" placeholder="PLZ"
/> />
<input <input
class="field-input" class="field-input"
bind:value={editCity} bind:value={editCity}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Stadt" placeholder="Stadt"
/> />
@ -248,7 +222,7 @@
<input <input
class="field-input" class="field-input"
bind:value={editCountry} bind:value={editCountry}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Land" placeholder="Land"
/> />
@ -260,7 +234,7 @@
<input <input
class="field-input" class="field-input"
bind:value={editWebsite} bind:value={editWebsite}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Website" placeholder="Website"
type="url" type="url"
@ -272,14 +246,13 @@
<input <input
class="field-input" class="field-input"
bind:value={editBirthday} bind:value={editBirthday}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
type="date" type="date"
/> />
</div> </div>
</div> </div>
<!-- Tags -->
{#if contactTags.length > 0} {#if contactTags.length > 0}
<div class="section"> <div class="section">
<span class="section-label">Tags</span> <span class="section-label">Tags</span>
@ -299,26 +272,23 @@
</div> </div>
{/if} {/if}
<!-- Links -->
<LinkedItems <LinkedItems
recordRef={{ app: 'contacts', collection: 'contacts', id: contactId }} recordRef={{ app: 'contacts', collection: 'contacts', id: contactId }}
{navigate} {navigate}
/> />
<!-- Notes -->
<div class="section"> <div class="section">
<span class="section-label">Notizen</span> <span class="section-label">Notizen</span>
<textarea <textarea
class="notes-input" class="description-input"
bind:value={editNotes} bind:value={editNotes}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Notizen hinzufügen..." placeholder="Notizen hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
{#if contact.createdAt} {#if contact.createdAt}
<span>Erstellt: {new Date(contact.createdAt).toLocaleDateString('de')}</span> <span>Erstellt: {new Date(contact.createdAt).toLocaleDateString('de')}</span>
@ -327,58 +297,27 @@
<span>Bearbeitet: {new Date(contact.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(contact.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Kontakt wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteContact}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style> <style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Profile header */
.profile-header { .profile-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
} }
.avatar-large { .avatar-large {
width: 48px; width: 56px;
height: 48px; height: 56px;
border-radius: 9999px; border-radius: 9999px;
flex-shrink: 0; background: rgba(0, 0, 0, 0.06);
color: #6b7280;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(0, 0, 0, 0.06); font-size: 1.125rem;
font-size: 1rem;
font-weight: 600; font-weight: 600;
color: #6b7280; flex-shrink: 0;
} }
:global(.dark) .avatar-large { :global(.dark) .avatar-large {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
@ -388,51 +327,32 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.125rem; gap: 0.25rem;
min-width: 0; min-width: 0;
} }
.name-input { .name-input {
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 600; font-weight: 500;
border: none; border: 1px solid transparent;
background: transparent; background: transparent;
outline: none; outline: none;
color: #374151; color: #374151;
padding: 0.125rem 0; padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
transition: border-color 0.15s;
} }
.name-input:hover,
.name-input:focus { .name-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1); border-color: rgba(0, 0, 0, 0.1);
}
.name-input::placeholder {
color: #c0bfba;
font-weight: 400;
} }
:global(.dark) .name-input { :global(.dark) .name-input {
color: #f3f4f6; color: #f3f4f6;
} }
:global(.dark) .name-input:hover,
:global(.dark) .name-input:focus { :global(.dark) .name-input:focus {
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
} }
:global(.dark) .name-input::placeholder {
color: #4b5563;
}
.fav-btn {
border: none;
background: transparent;
cursor: pointer;
color: #d1d5db;
padding: 0.25rem;
transition: color 0.15s;
flex-shrink: 0;
}
.fav-btn.active {
color: #f59e0b;
}
.fav-btn:hover {
color: #f59e0b;
}
/* Fields */
.fields { .fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -441,15 +361,19 @@
.field-row { .field-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.625rem; gap: 0.5rem;
} }
.field-icon { .field-icon {
flex-shrink: 0;
color: #9ca3af; color: #9ca3af;
display: flex; display: flex;
margin-top: 0.3rem; align-items: center;
flex-shrink: 0; padding-top: 0.375rem;
width: 1rem;
} }
.field-input { .field-input {
flex: 1;
min-width: 0;
font-size: 0.8125rem; font-size: 0.8125rem;
padding: 0.25rem 0.375rem; padding: 0.25rem 0.375rem;
border-radius: 0.25rem; border-radius: 0.25rem;
@ -457,19 +381,12 @@
background: transparent; background: transparent;
color: #374151; color: #374151;
outline: none; outline: none;
flex: 1;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.field-input:hover, .field-input:hover,
.field-input:focus { .field-input:focus {
border-color: rgba(0, 0, 0, 0.1); border-color: rgba(0, 0, 0, 0.1);
} }
.field-input::placeholder {
color: #c0bfba;
}
.field-input.small {
max-width: 5rem;
}
:global(.dark) .field-input { :global(.dark) .field-input {
color: #e5e7eb; color: #e5e7eb;
} }
@ -477,8 +394,8 @@
:global(.dark) .field-input:focus { :global(.dark) .field-input:focus {
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
} }
:global(.dark) .field-input::placeholder { .field-input.small {
color: #4b5563; max-width: 5rem;
} }
.field-group { .field-group {
display: flex; display: flex;
@ -488,15 +405,9 @@
} }
.field-row-inline { .field-row-inline {
display: flex; display: flex;
gap: 0.375rem; gap: 0.25rem;
} }
/* Notes */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.tags-list { .tags-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -508,140 +419,21 @@
gap: 0.25rem; gap: 0.25rem;
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 9999px; border-radius: 9999px;
border: none;
background: color-mix(in srgb, var(--tag-color) 12%, transparent); background: color-mix(in srgb, var(--tag-color) 12%, transparent);
border: none;
font-size: 0.6875rem; font-size: 0.6875rem;
color: #6b7280; color: #6b7280;
cursor: pointer; cursor: pointer;
transition: all 0.15s;
} }
.tag-pill:hover { .tag-pill:hover {
background: color-mix(in srgb, var(--tag-color) 20%, transparent); opacity: 0.8;
color: #ef4444;
}
:global(.dark) .tag-pill {
background: color-mix(in srgb, var(--tag-color) 18%, transparent);
color: #9ca3af;
}
:global(.dark) .tag-pill:hover {
background: color-mix(in srgb, var(--tag-color) 28%, transparent);
color: #ef4444;
} }
.tag-dot { .tag-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 9999px; border-radius: 9999px;
flex-shrink: 0;
} }
.section-label { :global(.dark) .tag-pill {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af; color: #9ca3af;
} }
.notes-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.notes-input:hover,
.notes-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.notes-input::placeholder {
color: #c0bfba;
}
:global(.dark) .notes-input {
color: #f3f4f6;
}
:global(.dark) .notes-input:hover,
:global(.dark) .notes-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .notes-input::placeholder {
color: #4b5563;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.fav-btn,
.action-btn,
.tag-pill {
min-height: 44px;
}
.field-input,
.name-input {
min-height: 44px;
}
}
</style> </style>

View file

@ -5,90 +5,73 @@
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { collectionsStore } from '../stores/collections.svelte'; import { collectionsStore } from '../stores/collections.svelte';
import { toastStore } from '@mana/shared-ui/toast';
import { Trash } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalCollection, LocalItem } from '../types'; import type { LocalCollection, LocalItem } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let collectionId = $derived(params.collectionId as string); let collectionId = $derived(params.collectionId as string);
let collection = $state<LocalCollection | null>(null);
let itemCount = $state(0);
let confirmDelete = $state(false);
// Edit fields
let editName = $state(''); let editName = $state('');
let editDescription = $state(''); let editDescription = $state('');
let editIcon = $state(''); let editIcon = $state('');
let editColor = $state(''); let editColor = $state('');
let focused = $state(false); const detail = useDetailEntity<LocalCollection>({
id: () => collectionId,
$effect(() => { table: 'invCollections',
collectionId; onLoad: (val) => {
confirmDelete = false; editName = val.name;
focused = false; editDescription = val.description ?? '';
editIcon = val.icon ?? '';
editColor = val.color ?? '';
},
}); });
let itemCount = $state(0);
$effect(() => { $effect(() => {
const sub = liveQuery(() => const sub = liveQuery(async () =>
db.table<LocalCollection>('invCollections').get(collectionId) db
).subscribe((val) => {
collection = val ?? null;
if (val && !focused) {
editName = val.name;
editDescription = val.description ?? '';
editIcon = val.icon ?? '';
editColor = val.color ?? '';
}
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalItem>('invItems') .table<LocalItem>('invItems')
.where('collectionId') .where('collectionId')
.equals(collectionId) .equals(collectionId)
.filter((i) => !i.deletedAt) .filter((i) => !i.deletedAt)
.count(); .count()
}).subscribe((val) => { ).subscribe((val) => {
itemCount = val ?? 0; itemCount = val ?? 0;
}); });
return () => sub.unsubscribe(); return () => sub.unsubscribe();
}); });
async function saveField() { async function saveField() {
focused = false; detail.blur();
await collectionsStore.update(collectionId, { await collectionsStore.update(collectionId, {
name: editName.trim() || collection?.name || 'Unbenannt', name: editName.trim() || detail.entity?.name || 'Unbenannt',
description: editDescription.trim() || null, description: editDescription.trim() || null,
icon: editIcon.trim() || null, icon: editIcon.trim() || null,
color: editColor.trim() || null, color: editColor.trim() || null,
}); });
} }
async function deleteCollection() {
const id = collectionId;
await collectionsStore.delete(id);
goBack();
toastStore.undo('Sammlung gelöscht', () => {
db.table('invCollections').update(id, {
deletedAt: undefined,
updatedAt: new Date().toISOString(),
});
});
}
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !collection} entity={detail.entity}
<p class="empty">Sammlung nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Sammlung nicht gefunden"
<!-- Icon + Title --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Sammlung wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Sammlung gelöscht',
delete: () => collectionsStore.delete(collectionId),
goBack,
})}
>
{#snippet body(collection)}
<div class="title-row"> <div class="title-row">
{#if collection.icon} {#if collection.icon}
<span class="title-icon">{collection.icon}</span> <span class="title-icon">{collection.icon}</span>
@ -96,22 +79,21 @@
<input <input
class="title-input" class="title-input"
bind:value={editName} bind:value={editName}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Name..." placeholder="Name..."
/> />
</div> </div>
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Icon</span> <span class="prop-label">Icon</span>
<input <input
class="prop-input" class="prop-input"
bind:value={editIcon} bind:value={editIcon}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="z.B. &#128230;" placeholder="z.B. 📦"
/> />
</div> </div>
@ -120,252 +102,35 @@
<input <input
class="prop-input" class="prop-input"
bind:value={editColor} bind:value={editColor}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="z.B. #78716C" placeholder="z.B. #78716C"
/> />
</div> </div>
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Gegenstaende</span> <span class="prop-label">Gegenstände</span>
<span class="prop-value">{itemCount}</span> <span class="prop-value">{itemCount}</span>
</div> </div>
</div> </div>
<!-- Description -->
<div class="section"> <div class="section">
<span class="section-label">Beschreibung</span> <span class="section-label">Beschreibung</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editDescription} bind:value={editDescription}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Beschreibung hinzufuegen..." placeholder="Beschreibung hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(collection.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(collection.createdAt ?? '').toLocaleDateString('de')}</span>
{#if collection.updatedAt} {#if collection.updatedAt}
<span>Bearbeitet: {new Date(collection.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(collection.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Sammlung wirklich loeschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteCollection}>Loeschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Loeschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
.title-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.title-icon {
font-size: 1.25rem;
flex-shrink: 0;
padding-top: 0.125rem;
}
.title-input {
flex: 1;
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-value {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-input:hover,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-input:hover,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.action-btn {
min-height: 44px;
}
.prop-input {
min-height: 44px;
}
}
</style>

View file

@ -3,47 +3,32 @@
Memo details with transcript, pin toggle, auto-save on blur. Memo details with transcript, pin toggle, auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie'; import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { db } from '$lib/data/database'; import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { memosStore } from '../stores/memos.svelte'; import { memosStore } from '../stores/memos.svelte';
import { toastStore } from '@mana/shared-ui/toast'; import { PushPin } from '@mana/shared-icons';
import { Trash, PushPin } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalMemo, ProcessingStatus } from '../types'; import type { LocalMemo, ProcessingStatus } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let memoId = $derived(params.memoId as string); let memoId = $derived(params.memoId as string);
let memo = $state<LocalMemo | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editTitle = $state(''); let editTitle = $state('');
let editIntro = $state(''); let editIntro = $state('');
let editLanguage = $state(''); let editLanguage = $state('');
let focused = $state(false); const detail = useDetailEntity<LocalMemo>({
id: () => memoId,
$effect(() => { table: 'memos',
memoId; onLoad: (val) => {
confirmDelete = false; editTitle = val.title ?? '';
focused = false; editIntro = val.intro ?? '';
}); editLanguage = val.language ?? '';
},
$effect(() => {
const sub = liveQuery(() => db.table<LocalMemo>('memos').get(memoId)).subscribe((val) => {
memo = val ?? null;
if (val && !focused) {
editTitle = val.title ?? '';
editIntro = val.intro ?? '';
editLanguage = val.language ?? '';
}
});
return () => sub.unsubscribe();
}); });
async function saveField() { async function saveField() {
focused = false; detail.blur();
await memosStore.update(memoId, { await memosStore.update(memoId, {
title: editTitle.trim() || null, title: editTitle.trim() || null,
intro: editIntro.trim() || null, intro: editIntro.trim() || null,
@ -52,6 +37,7 @@
} }
async function togglePin() { async function togglePin() {
const memo = detail.entity;
if (!memo) return; if (!memo) return;
if (memo.isPinned) { if (memo.isPinned) {
await memosStore.unpin(memoId); await memosStore.unpin(memoId);
@ -60,15 +46,6 @@
} }
} }
async function deleteMemo() {
const id = memoId;
await memosStore.delete(id);
goBack();
toastStore.undo('Memo gelöscht', () => {
db.table('memos').update(id, { deletedAt: undefined, updatedAt: new Date().toISOString() });
});
}
function formatDuration(ms: number | null): string { function formatDuration(ms: number | null): string {
if (!ms) return '--:--'; if (!ms) return '--:--';
const sec = Math.round(ms / 1000); const sec = Math.round(ms / 1000);
@ -92,16 +69,27 @@
}; };
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !memo} entity={detail.entity}
<p class="empty">Memo nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Memo nicht gefunden"
<!-- Title + Pin --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Memo wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Memo gelöscht',
delete: () => memosStore.delete(memoId),
goBack,
})}
>
{#snippet body(memo)}
<div class="title-row"> <div class="title-row">
<input <input
class="title-input" class="title-input"
bind:value={editTitle} bind:value={editTitle}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Titel..." placeholder="Titel..."
/> />
@ -110,11 +98,10 @@
</button> </button>
</div> </div>
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Status</span> <span class="prop-label">Status</span>
<span class="status-badge" style="color: {statusColors[memo.processingStatus]}"> <span class="prop-value" style="color: {statusColors[memo.processingStatus]}">
{statusLabels[memo.processingStatus]} {statusLabels[memo.processingStatus]}
</span> </span>
</div> </div>
@ -129,27 +116,25 @@
<input <input
class="prop-input" class="prop-input"
bind:value={editLanguage} bind:value={editLanguage}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="z.B. de" placeholder="z.B. de"
/> />
</div> </div>
</div> </div>
<!-- Intro -->
<div class="section"> <div class="section">
<span class="section-label">Zusammenfassung</span> <span class="section-label">Zusammenfassung</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editIntro} bind:value={editIntro}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Zusammenfassung hinzufuegen..." placeholder="Zusammenfassung hinzufügen..."
rows={2} rows={2}
></textarea> ></textarea>
</div> </div>
<!-- Transcript (read-only) -->
{#if memo.transcript} {#if memo.transcript}
<div class="section"> <div class="section">
<span class="section-label">Transkript</span> <span class="section-label">Transkript</span>
@ -157,78 +142,26 @@
</div> </div>
{/if} {/if}
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(memo.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(memo.createdAt ?? '').toLocaleDateString('de')}</span>
{#if memo.updatedAt} {#if memo.updatedAt}
<span>Bearbeitet: {new Date(memo.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(memo.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Memo wirklich loeschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteMemo}>Loeschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Loeschen
</button>
{/if}
</div>
{/if}
</div>
<style> <style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
.title-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.title-input {
flex: 1;
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.pin-btn { .pin-btn {
border: none; border: none;
background: transparent; background: transparent;
color: #9ca3af;
cursor: pointer; cursor: pointer;
padding: 0.125rem; padding: 0.125rem;
color: #9ca3af;
flex-shrink: 0; flex-shrink: 0;
transition: color 0.15s; transition:
color 0.15s,
transform 0.15s;
} }
.pin-btn:hover { .pin-btn:hover {
color: #6b7280; color: #6b7280;
@ -236,183 +169,19 @@
.pin-btn.pinned { .pin-btn.pinned {
color: #f59e0b; color: #f59e0b;
} }
:global(.dark) .pin-btn:hover {
color: #e5e7eb;
}
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-value {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.status-badge {
font-size: 0.8125rem;
font-weight: 500;
}
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-input:hover,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-input:hover,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
.transcript { .transcript {
font-size: 0.8125rem; font-size: 0.8125rem;
color: #374151; color: #6b7280;
line-height: 1.5;
padding: 0.5rem; padding: 0.5rem;
border-radius: 0.375rem; border-radius: 0.375rem;
background: rgba(0, 0, 0, 0.02); background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.04);
white-space: pre-wrap; white-space: pre-wrap;
max-height: 12rem; max-height: 12rem;
overflow-y: auto; overflow-y: auto;
line-height: 1.5;
} }
:global(.dark) .transcript { :global(.dark) .transcript {
color: #e5e7eb;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af; color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.pin-btn,
.action-btn {
min-height: 44px;
}
.prop-input {
min-height: 44px;
}
} }
</style> </style>

View file

@ -3,22 +3,16 @@
All fields are always editable. Changes auto-save on blur. All fields are always editable. Changes auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie'; import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { db } from '$lib/data/database'; import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { decryptRecord } from '$lib/data/crypto';
import { libraryStore } from '../stores/library.svelte'; import { libraryStore } from '../stores/library.svelte';
import { toastStore } from '@mana/shared-ui/toast'; import { Heart } from '@mana/shared-icons';
import { Heart, Trash } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalSong } from '../types'; import type { LocalSong } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let songId = $derived(params.songId as string); let songId = $derived(params.songId as string);
let song = $state<LocalSong | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editTitle = $state(''); let editTitle = $state('');
let editArtist = $state(''); let editArtist = $state('');
let editAlbum = $state(''); let editAlbum = $state('');
@ -26,38 +20,24 @@
let editYear = $state<number | null>(null); let editYear = $state<number | null>(null);
let editBpm = $state<number | null>(null); let editBpm = $state<number | null>(null);
let focused = $state(false); const detail = useDetailEntity<LocalSong>({
id: () => songId,
$effect(() => { table: 'songs',
songId; decrypt: true,
confirmDelete = false; onLoad: (val) => {
focused = false; editTitle = val.title;
}); editArtist = val.artist ?? '';
editAlbum = val.album ?? '';
$effect(() => { editGenre = val.genre ?? '';
const sub = liveQuery(async () => { editYear = val.year ?? null;
const raw = await db.table<LocalSong>('songs').get(songId); editBpm = val.bpm ?? null;
// title is encrypted on disk; decrypt a clone so the inline },
// editor binds to plaintext.
return raw ? await decryptRecord('songs', { ...raw }) : null;
}).subscribe((val) => {
song = val ?? null;
if (val && !focused) {
editTitle = val.title;
editArtist = val.artist ?? '';
editAlbum = val.album ?? '';
editGenre = val.genre ?? '';
editYear = val.year ?? null;
editBpm = val.bpm ?? null;
}
});
return () => sub.unsubscribe();
}); });
async function saveField() { async function saveField() {
focused = false; detail.blur();
await libraryStore.updateMetadata(songId, { await libraryStore.updateMetadata(songId, {
title: editTitle.trim() || song?.title || 'Ohne Titel', title: editTitle.trim() || detail.entity?.title || 'Ohne Titel',
artist: editArtist.trim() || undefined, artist: editArtist.trim() || undefined,
album: editAlbum.trim() || undefined, album: editAlbum.trim() || undefined,
genre: editGenre.trim() || undefined, genre: editGenre.trim() || undefined,
@ -70,15 +50,6 @@
await libraryStore.toggleFavorite(songId); await libraryStore.toggleFavorite(songId);
} }
async function deleteSong() {
const id = songId;
await libraryStore.delete(id);
goBack();
toastStore.undo('Song gelöscht', () => {
db.table('songs').update(id, { deletedAt: undefined, updatedAt: new Date().toISOString() });
});
}
function formatDuration(sec?: number | null): string { function formatDuration(sec?: number | null): string {
if (!sec) return '--:--'; if (!sec) return '--:--';
const m = Math.floor(sec / 60); const m = Math.floor(sec / 60);
@ -87,32 +58,42 @@
} }
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !song} entity={detail.entity}
<p class="empty">Song nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Song nicht gefunden"
<!-- Title row with favorite --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Song wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Song gelöscht',
delete: () => libraryStore.delete(songId),
goBack,
})}
>
{#snippet body(song)}
<div class="title-row"> <div class="title-row">
<input <input
class="title-input" class="title-input"
bind:value={editTitle} bind:value={editTitle}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Titel..." placeholder="Titel..."
/> />
<button class="fav-btn" onclick={toggleFavorite}> <button class="fav-btn" class:active={song.favorite} onclick={toggleFavorite}>
<Heart size={18} weight={song.favorite ? 'fill' : 'regular'} /> <Heart size={18} weight={song.favorite ? 'fill' : 'regular'} />
</button> </button>
</div> </div>
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Künstler</span> <span class="prop-label">Künstler</span>
<input <input
class="prop-input" class="prop-input"
bind:value={editArtist} bind:value={editArtist}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Unbekannt" placeholder="Unbekannt"
/> />
@ -123,9 +104,9 @@
<input <input
class="prop-input" class="prop-input"
bind:value={editAlbum} bind:value={editAlbum}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="--" placeholder=""
/> />
</div> </div>
@ -134,9 +115,9 @@
<input <input
class="prop-input" class="prop-input"
bind:value={editGenre} bind:value={editGenre}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="--" placeholder=""
/> />
</div> </div>
@ -146,9 +127,9 @@
type="number" type="number"
class="prop-input" class="prop-input"
bind:value={editYear} bind:value={editYear}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="--" placeholder=""
/> />
</div> </div>
@ -158,9 +139,9 @@
type="number" type="number"
class="prop-input" class="prop-input"
bind:value={editBpm} bind:value={editBpm}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="--" placeholder=""
/> />
</div> </div>
@ -175,7 +156,6 @@
</div> </div>
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(song.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(song.createdAt ?? '').toLocaleDateString('de')}</span>
{#if song.updatedAt} {#if song.updatedAt}
@ -185,205 +165,5 @@
<span>Zuletzt gehört: {new Date(song.lastPlayedAt).toLocaleDateString('de')}</span> <span>Zuletzt gehört: {new Date(song.lastPlayedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Song wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteSong}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title row */
.title-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.title-input {
flex: 1;
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.fav-btn {
border: none;
background: transparent;
cursor: pointer;
padding: 0.125rem;
color: #ef4444;
flex-shrink: 0;
transition: transform 0.15s;
}
.fav-btn:hover {
transform: scale(1.15);
}
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-value {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
text-align: right;
}
.prop-input:hover,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-input:hover,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Number inputs — hide spinners */
.prop-input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
.prop-input[type='number']::-webkit-inner-spin-button,
.prop-input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.fav-btn,
.action-btn {
min-height: 44px;
}
.prop-input {
min-height: 44px;
}
}
</style>

View file

@ -3,23 +3,20 @@
All fields are always editable. Changes auto-save on blur. All fields are always editable. Changes auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { placesStore } from '../stores/places.svelte'; import { placesStore } from '../stores/places.svelte';
import { Trash, Star, MapPin, X } from '@mana/shared-icons'; import { Star, MapPin, X } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types'; import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types';
import { useAllTags, getTagsByIds } from '@mana/shared-stores'; import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import LinkedItems from '$lib/components/links/LinkedItems.svelte'; import LinkedItems from '$lib/components/links/LinkedItems.svelte';
let { navigate, goBack, params }: ViewProps = $props(); let { navigate, params, goBack }: ViewProps = $props();
let placeId = $derived(params.placeId as string); let placeId = $derived(params.placeId as string);
let place = $state<LocalPlace | null>(null);
let logs = $state<LocalLocationLog[]>([]);
let confirmDelete = $state(false);
let focused = $state(false);
let editName = $state(''); let editName = $state('');
let editDescription = $state(''); let editDescription = $state('');
let editAddress = $state(''); let editAddress = $state('');
@ -29,7 +26,32 @@
const tagsQuery = useAllTags(); const tagsQuery = useAllTags();
let allTags = $derived(tagsQuery.value ?? []); let allTags = $derived(tagsQuery.value ?? []);
let placeTags = $derived(getTagsByIds(allTags, place?.tagIds ?? []));
const detail = useDetailEntity<LocalPlace>({
id: () => placeId,
table: 'places',
onLoad: (p) => {
editName = p.name ?? '';
editDescription = p.description ?? '';
editAddress = p.address ?? '';
editCategory = p.category ?? 'other';
editLatitude = p.latitude?.toString() ?? '';
editLongitude = p.longitude?.toString() ?? '';
},
});
const logsQuery = useLiveQueryWithDefault(async () => {
const all = await db
.table<LocalLocationLog>('locationLogs')
.where('placeId')
.equals(placeId)
.reverse()
.sortBy('timestamp');
return all.slice(0, 20);
}, [] as LocalLocationLog[]);
const logs = $derived(logsQuery.value);
let placeTags = $derived(getTagsByIds(allTags, detail.entity?.tagIds ?? []));
const CATEGORIES: { value: PlaceCategory; label: string }[] = [ const CATEGORIES: { value: PlaceCategory; label: string }[] = [
{ value: 'home', label: 'Zuhause' }, { value: 'home', label: 'Zuhause' },
@ -42,52 +64,15 @@
]; ];
async function removeTag(tagId: string) { async function removeTag(tagId: string) {
const current = place?.tagIds ?? []; const current = detail.entity?.tagIds ?? [];
await placesStore.updateTagIds( await placesStore.updateTagIds(
placeId, placeId,
current.filter((id) => id !== tagId) current.filter((id) => id !== tagId)
); );
} }
$effect(() => {
placeId; // track
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalPlace>('places').get(placeId)).subscribe((val) => {
place = val ?? null;
if (val && !focused) syncFields(val);
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(() =>
db
.table<LocalLocationLog>('locationLogs')
.where('placeId')
.equals(placeId)
.reverse()
.sortBy('timestamp')
).subscribe((val) => {
logs = (val ?? []).slice(0, 20);
});
return () => sub.unsubscribe();
});
function syncFields(p: LocalPlace) {
editName = p.name ?? '';
editDescription = p.description ?? '';
editAddress = p.address ?? '';
editCategory = p.category ?? 'other';
editLatitude = p.latitude?.toString() ?? '';
editLongitude = p.longitude?.toString() ?? '';
}
async function saveField() { async function saveField() {
focused = false; detail.blur();
const lat = parseFloat(editLatitude); const lat = parseFloat(editLatitude);
const lng = parseFloat(editLongitude); const lng = parseFloat(editLongitude);
await placesStore.updatePlace(placeId, { await placesStore.updatePlace(placeId, {
@ -125,6 +110,7 @@
} }
let mapUrl = $derived.by(() => { let mapUrl = $derived.by(() => {
const place = detail.entity;
if (!place || !place.latitude || !place.longitude) return ''; if (!place || !place.latitude || !place.longitude) return '';
const lat = place.latitude; const lat = place.latitude;
const lng = place.longitude; const lng = place.longitude;
@ -133,11 +119,17 @@
}); });
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !place} entity={detail.entity}
<p class="empty">Ort nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Ort nicht gefunden"
<!-- Header --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Ort wirklich löschen?"
onConfirmDelete={deletePlace}
>
{#snippet body(place)}
<div class="profile-header"> <div class="profile-header">
<div class="place-avatar"> <div class="place-avatar">
<MapPin size={20} /> <MapPin size={20} />
@ -146,7 +138,7 @@
<input <input
class="name-input" class="name-input"
bind:value={editName} bind:value={editName}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Name" placeholder="Name"
/> />
@ -156,7 +148,6 @@
</button> </button>
</div> </div>
<!-- Map Preview -->
{#if mapUrl} {#if mapUrl}
<div class="map-container"> <div class="map-container">
<iframe <iframe
@ -170,7 +161,6 @@
</div> </div>
{/if} {/if}
<!-- Fields -->
<div class="fields"> <div class="fields">
<div class="field-row"> <div class="field-row">
<span class="field-label">Kategorie</span> <span class="field-label">Kategorie</span>
@ -186,7 +176,7 @@
<input <input
class="field-input" class="field-input"
bind:value={editAddress} bind:value={editAddress}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Adresse eingeben..." placeholder="Adresse eingeben..."
/> />
@ -198,7 +188,7 @@
<input <input
class="field-input small" class="field-input small"
bind:value={editLatitude} bind:value={editLatitude}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Lat" placeholder="Lat"
type="number" type="number"
@ -207,7 +197,7 @@
<input <input
class="field-input small" class="field-input small"
bind:value={editLongitude} bind:value={editLongitude}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Lng" placeholder="Lng"
type="number" type="number"
@ -219,9 +209,9 @@
<div class="field-row"> <div class="field-row">
<span class="field-label">Beschreibung</span> <span class="field-label">Beschreibung</span>
<textarea <textarea
class="field-textarea" class="description-input"
bind:value={editDescription} bind:value={editDescription}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Notizen zum Ort..." placeholder="Notizen zum Ort..."
rows={2} rows={2}
@ -229,7 +219,6 @@
</div> </div>
</div> </div>
<!-- Tags -->
{#if placeTags.length > 0} {#if placeTags.length > 0}
<div class="section"> <div class="section">
<span class="section-label">Tags</span> <span class="section-label">Tags</span>
@ -249,10 +238,8 @@
</div> </div>
{/if} {/if}
<!-- Links -->
<LinkedItems recordRef={{ app: 'places', collection: 'places', id: placeId }} {navigate} /> <LinkedItems recordRef={{ app: 'places', collection: 'places', id: placeId }} {navigate} />
<!-- Visit Log -->
{#if logs.length > 0} {#if logs.length > 0}
<div class="section"> <div class="section">
<span class="section-label">Letzte Besuche</span> <span class="section-label">Letzte Besuche</span>
@ -261,7 +248,7 @@
<div class="log-row"> <div class="log-row">
<span class="log-time">{formatDate(log.timestamp)}</span> <span class="log-time">{formatDate(log.timestamp)}</span>
{#if log.accuracy} {#if log.accuracy}
<span class="log-accuracy">&pm;{Math.round(log.accuracy)}m</span> <span class="log-accuracy">±{Math.round(log.accuracy)}m</span>
{/if} {/if}
</div> </div>
{/each} {/each}
@ -269,7 +256,6 @@
</div> </div>
{/if} {/if}
<!-- Stats & Meta -->
<div class="meta"> <div class="meta">
{#if (place.visitCount ?? 0) > 0} {#if (place.visitCount ?? 0) > 0}
<span>Besuche: {place.visitCount}</span> <span>Besuche: {place.visitCount}</span>
@ -284,41 +270,10 @@
<span>Bearbeitet: {new Date(place.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(place.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Ort wirklich loeschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deletePlace}>Loeschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Loeschen
</button>
{/if}
</div>
{/if}
</div>
<style> <style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Header */
.profile-header { .profile-header {
display: flex; display: flex;
align-items: center; align-items: center;
@ -328,158 +283,111 @@
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 9999px; border-radius: 9999px;
flex-shrink: 0; background: rgba(0, 0, 0, 0.06);
color: #6b7280;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(14, 165, 233, 0.1); flex-shrink: 0;
color: #0ea5e9; }
:global(.dark) .place-avatar {
background: rgba(255, 255, 255, 0.08);
color: #9ca3af;
} }
.name-fields { .name-fields {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.name-input { .name-input {
font-size: 0.9375rem; width: 100%;
font-size: 1rem;
font-weight: 600; font-weight: 600;
border: none; border: 1px solid transparent;
background: transparent; background: transparent;
outline: none; outline: none;
color: #374151; color: #374151;
padding: 0.125rem 0; padding: 0.125rem 0.25rem;
width: 100%; border-radius: 0.25rem;
transition: border-color 0.15s;
} }
.name-input:hover,
.name-input:focus { .name-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1); border-color: rgba(0, 0, 0, 0.1);
}
.name-input::placeholder {
color: #c0bfba;
font-weight: 400;
} }
:global(.dark) .name-input { :global(.dark) .name-input {
color: #f3f4f6; color: #f3f4f6;
} }
:global(.dark) .name-input:hover,
:global(.dark) .name-input:focus { :global(.dark) .name-input:focus {
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
} }
:global(.dark) .name-input::placeholder {
color: #4b5563;
}
.fav-btn {
border: none;
background: transparent;
cursor: pointer;
color: #d1d5db;
padding: 0.25rem;
transition: color 0.15s;
flex-shrink: 0;
}
.fav-btn.active {
color: #f59e0b;
}
.fav-btn:hover {
color: #f59e0b;
}
/* Map */
.map-container { .map-container {
border-radius: 0.75rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08)); border: 1px solid rgba(0, 0, 0, 0.08);
} }
.map-container iframe { :global(.dark) .map-container {
display: block; border-color: rgba(255, 255, 255, 0.08);
} }
/* Fields */
.fields { .fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.5rem;
} }
.field-row { .field-row {
display: flex; display: flex;
flex-direction: column; align-items: flex-start;
gap: 0.25rem; justify-content: space-between;
gap: 0.5rem;
} }
.field-label { .field-label {
font-size: 0.6875rem; font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af; color: #9ca3af;
min-width: 5.5rem;
padding-top: 0.375rem;
} }
.field-input, .field-input,
.field-textarea,
.field-select { .field-select {
flex: 1;
min-width: 0;
font-size: 0.8125rem; font-size: 0.8125rem;
padding: 0.375rem 0.5rem; padding: 0.25rem 0.375rem;
border-radius: 0.375rem; border-radius: 0.25rem;
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
color: #374151; color: #374151;
outline: none; outline: none;
text-align: right;
transition: border-color 0.15s; transition: border-color 0.15s;
font-family: inherit;
} }
.field-input:hover, .field-input:hover,
.field-input:focus, .field-input:focus,
.field-textarea:hover, .field-select:hover,
.field-textarea:focus { .field-select:focus {
border-color: rgba(0, 0, 0, 0.1); border-color: rgba(0, 0, 0, 0.1);
} }
.field-input::placeholder,
.field-textarea::placeholder {
color: #c0bfba;
}
.field-input.small {
flex: 1;
}
.field-select {
cursor: pointer;
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
}
.field-textarea {
resize: vertical;
}
:global(.dark) .field-input, :global(.dark) .field-input,
:global(.dark) .field-textarea,
:global(.dark) .field-select { :global(.dark) .field-select {
color: #e5e7eb; color: #e5e7eb;
} }
:global(.dark) .field-input:hover, :global(.dark) .field-input:hover,
:global(.dark) .field-input:focus, :global(.dark) .field-input:focus,
:global(.dark) .field-textarea:hover, :global(.dark) .field-select:hover,
:global(.dark) .field-textarea:focus { :global(.dark) .field-select:focus {
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
} }
:global(.dark) .field-input::placeholder, .field-input.small {
:global(.dark) .field-textarea::placeholder { max-width: 6rem;
color: #4b5563;
} }
:global(.dark) .field-select {
border-color: rgba(255, 255, 255, 0.1);
background: transparent;
}
.coords-row { .coords-row {
display: flex; display: flex;
gap: 0.375rem; gap: 0.25rem;
flex: 1;
justify-content: flex-end;
} }
/* Tags */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.tags-list { .tags-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -491,129 +399,36 @@
gap: 0.25rem; gap: 0.25rem;
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 9999px; border-radius: 9999px;
border: none;
background: color-mix(in srgb, var(--tag-color) 12%, transparent); background: color-mix(in srgb, var(--tag-color) 12%, transparent);
border: none;
font-size: 0.6875rem; font-size: 0.6875rem;
color: #6b7280; color: #6b7280;
cursor: pointer; cursor: pointer;
transition: all 0.15s;
} }
.tag-pill:hover { .tag-pill:hover {
background: color-mix(in srgb, var(--tag-color) 20%, transparent); opacity: 0.8;
color: #ef4444;
}
:global(.dark) .tag-pill {
background: color-mix(in srgb, var(--tag-color) 18%, transparent);
color: #9ca3af;
}
:global(.dark) .tag-pill:hover {
background: color-mix(in srgb, var(--tag-color) 28%, transparent);
color: #ef4444;
} }
.tag-dot { .tag-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 9999px; border-radius: 9999px;
flex-shrink: 0; }
:global(.dark) .tag-pill {
color: #9ca3af;
} }
/* Visit Log */
.log-list { .log-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.125rem; gap: 0.25rem;
font-size: 0.75rem;
} }
.log-row { .log-row {
display: flex; display: flex;
align-items: center; justify-content: space-between;
gap: 0.375rem; color: #6b7280;
padding: 0.125rem 0;
font-size: 0.75rem;
}
.log-time {
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
} }
.log-accuracy { .log-accuracy {
color: var(--color-muted-foreground);
font-size: 0.6875rem;
}
/* Meta */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af; color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
/* Delete */
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.fav-btn,
.action-btn,
.tag-pill {
min-height: 44px;
}
.field-input,
.field-textarea,
.field-select {
min-height: 44px;
}
} }
</style> </style>

View file

@ -3,20 +3,15 @@
All fields are always editable. Changes auto-save on blur. All fields are always editable. Changes auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { toastStore } from '@mana/shared-ui/toast'; import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { Trash } from '@mana/shared-icons'; import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalPlant, HealthStatus, LightLevel } from '../types'; import type { LocalPlant, HealthStatus, LightLevel } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let plantId = $derived(params.plantId as string); let plantId = $derived(params.plantId as string);
let plant = $state<LocalPlant | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editName = $state(''); let editName = $state('');
let editScientificName = $state(''); let editScientificName = $state('');
let editSpecies = $state(''); let editSpecies = $state('');
@ -26,35 +21,25 @@
let editCareNotes = $state(''); let editCareNotes = $state('');
let editAcquiredAt = $state(''); let editAcquiredAt = $state('');
let focused = $state(false); const detail = useDetailEntity<LocalPlant>({
id: () => plantId,
$effect(() => { table: 'plants',
plantId; onLoad: (val) => {
confirmDelete = false; editName = val.name;
focused = false; editScientificName = val.scientificName ?? '';
}); editSpecies = val.species ?? '';
editHealthStatus = val.healthStatus ?? 'healthy';
$effect(() => { editLightRequirements = val.lightRequirements ?? '';
const sub = liveQuery(() => db.table<LocalPlant>('plants').get(plantId)).subscribe((val) => { editWateringFrequencyDays = val.wateringFrequencyDays ?? null;
plant = val ?? null; editCareNotes = val.careNotes ?? '';
if (val && !focused) { editAcquiredAt = val.acquiredAt?.split('T')[0] ?? '';
editName = val.name; },
editScientificName = val.scientificName ?? '';
editSpecies = val.species ?? '';
editHealthStatus = val.healthStatus ?? 'healthy';
editLightRequirements = val.lightRequirements ?? '';
editWateringFrequencyDays = val.wateringFrequencyDays ?? null;
editCareNotes = val.careNotes ?? '';
editAcquiredAt = val.acquiredAt?.split('T')[0] ?? '';
}
});
return () => sub.unsubscribe();
}); });
async function saveField() { async function saveField() {
focused = false; detail.blur();
await db.table('plants').update(plantId, { await db.table('plants').update(plantId, {
name: editName.trim() || plant?.name || 'Unbenannt', name: editName.trim() || detail.entity?.name || 'Unbenannt',
scientificName: editScientificName.trim() || null, scientificName: editScientificName.trim() || null,
species: editSpecies.trim() || null, species: editSpecies.trim() || null,
healthStatus: editHealthStatus, healthStatus: editHealthStatus,
@ -75,15 +60,10 @@
} }
async function deletePlant() { async function deletePlant() {
const id = plantId; await db.table('plants').update(plantId, {
await db.table('plants').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
goBack();
toastStore.undo('Pflanze gelöscht', () => {
db.table('plants').update(id, { deletedAt: undefined, updatedAt: new Date().toISOString() });
});
} }
const healthLabels: Record<HealthStatus, string> = { const healthLabels: Record<HealthStatus, string> = {
@ -106,27 +86,37 @@
}; };
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !plant} entity={detail.entity}
<p class="empty">Pflanze nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Pflanze nicht gefunden"
<!-- Title --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Pflanze wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Pflanze gelöscht',
delete: deletePlant,
goBack,
})}
>
{#snippet body(plant)}
<input <input
class="title-input" class="title-input"
bind:value={editName} bind:value={editName}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Name..." placeholder="Name..."
/> />
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Wissenschaftlicher Name</span> <span class="prop-label">Wissenschaftlicher Name</span>
<input <input
class="prop-input" class="prop-input"
bind:value={editScientificName} bind:value={editScientificName}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="—" placeholder="—"
/> />
@ -137,7 +127,7 @@
<input <input
class="prop-input" class="prop-input"
bind:value={editSpecies} bind:value={editSpecies}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="—" placeholder="—"
/> />
@ -172,12 +162,12 @@
</div> </div>
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Giessen (Tage)</span> <span class="prop-label">Gießen (Tage)</span>
<input <input
type="number" type="number"
class="prop-input" class="prop-input"
bind:value={editWateringFrequencyDays} bind:value={editWateringFrequencyDays}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="—" placeholder="—"
min="1" min="1"
@ -190,235 +180,29 @@
type="date" type="date"
class="prop-input" class="prop-input"
bind:value={editAcquiredAt} bind:value={editAcquiredAt}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
</div> </div>
</div> </div>
<!-- Care Notes -->
<div class="section"> <div class="section">
<span class="section-label">Pflegehinweise</span> <span class="section-label">Pflegehinweise</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editCareNotes} bind:value={editCareNotes}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Pflegehinweise hinzufuegen..." placeholder="Pflegehinweise hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(plant.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(plant.createdAt ?? '').toLocaleDateString('de')}</span>
{#if plant.updatedAt} {#if plant.updatedAt}
<span>Bearbeitet: {new Date(plant.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(plant.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Pflanze wirklich loeschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deletePlant}>Loeschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Loeschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
.title-input {
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-select,
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-select:hover,
.prop-input:hover,
.prop-select:focus,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .prop-select,
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-select:hover,
:global(.dark) .prop-input:hover,
:global(.dark) .prop-select:focus,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.action-btn {
min-height: 44px;
}
.prop-select,
.prop-input {
min-height: 44px;
}
}
</style>

View file

@ -5,62 +5,48 @@
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { decksStore } from '../stores/decks.svelte'; import { decksStore } from '../stores/decks.svelte';
import { toastStore } from '@mana/shared-ui/toast';
import { Trash } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalDeck, LocalSlide } from '../types'; import type { LocalDeck, LocalSlide } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let deckId = $derived(params.deckId as string); let deckId = $derived(params.deckId as string);
let deck = $state<LocalDeck | null>(null);
let slideCount = $state(0);
let confirmDelete = $state(false);
// Edit fields
let editTitle = $state(''); let editTitle = $state('');
let editDescription = $state(''); let editDescription = $state('');
let editIsPublic = $state(false); let editIsPublic = $state(false);
let focused = $state(false); const detail = useDetailEntity<LocalDeck>({
id: () => deckId,
$effect(() => { table: 'presiDecks',
deckId; onLoad: (val) => {
confirmDelete = false; editTitle = val.title;
focused = false; editDescription = val.description ?? '';
editIsPublic = val.isPublic;
},
}); });
let slideCount = $state(0);
$effect(() => { $effect(() => {
const sub = liveQuery(() => db.table<LocalDeck>('presiDecks').get(deckId)).subscribe((val) => { const sub = liveQuery(async () =>
deck = val ?? null; db
if (val && !focused) {
editTitle = val.title;
editDescription = val.description ?? '';
editIsPublic = val.isPublic;
}
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalSlide>('slides') .table<LocalSlide>('slides')
.where('deckId') .where('deckId')
.equals(deckId) .equals(deckId)
.filter((s) => !s.deletedAt) .filter((s) => !s.deletedAt)
.count(); .count()
}).subscribe((val) => { ).subscribe((val) => {
slideCount = val ?? 0; slideCount = val ?? 0;
}); });
return () => sub.unsubscribe(); return () => sub.unsubscribe();
}); });
async function saveField() { async function saveField() {
focused = false; detail.blur();
await decksStore.updateDeck(deckId, { await decksStore.updateDeck(deckId, {
title: editTitle.trim() || deck?.title || 'Unbenannt', title: editTitle.trim() || detail.entity?.title || 'Unbenannt',
description: editDescription.trim() || undefined, description: editDescription.trim() || undefined,
isPublic: editIsPublic, isPublic: editIsPublic,
}); });
@ -70,34 +56,32 @@
editIsPublic = !editIsPublic; editIsPublic = !editIsPublic;
await decksStore.updateDeck(deckId, { isPublic: editIsPublic }); await decksStore.updateDeck(deckId, { isPublic: editIsPublic });
} }
async function deleteDeck() {
const id = deckId;
await decksStore.deleteDeck(id);
goBack();
toastStore.undo('Präsentation gelöscht', () => {
db.table('presiDecks').update(id, {
deletedAt: undefined,
updatedAt: new Date().toISOString(),
});
});
}
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !deck} entity={detail.entity}
<p class="empty">Präsentation nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Präsentation nicht gefunden"
<!-- Title --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Präsentation wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Präsentation gelöscht',
delete: () => decksStore.deleteDeck(deckId),
goBack,
})}
>
{#snippet body(deck)}
<input <input
class="title-input" class="title-input"
bind:value={editTitle} bind:value={editTitle}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Titel..." placeholder="Titel..."
/> />
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Öffentlich</span> <span class="prop-label">Öffentlich</span>
@ -112,243 +96,23 @@
</div> </div>
</div> </div>
<!-- Description -->
<div class="section"> <div class="section">
<span class="section-label">Beschreibung</span> <span class="section-label">Beschreibung</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editDescription} bind:value={editDescription}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Beschreibung hinzufügen..." placeholder="Beschreibung hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(deck.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(deck.createdAt ?? '').toLocaleDateString('de')}</span>
{#if deck.updatedAt} {#if deck.updatedAt}
<span>Bearbeitet: {new Date(deck.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(deck.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Präsentation wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteDeck}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title */
.title-input {
font-size: 1.125rem;
font-weight: 600;
border: 1px solid transparent;
background: transparent;
outline: none;
color: #374151;
padding: 0.125rem 0;
border-radius: 0.25rem;
transition: border-color 0.15s;
}
.title-input:hover,
.title-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:hover,
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-value {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.toggle-btn {
font-size: 0.8125rem;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.toggle-btn:hover {
border-color: rgba(0, 0, 0, 0.1);
}
.toggle-btn.active {
color: #22c55e;
}
:global(.dark) .toggle-btn {
color: #6b7280;
}
:global(.dark) .toggle-btn:hover {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .toggle-btn.active {
color: #22c55e;
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.action-btn,
.toggle-btn {
min-height: 44px;
}
}
</style>

View file

@ -3,58 +3,39 @@
All fields are always editable. Changes auto-save on blur. All fields are always editable. Changes auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import { encryptRecord } from '$lib/data/crypto';
import { toastStore } from '@mana/shared-ui/toast'; import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { Trash } from '@mana/shared-icons'; import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalQuestion, QuestionStatus, QuestionPriority, ResearchDepth } from '../types'; import type { LocalQuestion, QuestionStatus, QuestionPriority, ResearchDepth } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let questionId = $derived(params.questionId as string); let questionId = $derived(params.questionId as string);
let question = $state<LocalQuestion | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editTitle = $state(''); let editTitle = $state('');
let editDescription = $state(''); let editDescription = $state('');
let editStatus = $state<QuestionStatus>('open'); let editStatus = $state<QuestionStatus>('open');
let editPriority = $state<QuestionPriority>('normal'); let editPriority = $state<QuestionPriority>('normal');
let editResearchDepth = $state<ResearchDepth>('standard'); let editResearchDepth = $state<ResearchDepth>('standard');
let focused = $state(false); const detail = useDetailEntity<LocalQuestion>({
id: () => questionId,
$effect(() => { table: 'questions',
questionId; decrypt: true,
confirmDelete = false; onLoad: (val) => {
focused = false; editTitle = val.title;
}); editDescription = val.description ?? '';
editStatus = val.status;
$effect(() => { editPriority = val.priority;
const sub = liveQuery(async () => { editResearchDepth = val.researchDepth;
const raw = await db.table<LocalQuestion>('questions').get(questionId); },
// title + description are encrypted on disk; decrypt a clone so
// the inline editor binds to plaintext.
return raw ? await decryptRecord('questions', { ...raw }) : null;
}).subscribe((val) => {
question = val ?? null;
if (val && !focused) {
editTitle = val.title;
editDescription = val.description ?? '';
editStatus = val.status;
editPriority = val.priority;
editResearchDepth = val.researchDepth;
}
});
return () => sub.unsubscribe();
}); });
async function saveField() { async function saveField() {
focused = false; detail.blur();
const diff: Record<string, unknown> = { const diff: Record<string, unknown> = {
title: editTitle.trim() || question?.title || 'Ohne Titel', title: editTitle.trim() || detail.entity?.title || 'Ohne Titel',
description: editDescription.trim() || undefined, description: editDescription.trim() || undefined,
status: editStatus, status: editStatus,
priority: editPriority, priority: editPriority,
@ -75,18 +56,10 @@
} }
async function deleteQuestion() { async function deleteQuestion() {
const id = questionId; await db.table('questions').update(questionId, {
await db.table('questions').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
goBack();
toastStore.undo('Frage gelöscht', () => {
db.table('questions').update(id, {
deletedAt: undefined,
updatedAt: new Date().toISOString(),
});
});
} }
const statusLabels: Record<QuestionStatus, string> = { const statusLabels: Record<QuestionStatus, string> = {
@ -117,20 +90,30 @@
}; };
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !question} entity={detail.entity}
<p class="empty">Frage nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Frage nicht gefunden"
<!-- Title --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Frage wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Frage gelöscht',
delete: deleteQuestion,
goBack,
})}
>
{#snippet body(question)}
<input <input
class="title-input" class="title-input"
bind:value={editTitle} bind:value={editTitle}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Titel..." placeholder="Titel..."
/> />
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Status</span> <span class="prop-label">Status</span>
@ -165,20 +148,18 @@
</div> </div>
</div> </div>
<!-- Description -->
<div class="section"> <div class="section">
<span class="section-label">Beschreibung</span> <span class="section-label">Beschreibung</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editDescription} bind:value={editDescription}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Beschreibung hinzufügen..." placeholder="Beschreibung hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Tags -->
{#if question.tags.length > 0} {#if question.tags.length > 0}
<div class="section"> <div class="section">
<span class="section-label">Tags</span> <span class="section-label">Tags</span>
@ -190,149 +171,16 @@
</div> </div>
{/if} {/if}
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(question.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(question.createdAt ?? '').toLocaleDateString('de')}</span>
{#if question.updatedAt} {#if question.updatedAt}
<span>Bearbeitet: {new Date(question.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(question.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Frage wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteQuestion}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style> <style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title */
.title-input {
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-select {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-select:hover,
.prop-select:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .prop-select {
color: #e5e7eb;
}
:global(.dark) .prop-select:hover,
:global(.dark) .prop-select:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
/* Tags */
.tag-list { .tag-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -349,76 +197,4 @@
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
color: #9ca3af; color: #9ca3af;
} }
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.action-btn {
min-height: 44px;
}
.prop-select {
min-height: 44px;
}
}
</style> </style>

View file

@ -3,22 +3,17 @@
Skill details with XP display and quick add XP. Auto-save on blur. Skill details with XP display and quick add XP. Auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie'; import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { db } from '$lib/data/database'; import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { skillStore } from '../stores/skills.svelte'; import { skillStore } from '../stores/skills.svelte';
import { toastStore } from '@mana/shared-ui/toast'; import { Lightning } from '@mana/shared-icons';
import { Trash, Lightning } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalSkill, SkillBranch } from '../types'; import type { LocalSkill, SkillBranch } from '../types';
import { BRANCH_INFO, LEVEL_NAMES, xpProgress, xpForNextLevel } from '../types'; import { BRANCH_INFO, LEVEL_NAMES, xpProgress, xpForNextLevel } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let skillId = $derived(params.skillId as string); let skillId = $derived(params.skillId as string);
let skill = $state<LocalSkill | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editName = $state(''); let editName = $state('');
let editDescription = $state(''); let editDescription = $state('');
let editBranch = $state<SkillBranch>('custom'); let editBranch = $state<SkillBranch>('custom');
@ -30,33 +25,28 @@
let addXpDescription = $state(''); let addXpDescription = $state('');
let levelUpMessage = $state(''); let levelUpMessage = $state('');
let focused = $state(false); const detail = useDetailEntity<LocalSkill>({
id: () => skillId,
table: 'skills',
onLoad: (val) => {
editName = val.name;
editDescription = val.description ?? '';
editBranch = val.branch;
editIcon = val.icon ?? '';
editColor = val.color ?? '';
},
});
// Reset level-up flash on skill change
$effect(() => { $effect(() => {
skillId; skillId;
confirmDelete = false;
focused = false;
levelUpMessage = ''; levelUpMessage = '';
}); });
$effect(() => {
const sub = liveQuery(() => db.table<LocalSkill>('skills').get(skillId)).subscribe((val) => {
skill = val ?? null;
if (val && !focused) {
editName = val.name;
editDescription = val.description ?? '';
editBranch = val.branch;
editIcon = val.icon ?? '';
editColor = val.color ?? '';
}
});
return () => sub.unsubscribe();
});
async function saveField() { async function saveField() {
focused = false; detail.blur();
await skillStore.updateSkill(skillId, { await skillStore.updateSkill(skillId, {
name: editName.trim() || skill?.name || 'Unbenannt', name: editName.trim() || detail.entity?.name || 'Unbenannt',
description: editDescription.trim() || '', description: editDescription.trim() || '',
branch: editBranch, branch: editBranch,
icon: editIcon.trim() || 'star', icon: editIcon.trim() || 'star',
@ -73,7 +63,7 @@
const result = await skillStore.addXp( const result = await skillStore.addXp(
skillId, skillId,
addXpAmount, addXpAmount,
addXpDescription.trim() || 'Manuell hinzugefuegt' addXpDescription.trim() || 'Manuell hinzugefügt'
); );
addXpDescription = ''; addXpDescription = '';
if (result.leveledUp) { if (result.leveledUp) {
@ -81,34 +71,35 @@
setTimeout(() => (levelUpMessage = ''), 3000); setTimeout(() => (levelUpMessage = ''), 3000);
} }
} }
async function deleteSkill() {
const id = skillId;
await skillStore.deleteSkill(id);
goBack();
toastStore.undo('Skill gelöscht', () => {
db.table('skills').update(id, { deletedAt: undefined, updatedAt: new Date().toISOString() });
});
}
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !skill} entity={detail.entity}
<p class="empty">Skill nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Skill nicht gefunden"
<!-- Icon + Title --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Skill wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Skill gelöscht',
delete: () => skillStore.deleteSkill(skillId),
goBack,
})}
>
{#snippet body(skill)}
<div class="title-row"> <div class="title-row">
<span class="title-icon">{skill.icon}</span> <span class="title-icon">{skill.icon}</span>
<input <input
class="title-input" class="title-input"
bind:value={editName} bind:value={editName}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Name..." placeholder="Name..."
/> />
</div> </div>
<!-- XP Progress -->
<div class="xp-section"> <div class="xp-section">
<div class="xp-header"> <div class="xp-header">
<span class="xp-level">Level {skill.level}{LEVEL_NAMES[skill.level] ?? 'Unbekannt'}</span <span class="xp-level">Level {skill.level}{LEVEL_NAMES[skill.level] ?? 'Unbekannt'}</span
@ -129,19 +120,17 @@
<div class="level-up">{levelUpMessage}</div> <div class="level-up">{levelUpMessage}</div>
{/if} {/if}
<!-- Quick Add XP -->
<div class="section"> <div class="section">
<span class="section-label">XP hinzufuegen</span> <span class="section-label">XP hinzufügen</span>
<div class="add-xp-row"> <div class="add-xp-row">
<input type="number" class="xp-input" bind:value={addXpAmount} min="1" placeholder="XP" /> <input type="number" class="xp-input" bind:value={addXpAmount} min="1" placeholder="XP" />
<input class="xp-desc-input" bind:value={addXpDescription} placeholder="Beschreibung..." /> <input class="xp-desc-input" bind:value={addXpDescription} placeholder="Beschreibung..." />
<button class="xp-btn" onclick={handleAddXp}> <button class="xp-btn" onclick={handleAddXp}>
<Lightning size={14} /> Hinzufuegen <Lightning size={14} /> Hinzufügen
</button> </button>
</div> </div>
</div> </div>
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Branch</span> <span class="prop-label">Branch</span>
@ -157,9 +146,9 @@
<input <input
class="prop-input" class="prop-input"
bind:value={editIcon} bind:value={editIcon}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="z.B. &#11088;" placeholder="z.B. "
/> />
</div> </div>
@ -168,97 +157,35 @@
<input <input
class="prop-input" class="prop-input"
bind:value={editColor} bind:value={editColor}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="z.B. #D946EF" placeholder="z.B. #D946EF"
/> />
</div> </div>
</div> </div>
<!-- Description -->
<div class="section"> <div class="section">
<span class="section-label">Beschreibung</span> <span class="section-label">Beschreibung</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editDescription} bind:value={editDescription}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Beschreibung hinzufuegen..." placeholder="Beschreibung hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(skill.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(skill.createdAt ?? '').toLocaleDateString('de')}</span>
{#if skill.updatedAt} {#if skill.updatedAt}
<span>Bearbeitet: {new Date(skill.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(skill.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Skill wirklich loeschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteSkill}>Loeschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Loeschen
</button>
{/if}
</div>
{/if}
</div>
<style> <style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
.title-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.title-icon {
font-size: 1.25rem;
flex-shrink: 0;
padding-top: 0.125rem;
}
.title-input {
flex: 1;
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* XP Section */
.xp-section { .xp-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -309,8 +236,6 @@
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
} }
/* Add XP */
.add-xp-row { .add-xp-row {
display: flex; display: flex;
gap: 0.375rem; gap: 0.375rem;
@ -340,16 +265,10 @@
color: #374151; color: #374151;
outline: none; outline: none;
} }
.xp-desc-input::placeholder {
color: #c0bfba;
}
:global(.dark) .xp-desc-input { :global(.dark) .xp-desc-input {
color: #e5e7eb; color: #e5e7eb;
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
} }
:global(.dark) .xp-desc-input::placeholder {
color: #4b5563;
}
.xp-btn { .xp-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@ -362,168 +281,8 @@
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
transition: opacity 0.15s;
} }
.xp-btn:hover { .xp-btn:hover {
opacity: 0.85; opacity: 0.85;
} }
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-select,
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-select:hover,
.prop-input:hover,
.prop-select:focus,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .prop-select,
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-select:hover,
:global(.dark) .prop-input:hover,
:global(.dark) .prop-select:focus,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.action-btn,
.xp-btn {
min-height: 44px;
}
.prop-select,
.prop-input,
.xp-input,
.xp-desc-input {
min-height: 44px;
}
}
</style> </style>

View file

@ -3,50 +3,30 @@
File details with editable name, favorite toggle. Auto-save on blur. File details with editable name, favorite toggle. Auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie'; import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { db } from '$lib/data/database'; import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { decryptRecord } from '$lib/data/crypto';
import { filesStore } from '../stores/files.svelte'; import { filesStore } from '../stores/files.svelte';
import { toastStore } from '@mana/shared-ui/toast'; import { Heart } from '@mana/shared-icons';
import { Heart, Trash } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalFile } from '../types'; import type { LocalFile } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let fileId = $derived(params.fileId as string); let fileId = $derived(params.fileId as string);
let file = $state<LocalFile | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editName = $state(''); let editName = $state('');
let focused = $state(false); const detail = useDetailEntity<LocalFile>({
id: () => fileId,
$effect(() => { table: 'files',
fileId; decrypt: true,
confirmDelete = false; onLoad: (val) => {
focused = false; editName = val.name;
}); },
$effect(() => {
const sub = liveQuery(async () => {
const raw = await db.table<LocalFile>('files').get(fileId);
// name + originalName are encrypted on disk; decrypt a clone
// so the rename input binds to plaintext.
return raw ? await decryptRecord('files', { ...raw }) : null;
}).subscribe((val) => {
file = val ?? null;
if (val && !focused) {
editName = val.name;
}
});
return () => sub.unsubscribe();
}); });
async function saveField() { async function saveField() {
focused = false; detail.blur();
const name = editName.trim() || file?.name || 'Unbenannt'; const name = editName.trim() || detail.entity?.name || 'Unbenannt';
await filesStore.renameFile(fileId, name); await filesStore.renameFile(fileId, name);
} }
@ -54,15 +34,6 @@
await filesStore.toggleFileFavorite(fileId); await filesStore.toggleFileFavorite(fileId);
} }
async function deleteFile() {
const id = fileId;
await filesStore.deleteFile(id);
goBack();
toastStore.undo('Datei gelöscht', () => {
db.table('files').update(id, { deletedAt: undefined, updatedAt: new Date().toISOString() });
});
}
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'; if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
@ -71,20 +42,29 @@
} }
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !file} entity={detail.entity}
<p class="empty">Datei nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Datei nicht gefunden"
<!-- Name --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Datei gelöscht',
delete: () => filesStore.deleteFile(fileId),
goBack,
})}
>
{#snippet body(file)}
<input <input
class="title-input" class="title-input"
bind:value={editName} bind:value={editName}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Dateiname..." placeholder="Dateiname..."
/> />
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Originalname</span> <span class="prop-label">Originalname</span>
@ -116,187 +96,11 @@
{/if} {/if}
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(file.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(file.createdAt ?? '').toLocaleDateString('de')}</span>
{#if file.updatedAt} {#if file.updatedAt}
<span>Bearbeitet: {new Date(file.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(file.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Datei wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteFile}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title */
.title-input {
font-size: 1.125rem;
font-weight: 600;
border: 1px solid transparent;
background: transparent;
outline: none;
color: #374151;
padding: 0.125rem 0;
border-radius: 0.25rem;
transition: border-color 0.15s;
}
.title-input:hover,
.title-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:hover,
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-value {
font-size: 0.8125rem;
color: #374151;
max-width: 60%;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prop-value.mono {
font-family: monospace;
font-size: 0.75rem;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.fav-btn {
border: none;
background: transparent;
cursor: pointer;
padding: 0.125rem;
color: #9ca3af;
display: flex;
align-items: center;
transition: color 0.15s;
}
.fav-btn:hover {
color: #ef4444;
}
.fav-btn.active {
color: #ef4444;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.fav-btn,
.action-btn {
min-height: 44px;
}
}
</style>

View file

@ -3,21 +3,18 @@
All fields are always editable. Changes auto-save on blur. All fields are always editable. Changes auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { Trash } from '@mana/shared-icons'; import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { toTimeEntry } from '../queries';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalTimeEntry, LocalProject, LocalClient } from '../types'; import type { LocalTimeEntry, LocalProject, LocalClient, TimeEntry } from '../types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let entryId = $derived(params.entryId as string); let entryId = $derived(params.entryId as string);
let entry = $state<LocalTimeEntry | null>(null);
let projects = $state<LocalProject[]>([]);
let clients = $state<LocalClient[]>([]);
let confirmDelete = $state(false);
// Edit fields
let editDescription = $state(''); let editDescription = $state('');
let editDate = $state(''); let editDate = $state('');
let editDurationH = $state(0); let editDurationH = $state(0);
@ -26,56 +23,40 @@
let editProjectId = $state<string | null>(null); let editProjectId = $state<string | null>(null);
let editBillable = $state(false); let editBillable = $state(false);
let focused = $state(false); const detail = useDetailEntity<TimeEntry>({
id: () => entryId,
$effect(() => { loader: async (id) => {
entryId; const local = await db.table<LocalTimeEntry>('timeEntries').get(id);
confirmDelete = false; if (!local) return null;
focused = false; const block = local.timeBlockId
? await db.table<LocalTimeBlock>('timeBlocks').get(local.timeBlockId)
: null;
return toTimeEntry(local, block);
},
onLoad: (val) => {
editDescription = val.description ?? '';
editDate = val.date ?? '';
const dur = val.duration ?? 0;
editDurationH = Math.floor(dur / 3600);
editDurationM = Math.floor((dur % 3600) / 60);
editDurationS = dur % 60;
editProjectId = val.projectId ?? null;
editBillable = val.isBillable;
},
}); });
$effect(() => { const projectsQuery = useLiveQueryWithDefault(async () => {
const sub = liveQuery(() => db.table<LocalTimeEntry>('timeEntries').get(entryId)).subscribe( const all = await db.table<LocalProject>('timesProjects').toArray();
(val) => { return all.filter((p) => !p.deletedAt && !p.isArchived);
entry = val ?? null; }, [] as LocalProject[]);
if (val && !focused) {
editDescription = val.description ?? '';
editDate = val.date ?? '';
const dur = val.duration ?? 0;
editDurationH = Math.floor(dur / 3600);
editDurationM = Math.floor((dur % 3600) / 60);
editDurationS = dur % 60;
editProjectId = val.projectId ?? null;
editBillable = val.isBillable;
}
}
);
return () => sub.unsubscribe();
});
$effect(() => { const clientsQuery = useLiveQueryWithDefault(async () => {
const sub = liveQuery(async () => const all = await db.table<LocalClient>('timeClients').toArray();
db return all.filter((c) => !c.deletedAt);
.table<LocalProject>('timesProjects') }, [] as LocalClient[]);
.toArray()
.then((all) => all.filter((p) => !p.deletedAt && !p.isArchived))
).subscribe((val) => {
projects = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => { const projects = $derived(projectsQuery.value);
const sub = liveQuery(async () => const clients = $derived(clientsQuery.value);
db
.table<LocalClient>('timeClients')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt))
).subscribe((val) => {
clients = val ?? [];
});
return () => sub.unsubscribe();
});
let selectedProject = $derived( let selectedProject = $derived(
editProjectId ? projects.find((p) => p.id === editProjectId) : null editProjectId ? projects.find((p) => p.id === editProjectId) : null
@ -96,7 +77,7 @@
} }
async function saveField() { async function saveField() {
focused = false; detail.blur();
await db.table('timeEntries').update(entryId, { await db.table('timeEntries').update(entryId, {
description: editDescription.trim(), description: editDescription.trim(),
date: editDate, date: editDate,
@ -129,33 +110,33 @@
} }
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !entry} entity={detail.entity}
<p class="empty">Eintrag nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Eintrag nicht gefunden"
<!-- Description --> confirmDelete={detail.confirmDelete}
<div class="section"> onAskDelete={detail.askDelete}
<input onCancelDelete={detail.cancelDelete}
class="title-input" confirmDeleteLabel="Eintrag wirklich löschen?"
bind:value={editDescription} onConfirmDelete={deleteEntry}
onfocus={() => (focused = true)} >
onblur={saveField} {#snippet body(entry)}
placeholder="Beschreibung..." <input
/> class="title-input"
</div> bind:value={editDescription}
onfocus={detail.focus}
onblur={saveField}
placeholder="Beschreibung..."
/>
<!-- Time info -->
{#if entry.startTime || entry.endTime} {#if entry.startTime || entry.endTime}
<div class="time-range"> <div class="time-range">
{#if entry.startTime}{fmtTime(entry.startTime)}{/if} {#if entry.startTime}{fmtTime(entry.startTime)}{/if}
{#if entry.startTime && entry.endTime} {#if entry.startTime && entry.endTime}{/if}
&ndash;
{/if}
{#if entry.endTime}{fmtTime(entry.endTime)}{/if} {#if entry.endTime}{fmtTime(entry.endTime)}{/if}
</div> </div>
{/if} {/if}
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Datum</span> <span class="prop-label">Datum</span>
@ -163,7 +144,7 @@
type="date" type="date"
class="prop-input" class="prop-input"
bind:value={editDate} bind:value={editDate}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
</div> </div>
@ -177,7 +158,7 @@
min="0" min="0"
class="dur-input" class="dur-input"
bind:value={editDurationH} bind:value={editDurationH}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
<span class="dur-unit">h</span> <span class="dur-unit">h</span>
@ -189,7 +170,7 @@
max="59" max="59"
class="dur-input" class="dur-input"
bind:value={editDurationM} bind:value={editDurationM}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
<span class="dur-unit">m</span> <span class="dur-unit">m</span>
@ -201,7 +182,7 @@
max="59" max="59"
class="dur-input" class="dur-input"
bind:value={editDurationS} bind:value={editDurationS}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
<span class="dur-unit">s</span> <span class="dur-unit">s</span>
@ -225,8 +206,7 @@
{project.name} {project.name}
{#if project.clientId} {#if project.clientId}
{@const client = clients.find((c) => c.id === project.clientId)} {@const client = clients.find((c) => c.id === project.clientId)}
{#if client} {#if client}· {client.name}{/if}
&middot; {client.name}{/if}
{/if} {/if}
</option> </option>
{/each} {/each}
@ -235,21 +215,11 @@
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Abrechenbar</span> <span class="prop-label">Abrechenbar</span>
<button class="billable-toggle" class:active={editBillable} onclick={handleBillableChange}> <button class="toggle-btn" class:active={editBillable} onclick={handleBillableChange}>
{editBillable ? 'Ja' : 'Nein'} {editBillable ? 'Ja' : 'Nein'}
</button> </button>
</div> </div>
{#if selectedProject}
<div class="prop-row">
<span class="prop-label">Projekt</span>
<div class="flex items-center gap-1.5">
<span class="color-dot" style="background-color: {selectedProject.color}"></span>
<span class="prop-value">{selectedProject.name}</span>
</div>
</div>
{/if}
{#if selectedClient} {#if selectedClient}
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Kunde</span> <span class="prop-label">Kunde</span>
@ -258,7 +228,6 @@
{/if} {/if}
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
{#if entry.createdAt} {#if entry.createdAt}
<span>Erstellt: {new Date(entry.createdAt).toLocaleDateString('de')}</span> <span>Erstellt: {new Date(entry.createdAt).toLocaleDateString('de')}</span>
@ -267,123 +236,18 @@
<span>Quelle: {entry.source.app}</span> <span>Quelle: {entry.source.app}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Eintrag wirklich loschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteEntry}>Loschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Loschen
</button>
{/if}
</div>
{/if}
</div>
<style> <style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title */
.title-input {
width: 100%;
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Time range */
.time-range { .time-range {
font-size: 0.8125rem;
color: #9ca3af;
font-variant-numeric: tabular-nums;
}
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem; font-size: 0.75rem;
color: #9ca3af; color: #9ca3af;
} }
.prop-value {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.prop-select,
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-select:hover,
.prop-input:hover,
.prop-select:focus,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .prop-select,
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-select:hover,
:global(.dark) .prop-input:hover,
:global(.dark) .prop-select:focus,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Duration inputs */
.duration-inputs { .duration-inputs {
display: flex; display: flex;
gap: 0.375rem; gap: 0.375rem;
align-items: center;
} }
.dur-field { .dur-field {
display: flex; display: flex;
@ -393,25 +257,22 @@
.dur-input { .dur-input {
width: 2.5rem; width: 2.5rem;
font-size: 0.8125rem; font-size: 0.8125rem;
text-align: right;
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
color: #374151; color: #374151;
outline: none; outline: none;
text-align: right;
font-variant-numeric: tabular-nums;
-moz-appearance: textfield;
}
.dur-input::-webkit-outer-spin-button,
.dur-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
} }
.dur-input:hover, .dur-input:hover,
.dur-input:focus { .dur-input:focus {
border-color: rgba(0, 0, 0, 0.1); border-color: rgba(0, 0, 0, 0.1);
} }
.dur-unit {
font-size: 0.6875rem;
color: #9ca3af;
}
:global(.dark) .dur-input { :global(.dark) .dur-input {
color: #e5e7eb; color: #e5e7eb;
} }
@ -419,121 +280,4 @@
:global(.dark) .dur-input:focus { :global(.dark) .dur-input:focus {
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
} }
.dur-unit {
font-size: 0.6875rem;
color: #9ca3af;
}
/* Billable toggle */
.billable-toggle {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.billable-toggle.active {
border-color: #22c55e;
color: #22c55e;
background: rgba(34, 197, 94, 0.08);
}
:global(.dark) .billable-toggle {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .billable-toggle.active {
border-color: #22c55e;
}
/* Color dot */
.color-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
}
/* Section */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.action-btn,
.billable-toggle {
min-height: 44px;
}
.prop-select,
.prop-input,
.dur-input {
min-height: 44px;
}
}
</style> </style>

View file

@ -3,13 +3,14 @@
All fields are always editable. Changes auto-save on blur. All fields are always editable. Changes auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { decryptRecord } from '$lib/data/crypto'; import { decryptRecord } from '$lib/data/crypto';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { tasksStore } from '../stores/tasks.svelte'; import { tasksStore } from '../stores/tasks.svelte';
import { getBlock, decryptBlock } from '$lib/data/time-blocks/service'; import { getBlock, decryptBlock } from '$lib/data/time-blocks/service';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { Check, Trash, X, CalendarBlank } from '@mana/shared-icons'; import { Check, X, CalendarBlank } from '@mana/shared-icons';
import SlotSuggestions from '$lib/modules/calendar/components/SlotSuggestions.svelte'; import SlotSuggestions from '$lib/modules/calendar/components/SlotSuggestions.svelte';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalTask, TaskPriority } from '../types'; import type { LocalTask, TaskPriority } from '../types';
@ -17,31 +18,56 @@
import LinkedItems from '$lib/components/links/LinkedItems.svelte'; import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { toastStore } from '@mana/shared-ui/toast'; import { toastStore } from '@mana/shared-ui/toast';
let { navigate, goBack, params }: ViewProps = $props(); let { navigate, params, goBack }: ViewProps = $props();
let taskId = $derived(params.taskId as string); let taskId = $derived(params.taskId as string);
let task = $state<LocalTask | null>(null); type TaskBundle = LocalTask & { _block: LocalTimeBlock | null };
let confirmDelete = $state(false);
// Edit fields — always live
let editTitle = $state(''); let editTitle = $state('');
let editDescription = $state(''); let editDescription = $state('');
let editDueDate = $state(''); let editDueDate = $state('');
let editPriority = $state<TaskPriority>('medium'); let editPriority = $state<TaskPriority>('medium');
// Schedule fields
let scheduleDate = $state(''); let scheduleDate = $state('');
let scheduleTime = $state(''); let scheduleTime = $state('');
let isScheduled = $state(false); let isScheduled = $state(false);
// Track whether user is actively editing to prevent overwrite from liveQuery
let focused = $state(false);
const tagsQuery = useAllTags(); const tagsQuery = useAllTags();
let allTags = $derived(tagsQuery.value ?? []); let allTags = $derived(tagsQuery.value ?? []);
const detail = useDetailEntity<TaskBundle>({
id: () => taskId,
loader: async (id) => {
const t = await db.table<LocalTask>('tasks').get(id);
if (!t) return null;
const block = t.scheduledBlockId ? await getBlock(t.scheduledBlockId) : null;
// Decrypt clones so the inline editor binds to plaintext title /
// description / metadata. The on-disk rows stay encrypted.
const decryptedTask = (await decryptRecord('tasks', { ...t })) as LocalTask;
const decryptedBlock = block ? await decryptBlock(block) : null;
return { ...decryptedTask, _block: decryptedBlock } as TaskBundle;
},
onLoad: (bundle) => {
editTitle = bundle.title;
editDescription = bundle.description ?? '';
editDueDate = bundle.dueDate?.split('T')[0] ?? '';
editPriority = bundle.priority;
if (bundle._block) {
isScheduled = true;
scheduleDate = bundle._block.startDate.split('T')[0];
scheduleTime = bundle._block.startDate.includes('T')
? (bundle._block.startDate.split('T')[1]?.substring(0, 5) ?? '')
: '';
} else {
isScheduled = false;
scheduleDate = '';
scheduleTime = '';
}
},
});
function getTaskTagIds(): string[] { function getTaskTagIds(): string[] {
return ((task?.metadata as Record<string, unknown>)?.labelIds as string[]) ?? []; return ((detail.entity?.metadata as Record<string, unknown>)?.labelIds as string[]) ?? [];
} }
let taskTags = $derived(getTagsByIds(allTags, getTaskTagIds())); let taskTags = $derived(getTagsByIds(allTags, getTaskTagIds()));
@ -55,50 +81,10 @@
}); });
} }
$effect(() => {
taskId; // track
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(async () => {
const t = await db.table<LocalTask>('tasks').get(taskId);
if (!t) return { task: null, block: null };
const block = t.scheduledBlockId ? await getBlock(t.scheduledBlockId) : null;
// Decrypt clones so the inline editor binds to plaintext title /
// description / metadata. The on-disk rows stay encrypted.
const decryptedTask = await decryptRecord('tasks', { ...t });
const decryptedBlock = block ? await decryptBlock(block) : null;
return { task: decryptedTask, block: decryptedBlock };
}).subscribe((val) => {
task = val?.task ?? null;
if (val?.task && !focused) {
editTitle = val.task.title;
editDescription = val.task.description ?? '';
editDueDate = val.task.dueDate?.split('T')[0] ?? '';
editPriority = val.task.priority;
// Load schedule from TimeBlock
if (val.block) {
isScheduled = true;
scheduleDate = val.block.startDate.split('T')[0];
scheduleTime = val.block.startDate.includes('T')
? val.block.startDate.split('T')[1]?.substring(0, 5)
: '';
} else {
isScheduled = false;
scheduleDate = '';
scheduleTime = '';
}
}
});
return () => sub.unsubscribe();
});
async function saveField() { async function saveField() {
focused = false; detail.blur();
await tasksStore.updateTask(taskId, { await tasksStore.updateTask(taskId, {
title: editTitle.trim() || task?.title || 'Untitled', title: editTitle.trim() || detail.entity?.title || 'Untitled',
description: editDescription.trim() || undefined, description: editDescription.trim() || undefined,
dueDate: editDueDate ? new Date(editDueDate).toISOString() : null, dueDate: editDueDate ? new Date(editDueDate).toISOString() : null,
priority: editPriority, priority: editPriority,
@ -109,12 +95,10 @@
async function toggleSchedule() { async function toggleSchedule() {
if (isScheduled) { if (isScheduled) {
// Unschedule
isScheduled = false; isScheduled = false;
scheduleDate = ''; scheduleDate = '';
scheduleTime = ''; scheduleTime = '';
} else { } else {
// Schedule for tomorrow 9:00 by default
isScheduled = true; isScheduled = true;
const tomorrow = new Date(); const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
@ -133,6 +117,7 @@
} }
async function toggleSubtask(subtaskId: string) { async function toggleSubtask(subtaskId: string) {
const task = detail.entity;
if (!task?.subtasks) return; if (!task?.subtasks) return;
const updated = task.subtasks.map((s) => const updated = task.subtasks.map((s) =>
s.id === subtaskId s.id === subtaskId
@ -170,11 +155,17 @@
}; };
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !task} entity={detail.entity}
<p class="empty">Aufgabe nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Aufgabe nicht gefunden"
<!-- Title row with checkbox --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Aufgabe wirklich löschen?"
onConfirmDelete={deleteTask}
>
{#snippet body(task)}
<div class="title-row"> <div class="title-row">
<button class="complete-btn" onclick={toggleComplete}> <button class="complete-btn" onclick={toggleComplete}>
<div class="checkbox" class:checked={task.isCompleted}> <div class="checkbox" class:checked={task.isCompleted}>
@ -185,13 +176,12 @@
class="title-input" class="title-input"
class:completed={task.isCompleted} class:completed={task.isCompleted}
bind:value={editTitle} bind:value={editTitle}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Titel..." placeholder="Titel..."
/> />
</div> </div>
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Priorität</span> <span class="prop-label">Priorität</span>
@ -213,7 +203,7 @@
type="date" type="date"
class="prop-input" class="prop-input"
bind:value={editDueDate} bind:value={editDueDate}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
</div> </div>
@ -225,7 +215,6 @@
</div> </div>
{/if} {/if}
<!-- Schedule on calendar -->
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Kalender</span> <span class="prop-label">Kalender</span>
{#if isScheduled} {#if isScheduled}
@ -234,14 +223,14 @@
type="date" type="date"
class="prop-input" class="prop-input"
bind:value={scheduleDate} bind:value={scheduleDate}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
<input <input
type="time" type="time"
class="prop-input" class="prop-input"
bind:value={scheduleTime} bind:value={scheduleTime}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
<button <button
@ -262,7 +251,7 @@
minDurationMinutes={task.estimatedDuration minDurationMinutes={task.estimatedDuration
? Math.round(task.estimatedDuration / 60) ? Math.round(task.estimatedDuration / 60)
: 60} : 60}
onSelect={(start, end) => { onSelect={(start) => {
isScheduled = true; isScheduled = true;
scheduleDate = start.toISOString().split('T')[0]; scheduleDate = start.toISOString().split('T')[0];
scheduleTime = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`; scheduleTime = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`;
@ -274,7 +263,6 @@
</div> </div>
</div> </div>
<!-- Tags -->
{#if taskTags.length > 0} {#if taskTags.length > 0}
<div class="section"> <div class="section">
<span class="section-label">Tags</span> <span class="section-label">Tags</span>
@ -294,23 +282,20 @@
</div> </div>
{/if} {/if}
<!-- Links -->
<LinkedItems recordRef={{ app: 'todo', collection: 'tasks', id: taskId }} {navigate} /> <LinkedItems recordRef={{ app: 'todo', collection: 'tasks', id: taskId }} {navigate} />
<!-- Description -->
<div class="section"> <div class="section">
<span class="section-label">Beschreibung</span> <span class="section-label">Beschreibung</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editDescription} bind:value={editDescription}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Beschreibung hinzufügen..." placeholder="Beschreibung hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Subtasks -->
{#if task.subtasks && task.subtasks.length > 0} {#if task.subtasks && task.subtasks.length > 0}
<div class="section"> <div class="section">
<span class="section-label"> <span class="section-label">
@ -331,218 +316,100 @@
</div> </div>
{/if} {/if}
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(task.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(task.createdAt ?? '').toLocaleDateString('de')}</span>
{#if task.updatedAt} {#if task.updatedAt}
<span>Bearbeitet: {new Date(task.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(task.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Aufgabe wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteTask}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style> <style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title row */
.title-row { .title-row {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.complete-btn { .complete-btn {
border: none; border: none;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
padding: 0.25rem 0 0 0; padding: 0.125rem;
flex-shrink: 0; flex-shrink: 0;
} }
.title-input {
flex: 1;
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input.completed {
text-decoration: line-through;
color: #9ca3af;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input.completed {
color: #6b7280;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Checkbox */
.checkbox { .checkbox {
width: 20px; width: 18px;
height: 20px; height: 18px;
border-radius: 0.25rem; border-radius: 4px;
border: 2px solid #d1d5db; border: 1.5px solid #d1d5db;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.15s;
}
.checkbox.checked {
border-color: #22c55e;
background: #22c55e;
color: white; color: white;
} transition:
.checkbox.small { background 0.15s,
width: 16px; border-color 0.15s;
height: 16px;
border-width: 1.5px;
} }
:global(.dark) .checkbox { :global(.dark) .checkbox {
border-color: #4b5563; border-color: #4b5563;
} }
.checkbox.checked {
/* Properties */ background: #22c55e;
.properties { border-color: #22c55e;
display: flex;
flex-direction: column;
gap: 0.5rem;
} }
.prop-row { .checkbox.small {
display: flex; width: 14px;
align-items: center; height: 14px;
justify-content: space-between; border-radius: 3px;
padding: 0.25rem 0;
} }
.prop-label { :global(.detail-view .title-input.completed) {
font-size: 0.75rem; text-decoration: line-through;
color: #9ca3af; color: #9ca3af;
} }
.prop-value {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.schedule-fields { .schedule-fields {
display: flex; display: flex;
gap: 0.25rem;
align-items: center; align-items: center;
gap: 0.375rem;
} }
.schedule-options { .schedule-options {
display: flex; display: flex;
flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
align-items: center;
} }
.schedule-btn { .schedule-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border: 1px dashed rgba(0, 0, 0, 0.15); border-radius: 0.25rem;
border-radius: 0.375rem; border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent; background: transparent;
font-size: 0.75rem; font-size: 0.75rem;
color: #6b7280; color: #6b7280;
cursor: pointer; cursor: pointer;
transition: all 0.15s;
} }
.schedule-btn:hover { .schedule-btn:hover {
border-color: #3b82f6; background: rgba(0, 0, 0, 0.04);
color: #3b82f6;
background: rgba(59, 130, 246, 0.05);
} }
:global(.dark) .schedule-btn { :global(.dark) .schedule-btn {
border-color: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af; color: #9ca3af;
} }
:global(.dark) .schedule-btn:hover {
border-color: #3b82f6;
color: #3b82f6;
}
.unschedule-btn { .unschedule-btn {
padding: 0.25rem;
border: none; border: none;
background: transparent; background: transparent;
border-radius: 0.25rem;
color: #9ca3af;
cursor: pointer; cursor: pointer;
padding: 0.125rem;
color: #9ca3af;
display: flex;
align-items: center;
} }
.unschedule-btn:hover { .unschedule-btn:hover {
color: #ef4444; color: #ef4444;
background: rgba(239, 68, 68, 0.1);
} }
.prop-select,
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-select:hover,
.prop-input:hover,
.prop-select:focus,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .prop-select,
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-select:hover,
:global(.dark) .prop-input:hover,
:global(.dark) .prop-select:focus,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.tags-list { .tags-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -554,82 +421,46 @@
gap: 0.25rem; gap: 0.25rem;
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 9999px; border-radius: 9999px;
border: none;
background: color-mix(in srgb, var(--tag-color) 12%, transparent); background: color-mix(in srgb, var(--tag-color) 12%, transparent);
border: none;
font-size: 0.6875rem; font-size: 0.6875rem;
color: #6b7280; color: #6b7280;
cursor: pointer; cursor: pointer;
transition: all 0.15s;
} }
.tag-pill:hover { .tag-pill:hover {
background: color-mix(in srgb, var(--tag-color) 20%, transparent); opacity: 0.8;
color: #ef4444;
}
:global(.dark) .tag-pill {
background: color-mix(in srgb, var(--tag-color) 18%, transparent);
color: #9ca3af;
}
:global(.dark) .tag-pill:hover {
background: color-mix(in srgb, var(--tag-color) 28%, transparent);
color: #ef4444;
} }
.tag-dot { .tag-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 9999px; border-radius: 9999px;
flex-shrink: 0;
} }
.section-label { :global(.dark) .tag-pill {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af; color: #9ca3af;
} }
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
/* Subtasks */
.subtask-list { .subtask-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem;
} }
.subtask-item { .subtask-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.25rem 0; padding: 0.25rem 0.375rem;
border: none; border: none;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
border-radius: 0.25rem;
transition: background 0.15s;
}
.subtask-item:hover {
background: rgba(0, 0, 0, 0.04);
}
:global(.dark) .subtask-item:hover {
background: rgba(255, 255, 255, 0.04);
} }
.subtask-title { .subtask-title {
font-size: 0.8125rem; font-size: 0.8125rem;
@ -642,85 +473,4 @@
:global(.dark) .subtask-title { :global(.dark) .subtask-title {
color: #e5e7eb; color: #e5e7eb;
} }
:global(.dark) .subtask-title.completed {
color: #6b7280;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.complete-btn,
.action-btn,
.schedule-btn,
.unschedule-btn,
.subtask-item,
.tag-pill {
min-height: 44px;
}
.prop-select,
.prop-input {
min-height: 44px;
}
}
</style> </style>

View file

@ -3,21 +3,16 @@
All fields are always editable. Changes auto-save on blur. All fields are always editable. Changes auto-save on blur.
--> -->
<script lang="ts"> <script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import { encryptRecord } from '$lib/data/crypto';
import { toastStore } from '@mana/shared-ui/toast'; import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { Trash } from '@mana/shared-icons'; import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import type { ViewProps } from '$lib/app-registry'; import type { ViewProps } from '$lib/app-registry';
import type { LocalLink } from '../types'; import type { LocalLink } from '../types';
let { navigate, goBack, params }: ViewProps = $props(); let { params, goBack }: ViewProps = $props();
let linkId = $derived(params.linkId as string); let linkId = $derived(params.linkId as string);
let link = $state<LocalLink | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editTitle = $state(''); let editTitle = $state('');
let editOriginalUrl = $state(''); let editOriginalUrl = $state('');
let editCustomCode = $state(''); let editCustomCode = $state('');
@ -25,39 +20,25 @@
let editIsActive = $state(true); let editIsActive = $state(true);
let editExpiresAt = $state(''); let editExpiresAt = $state('');
let focused = $state(false); const detail = useDetailEntity<LocalLink>({
id: () => linkId,
$effect(() => { table: 'links',
linkId; decrypt: true,
confirmDelete = false; onLoad: (val) => {
focused = false; editTitle = val.title ?? '';
}); editOriginalUrl = val.originalUrl;
editCustomCode = val.customCode ?? '';
$effect(() => { editDescription = val.description ?? '';
const sub = liveQuery(async () => { editIsActive = val.isActive;
const raw = await db.table<LocalLink>('links').get(linkId); editExpiresAt = val.expiresAt?.split('T')[0] ?? '';
// title + description are encrypted on disk; decrypt a clone so },
// the inline editor binds to plaintext.
return raw ? await decryptRecord('links', { ...raw }) : null;
}).subscribe((val) => {
link = val ?? null;
if (val && !focused) {
editTitle = val.title ?? '';
editOriginalUrl = val.originalUrl;
editCustomCode = val.customCode ?? '';
editDescription = val.description ?? '';
editIsActive = val.isActive;
editExpiresAt = val.expiresAt?.split('T')[0] ?? '';
}
});
return () => sub.unsubscribe();
}); });
async function saveField() { async function saveField() {
focused = false; detail.blur();
const diff: Record<string, unknown> = { const diff: Record<string, unknown> = {
title: editTitle.trim() || undefined, title: editTitle.trim() || undefined,
originalUrl: editOriginalUrl.trim() || link?.originalUrl || '', originalUrl: editOriginalUrl.trim() || detail.entity?.originalUrl || '',
customCode: editCustomCode.trim() || undefined, customCode: editCustomCode.trim() || undefined,
description: editDescription.trim() || undefined, description: editDescription.trim() || undefined,
isActive: editIsActive, isActive: editIsActive,
@ -76,39 +57,44 @@
} }
async function deleteLink() { async function deleteLink() {
const id = linkId; await db.table('links').update(linkId, {
await db.table('links').update(id, {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
goBack();
toastStore.undo('Link gelöscht', () => {
db.table('links').update(id, { deletedAt: undefined, updatedAt: new Date().toISOString() });
});
} }
</script> </script>
<div class="detail-view"> <DetailViewShell
{#if !link} entity={detail.entity}
<p class="empty">Link nicht gefunden</p> loading={detail.loading}
{:else} notFoundLabel="Link nicht gefunden"
<!-- Title --> confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Link wirklich löschen?"
onConfirmDelete={() =>
detail.deleteWithUndo({
label: 'Link gelöscht',
delete: deleteLink,
goBack,
})}
>
{#snippet body(link)}
<input <input
class="title-input" class="title-input"
bind:value={editTitle} bind:value={editTitle}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Titel..." placeholder="Titel..."
/> />
<!-- Properties -->
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">URL</span> <span class="prop-label">URL</span>
<input <input
class="prop-input url-input" class="prop-input"
bind:value={editOriginalUrl} bind:value={editOriginalUrl}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="https://..." placeholder="https://..."
/> />
@ -119,7 +105,7 @@
<input <input
class="prop-input" class="prop-input"
bind:value={editCustomCode} bind:value={editCustomCode}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="custom-code" placeholder="custom-code"
/> />
@ -134,15 +120,16 @@
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">Aktiv</span> <span class="prop-label">Aktiv</span>
<label class="toggle-label"> <button
<input class="toggle-btn"
type="checkbox" class:active={editIsActive}
class="toggle-input" onclick={() => {
bind:checked={editIsActive} editIsActive = !editIsActive;
onchange={handleActiveToggle} handleActiveToggle();
/> }}
<span class="toggle-text">{editIsActive ? 'Ja' : 'Nein'}</span> >
</label> {editIsActive ? 'Ja' : 'Nein'}
</button>
</div> </div>
<div class="prop-row"> <div class="prop-row">
@ -156,268 +143,29 @@
type="date" type="date"
class="prop-input" class="prop-input"
bind:value={editExpiresAt} bind:value={editExpiresAt}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
/> />
</div> </div>
</div> </div>
<!-- Description -->
<div class="section"> <div class="section">
<span class="section-label">Beschreibung</span> <span class="section-label">Beschreibung</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editDescription} bind:value={editDescription}
onfocus={() => (focused = true)} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder="Beschreibung hinzufügen..." placeholder="Beschreibung hinzufügen..."
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<!-- Metadata -->
<div class="meta"> <div class="meta">
<span>Erstellt: {new Date(link.createdAt ?? '').toLocaleDateString('de')}</span> <span>Erstellt: {new Date(link.createdAt ?? '').toLocaleDateString('de')}</span>
{#if link.updatedAt} {#if link.updatedAt}
<span>Bearbeitet: {new Date(link.updatedAt).toLocaleDateString('de')}</span> <span>Bearbeitet: {new Date(link.updatedAt).toLocaleDateString('de')}</span>
{/if} {/if}
</div> </div>
{/snippet}
<!-- Delete --> </DetailViewShell>
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Link wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteLink}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title */
.title-input {
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-value {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-input:hover,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.url-input {
flex: 1;
min-width: 0;
margin-left: 0.5rem;
text-align: right;
}
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-input:hover,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Toggle */
.toggle-label {
display: flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
}
.toggle-input {
accent-color: #22c55e;
}
.toggle-text {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .toggle-text {
color: #e5e7eb;
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
@media (max-width: 640px) {
.detail-view {
padding: 0.75rem;
}
.action-btn,
.toggle-label {
min-height: 44px;
}
.prop-input {
min-height: 44px;
}
}
</style>