mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
c3cb9dd533
commit
30787e36d2
18 changed files with 1472 additions and 4572 deletions
374
apps/mana/apps/web/src/lib/components/DetailViewShell.svelte
Normal file
374
apps/mana/apps/web/src/lib/components/DetailViewShell.svelte
Normal 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>
|
||||||
187
apps/mana/apps/web/src/lib/data/detail-entity.svelte.ts
Normal file
187
apps/mana/apps/web/src/lib/data/detail-entity.svelte.ts
Normal 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, () => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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. 📦"
|
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>
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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">±{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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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. ⭐"
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
–
|
|
||||||
{/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}
|
||||||
· {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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue