feat(manacore/web): add overlay detail views for 8 more modules

Add inline-editable DetailViews with auto-save for:
planta, inventar, skilltree, memoro, questions, uload, mukke, citycorners

Wire AppView list items to open overlay via navigate() with sibling
navigation support. Fix citycorners table names (cityLocations→ccLocations).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 22:57:09 +02:00
parent a08f1501f2
commit 650dea5e1d
17 changed files with 3361 additions and 24 deletions

View file

@ -87,6 +87,10 @@ export const APP_REGISTRY: AppEntry[] = [
name: 'Mukke',
color: '#F97316',
load: () => import('$lib/modules/mukke/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/mukke/AppView.svelte') },
detail: { load: () => import('$lib/modules/mukke/views/DetailView.svelte') },
},
},
{
id: 'photos',
@ -111,6 +115,10 @@ export const APP_REGISTRY: AppEntry[] = [
name: 'Planta',
color: '#16A34A',
load: () => import('$lib/modules/planta/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/planta/AppView.svelte') },
detail: { load: () => import('$lib/modules/planta/views/DetailView.svelte') },
},
},
{
id: 'presi',
@ -123,24 +131,40 @@ export const APP_REGISTRY: AppEntry[] = [
name: 'Inventar',
color: '#78716C',
load: () => import('$lib/modules/inventar/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/inventar/AppView.svelte') },
detail: { load: () => import('$lib/modules/inventar/views/DetailView.svelte') },
},
},
{
id: 'memoro',
name: 'Memoro',
color: '#F59E0B',
load: () => import('$lib/modules/memoro/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/memoro/AppView.svelte') },
detail: { load: () => import('$lib/modules/memoro/views/DetailView.svelte') },
},
},
{
id: 'questions',
name: 'Questions',
color: '#2563EB',
load: () => import('$lib/modules/questions/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/questions/AppView.svelte') },
detail: { load: () => import('$lib/modules/questions/views/DetailView.svelte') },
},
},
{
id: 'skilltree',
name: 'SkillTree',
color: '#D946EF',
load: () => import('$lib/modules/skilltree/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/skilltree/AppView.svelte') },
detail: { load: () => import('$lib/modules/skilltree/views/DetailView.svelte') },
},
},
{
id: 'moodlit',
@ -153,12 +177,20 @@ export const APP_REGISTRY: AppEntry[] = [
name: 'CityCorners',
color: '#14B8A6',
load: () => import('$lib/modules/citycorners/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/citycorners/AppView.svelte') },
detail: { load: () => import('$lib/modules/citycorners/views/DetailView.svelte') },
},
},
{
id: 'uload',
name: 'uLoad',
color: '#0EA5E9',
load: () => import('$lib/modules/uload/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/uload/AppView.svelte') },
detail: { load: () => import('$lib/modules/uload/views/DetailView.svelte') },
},
},
{
id: 'calc',

View file

@ -7,6 +7,9 @@
import { db } from '$lib/data/database';
import type { LocalLocation, LocalFavorite } from './types';
import { CATEGORY_COLORS } from './types';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
let { navigate, goBack, params }: ViewProps = $props();
let locations = $state<LocalLocation[]>([]);
let favorites = $state<LocalFavorite[]>([]);
@ -14,7 +17,7 @@
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalLocation>('cityLocations')
.table<LocalLocation>('ccLocations')
.toArray()
.then((all) => all.filter((l) => !l.deletedAt));
}).subscribe((val) => {
@ -26,7 +29,7 @@
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFavorite>('cityFavorites')
.table<LocalFavorite>('ccFavorites')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt));
}).subscribe((val) => {
@ -60,7 +63,15 @@
<div class="flex-1 overflow-auto">
{#each locations as location (location.id)}
<div class="flex items-start gap-2 rounded-md px-2 py-2 transition-colors hover:bg-white/5">
<button
onclick={() =>
navigate('detail', {
locationId: location.id,
_siblingIds: locations.map((l) => l.id),
_siblingKey: 'locationId',
})}
class="flex w-full items-start gap-2 rounded-md px-2 py-2 transition-colors hover:bg-white/5 cursor-pointer text-left"
>
<div
class="mt-0.5 h-2.5 w-2.5 shrink-0 rounded-full"
style="background: {CATEGORY_COLORS[location.category] ?? '#666'}"
@ -76,7 +87,7 @@
{categoryLabels[location.category] ?? location.category}
</p>
</div>
</div>
</button>
{/each}
{#if locations.length === 0}

View file

@ -0,0 +1,404 @@
<!--
CityCorners — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { favoritesStore } from '../stores/favorites.svelte';
import { Star, Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalLocation, LocalFavorite } from '../types';
import { CATEGORY_COLORS } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
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 editCategory = $state<LocalLocation['category']>('sight');
let editDescription = $state('');
let editAddress = $state('');
let focused = $state(false);
$effect(() => {
locationId;
confirmDelete = false;
focused = false;
});
$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();
});
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<LocalFavorite>('ccFavorites').toArray();
return all.find((f) => f.locationId === locationId && !f.deletedAt);
}).subscribe((val) => {
isFavorite = !!val;
});
return () => sub.unsubscribe();
});
async function saveField() {
focused = false;
await db.table('ccLocations').update(locationId, {
name: editName.trim() || location?.name || 'Ohne Name',
category: editCategory,
description: editDescription.trim() || undefined,
address: editAddress.trim() || undefined,
updatedAt: new Date().toISOString(),
});
}
async function handleCategoryChange() {
await db.table('ccLocations').update(locationId, {
category: editCategory,
updatedAt: new Date().toISOString(),
});
}
async function toggleFavorite() {
await favoritesStore.toggle(locationId);
}
async function deleteLocation() {
await db.table('ccLocations').update(locationId, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
goBack();
}
const categoryLabels: Record<LocalLocation['category'], string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Geschäft',
museum: 'Museum',
cafe: 'Café',
bar: 'Bar',
park: 'Park',
beach: 'Strand',
hotel: 'Hotel',
event_venue: 'Veranstaltungsort',
viewpoint: 'Aussichtspunkt',
};
</script>
<div class="detail-view">
{#if !location}
<p class="empty">Ort nicht gefunden</p>
{:else}
<!-- Title row with favorite -->
<div class="title-row">
<input
class="title-input"
bind:value={editName}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Name..."
/>
<button class="fav-btn" onclick={toggleFavorite}>
<Star size={18} weight={isFavorite ? 'fill' : 'regular'} />
</button>
</div>
<!-- Category dot -->
<div class="category-row">
<div class="category-dot" style="background: {CATEGORY_COLORS[editCategory] ?? '#666'}"></div>
<select class="prop-select" bind:value={editCategory} onchange={handleCategoryChange}>
{#each Object.entries(categoryLabels) as [key, label]}
<option value={key}>{label}</option>
{/each}
</select>
</div>
<!-- Properties -->
<div class="properties">
<div class="prop-row">
<span class="prop-label">Adresse</span>
<input
class="prop-input address-input"
bind:value={editAddress}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Adresse..."
/>
</div>
</div>
<!-- Description -->
<div class="section">
<span class="section-label">Beschreibung</span>
<textarea
class="description-input"
bind:value={editDescription}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Beschreibung hinzufügen..."
rows={3}
></textarea>
</div>
<!-- Metadata -->
<div class="meta">
<span>Erstellt: {new Date(location.createdAt ?? '').toLocaleDateString('de')}</span>
{#if location.updatedAt}
<span>Bearbeitet: {new Date(location.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<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>
.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 {
display: flex;
align-items: center;
gap: 0.5rem;
}
.category-dot {
width: 10px;
height: 10px;
border-radius: 50%;
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;
}
</style>

View file

@ -5,8 +5,11 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalCollection, LocalItem } from './types';
let { navigate, goBack, params }: ViewProps = $props();
let collections = $state<LocalCollection[]>([]);
let items = $state<LocalItem[]>([]);
@ -54,8 +57,14 @@
<div class="flex-1 overflow-auto">
{#each collections as collection (collection.id)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
<button
onclick={() =>
navigate('detail', {
collectionId: collection.id,
_siblingIds: collections.map((c) => c.id),
_siblingKey: 'collectionId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5"
>
<div class="flex items-center gap-2">
{#if collection.icon}
@ -67,7 +76,7 @@
{#if collection.description}
<p class="mt-1 truncate text-xs text-white/30">{collection.description}</p>
{/if}
</div>
</button>
{/each}
{#if collections.length === 0}

View file

@ -0,0 +1,351 @@
<!--
Inventar — DetailView (inline editable overlay)
Collection details, always editable, auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { collectionsStore } from '../stores/collections.svelte';
import { Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalCollection, LocalItem } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
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 editDescription = $state('');
let editIcon = $state('');
let editColor = $state('');
let focused = $state(false);
$effect(() => {
collectionId;
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() =>
db.table<LocalCollection>('invCollections').get(collectionId)
).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')
.where('collectionId')
.equals(collectionId)
.filter((i) => !i.deletedAt)
.count();
}).subscribe((val) => {
itemCount = val ?? 0;
});
return () => sub.unsubscribe();
});
async function saveField() {
focused = false;
await collectionsStore.update(collectionId, {
name: editName.trim() || collection?.name || 'Unbenannt',
description: editDescription.trim() || null,
icon: editIcon.trim() || null,
color: editColor.trim() || null,
});
}
async function deleteCollection() {
await collectionsStore.delete(collectionId);
goBack();
}
</script>
<div class="detail-view">
{#if !collection}
<p class="empty">Sammlung nicht gefunden</p>
{:else}
<!-- Icon + Title -->
<div class="title-row">
{#if collection.icon}
<span class="title-icon">{collection.icon}</span>
{/if}
<input
class="title-input"
bind:value={editName}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Name..."
/>
</div>
<!-- Properties -->
<div class="properties">
<div class="prop-row">
<span class="prop-label">Icon</span>
<input
class="prop-input"
bind:value={editIcon}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="z.B. &#128230;"
/>
</div>
<div class="prop-row">
<span class="prop-label">Farbe</span>
<input
class="prop-input"
bind:value={editColor}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="z.B. #78716C"
/>
</div>
<div class="prop-row">
<span class="prop-label">Gegenstaende</span>
<span class="prop-value">{itemCount}</span>
</div>
</div>
<!-- Description -->
<div class="section">
<span class="section-label">Beschreibung</span>
<textarea
class="description-input"
bind:value={editDescription}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Beschreibung hinzufuegen..."
rows={3}
></textarea>
</div>
<!-- Metadata -->
<div class="meta">
<span>Erstellt: {new Date(collection.createdAt ?? '').toLocaleDateString('de')}</span>
{#if collection.updatedAt}
<span>Bearbeitet: {new Date(collection.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<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;
}
</style>

View file

@ -5,8 +5,11 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalMemo } from './types';
let { navigate, goBack, params }: ViewProps = $props();
let memos = $state<LocalMemo[]>([]);
$effect(() => {
@ -51,8 +54,14 @@
<div class="flex-1 overflow-auto">
{#each sorted as memo (memo.id)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
<button
onclick={() =>
navigate('detail', {
memoId: memo.id,
_siblingIds: sorted.map((m) => m.id),
_siblingKey: 'memoId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
@ -78,7 +87,7 @@
: memo.processingStatus}
</span>
</div>
</div>
</button>
{/each}
{#if sorted.length === 0}

View file

@ -0,0 +1,400 @@
<!--
Memoro — DetailView (inline editable overlay)
Memo details with transcript, pin toggle, auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { memosStore } from '../stores/memos.svelte';
import { Trash, PushPin } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalMemo, ProcessingStatus } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let memoId = $derived(params.memoId as string);
let memo = $state<LocalMemo | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editTitle = $state('');
let editIntro = $state('');
let editLanguage = $state('');
let focused = $state(false);
$effect(() => {
memoId;
confirmDelete = false;
focused = false;
});
$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() {
focused = false;
await memosStore.update(memoId, {
title: editTitle.trim() || null,
intro: editIntro.trim() || null,
language: editLanguage.trim() || null,
});
}
async function togglePin() {
if (!memo) return;
if (memo.isPinned) {
await memosStore.unpin(memoId);
} else {
await memosStore.pin(memoId);
}
}
async function deleteMemo() {
await memosStore.delete(memoId);
goBack();
}
function formatDuration(ms: number | null): string {
if (!ms) return '--:--';
const sec = Math.round(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
const statusLabels: Record<ProcessingStatus, string> = {
pending: 'Ausstehend',
processing: 'Wird verarbeitet',
completed: 'Fertig',
failed: 'Fehlgeschlagen',
};
const statusColors: Record<ProcessingStatus, string> = {
pending: '#eab308',
processing: '#3b82f6',
completed: '#22c55e',
failed: '#ef4444',
};
</script>
<div class="detail-view">
{#if !memo}
<p class="empty">Memo nicht gefunden</p>
{:else}
<!-- Title + Pin -->
<div class="title-row">
<input
class="title-input"
bind:value={editTitle}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Titel..."
/>
<button class="pin-btn" class:pinned={memo.isPinned} onclick={togglePin}>
<PushPin size={16} />
</button>
</div>
<!-- Properties -->
<div class="properties">
<div class="prop-row">
<span class="prop-label">Status</span>
<span class="status-badge" style="color: {statusColors[memo.processingStatus]}">
{statusLabels[memo.processingStatus]}
</span>
</div>
<div class="prop-row">
<span class="prop-label">Dauer</span>
<span class="prop-value">{formatDuration(memo.audioDurationMs)}</span>
</div>
<div class="prop-row">
<span class="prop-label">Sprache</span>
<input
class="prop-input"
bind:value={editLanguage}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="z.B. de"
/>
</div>
</div>
<!-- Intro -->
<div class="section">
<span class="section-label">Zusammenfassung</span>
<textarea
class="description-input"
bind:value={editIntro}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Zusammenfassung hinzufuegen..."
rows={2}
></textarea>
</div>
<!-- Transcript (read-only) -->
{#if memo.transcript}
<div class="section">
<span class="section-label">Transkript</span>
<div class="transcript">{memo.transcript}</div>
</div>
{/if}
<!-- Metadata -->
<div class="meta">
<span>Erstellt: {new Date(memo.createdAt ?? '').toLocaleDateString('de')}</span>
{#if memo.updatedAt}
<span>Bearbeitet: {new Date(memo.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<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>
.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 {
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
padding: 0.125rem;
flex-shrink: 0;
transition: color 0.15s;
}
.pin-btn:hover {
color: #6b7280;
}
.pin-btn.pinned {
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 {
font-size: 0.8125rem;
color: #374151;
padding: 0.5rem;
border-radius: 0.375rem;
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.04);
white-space: pre-wrap;
max-height: 12rem;
overflow-y: auto;
line-height: 1.5;
}
:global(.dark) .transcript {
color: #e5e7eb;
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;
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;
}
</style>

View file

@ -6,6 +6,9 @@
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalSong, LocalPlaylist } from './types';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
let { navigate, goBack, params }: ViewProps = $props();
let songs = $state<LocalSong[]>([]);
let playlists = $state<LocalPlaylist[]>([]);
@ -61,8 +64,14 @@
<div class="flex-1 overflow-auto">
<h3 class="mb-2 text-xs font-medium text-white/50">Zuletzt gehört</h3>
{#each recentlyPlayed as song (song.id)}
<div
class="flex items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
<button
onclick={() =>
navigate('detail', {
songId: song.id,
_siblingIds: recentlyPlayed.map((s) => s.id),
_siblingKey: 'songId',
})}
class="flex w-full items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5 cursor-pointer text-left"
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-white/10 text-xs text-white/30"
@ -74,7 +83,7 @@
<p class="truncate text-xs text-white/40">{song.artist ?? 'Unbekannt'}</p>
</div>
<span class="text-xs text-white/30">{formatDuration(song.duration)}</span>
</div>
</button>
{/each}
{#if recentlyPlayed.length === 0}

View file

@ -0,0 +1,365 @@
<!--
Mukke — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { libraryStore } from '../stores/library.svelte';
import { Heart, Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalSong } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let songId = $derived(params.songId as string);
let song = $state<LocalSong | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editTitle = $state('');
let editArtist = $state('');
let editAlbum = $state('');
let editGenre = $state('');
let editYear = $state<number | null>(null);
let editBpm = $state<number | null>(null);
let focused = $state(false);
$effect(() => {
songId;
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalSong>('songs').get(songId)).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() {
focused = false;
await libraryStore.updateMetadata(songId, {
title: editTitle.trim() || song?.title || 'Ohne Titel',
artist: editArtist.trim() || undefined,
album: editAlbum.trim() || undefined,
genre: editGenre.trim() || undefined,
year: editYear,
bpm: editBpm,
});
}
async function toggleFavorite() {
await libraryStore.toggleFavorite(songId);
}
async function deleteSong() {
await libraryStore.delete(songId);
goBack();
}
function formatDuration(sec?: number | null): string {
if (!sec) return '--:--';
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
</script>
<div class="detail-view">
{#if !song}
<p class="empty">Song nicht gefunden</p>
{:else}
<!-- Title row with favorite -->
<div class="title-row">
<input
class="title-input"
bind:value={editTitle}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Titel..."
/>
<button class="fav-btn" onclick={toggleFavorite}>
<Heart size={18} weight={song.favorite ? 'fill' : 'regular'} />
</button>
</div>
<!-- Properties -->
<div class="properties">
<div class="prop-row">
<span class="prop-label">Künstler</span>
<input
class="prop-input"
bind:value={editArtist}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Unbekannt"
/>
</div>
<div class="prop-row">
<span class="prop-label">Album</span>
<input
class="prop-input"
bind:value={editAlbum}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="--"
/>
</div>
<div class="prop-row">
<span class="prop-label">Genre</span>
<input
class="prop-input"
bind:value={editGenre}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="--"
/>
</div>
<div class="prop-row">
<span class="prop-label">Jahr</span>
<input
type="number"
class="prop-input"
bind:value={editYear}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="--"
/>
</div>
<div class="prop-row">
<span class="prop-label">BPM</span>
<input
type="number"
class="prop-input"
bind:value={editBpm}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="--"
/>
</div>
<div class="prop-row">
<span class="prop-label">Dauer</span>
<span class="prop-value">{formatDuration(song.duration)}</span>
</div>
<div class="prop-row">
<span class="prop-label">Wiedergaben</span>
<span class="prop-value">{song.playCount}</span>
</div>
</div>
<!-- Metadata -->
<div class="meta">
<span>Erstellt: {new Date(song.createdAt ?? '').toLocaleDateString('de')}</span>
{#if song.updatedAt}
<span>Bearbeitet: {new Date(song.updatedAt).toLocaleDateString('de')}</span>
{/if}
{#if song.lastPlayedAt}
<span>Zuletzt gehört: {new Date(song.lastPlayedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<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;
}
</style>

View file

@ -5,8 +5,11 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalPlant, LocalWateringSchedule } from './types';
let { navigate, goBack, params }: ViewProps = $props();
let plants = $state<LocalPlant[]>([]);
let schedules = $state<LocalWateringSchedule[]>([]);
@ -70,8 +73,14 @@
{#each plants as plant (plant.id)}
{@const schedule = getSchedule(plant.id)}
{@const waterDue = needsWater(schedule)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
<button
onclick={() =>
navigate('detail', {
plantId: plant.id,
_siblingIds: plants.map((p) => p.id),
_siblingKey: 'plantId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5"
>
<div class="flex items-center gap-2">
<span class="text-sm"
@ -92,7 +101,7 @@
Alle {schedule.frequencyDays} Tage giessen
</p>
{/if}
</div>
</button>
{/each}
{#if plants.length === 0}

View file

@ -0,0 +1,406 @@
<!--
Planta — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalPlant, HealthStatus, LightLevel } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let plantId = $derived(params.plantId as string);
let plant = $state<LocalPlant | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editName = $state('');
let editScientificName = $state('');
let editSpecies = $state('');
let editHealthStatus = $state<HealthStatus>('healthy');
let editLightRequirements = $state<LightLevel | ''>('');
let editWateringFrequencyDays = $state<number | null>(null);
let editCareNotes = $state('');
let editAcquiredAt = $state('');
let focused = $state(false);
$effect(() => {
plantId;
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalPlant>('plants').get(plantId)).subscribe((val) => {
plant = val ?? null;
if (val && !focused) {
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() {
focused = false;
await db.table('plants').update(plantId, {
name: editName.trim() || plant?.name || 'Unbenannt',
scientificName: editScientificName.trim() || null,
species: editSpecies.trim() || null,
healthStatus: editHealthStatus,
lightRequirements: editLightRequirements || null,
wateringFrequencyDays: editWateringFrequencyDays,
careNotes: editCareNotes.trim() || null,
acquiredAt: editAcquiredAt ? new Date(editAcquiredAt).toISOString() : null,
updatedAt: new Date().toISOString(),
});
}
async function handleSelectChange() {
await db.table('plants').update(plantId, {
healthStatus: editHealthStatus,
lightRequirements: editLightRequirements || null,
updatedAt: new Date().toISOString(),
});
}
async function deletePlant() {
await db.table('plants').update(plantId, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
goBack();
}
const healthLabels: Record<HealthStatus, string> = {
healthy: 'Gesund',
needs_attention: 'Braucht Pflege',
sick: 'Krank',
};
const lightLabels: Record<LightLevel, string> = {
low: 'Wenig',
medium: 'Mittel',
bright: 'Hell',
direct: 'Direkt',
};
const healthColors: Record<HealthStatus, string> = {
healthy: '#22c55e',
needs_attention: '#f59e0b',
sick: '#ef4444',
};
</script>
<div class="detail-view">
{#if !plant}
<p class="empty">Pflanze nicht gefunden</p>
{:else}
<!-- Title -->
<input
class="title-input"
bind:value={editName}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Name..."
/>
<!-- Properties -->
<div class="properties">
<div class="prop-row">
<span class="prop-label">Wissenschaftlicher Name</span>
<input
class="prop-input"
bind:value={editScientificName}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="—"
/>
</div>
<div class="prop-row">
<span class="prop-label">Art</span>
<input
class="prop-input"
bind:value={editSpecies}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="—"
/>
</div>
<div class="prop-row">
<span class="prop-label">Zustand</span>
<select
class="prop-select"
bind:value={editHealthStatus}
onchange={handleSelectChange}
style="color: {healthColors[editHealthStatus]}"
>
{#each ['healthy', 'needs_attention', 'sick'] as const as s}
<option value={s}>{healthLabels[s]}</option>
{/each}
</select>
</div>
<div class="prop-row">
<span class="prop-label">Licht</span>
<select
class="prop-select"
bind:value={editLightRequirements}
onchange={handleSelectChange}
>
<option value=""></option>
{#each ['low', 'medium', 'bright', 'direct'] as const as l}
<option value={l}>{lightLabels[l]}</option>
{/each}
</select>
</div>
<div class="prop-row">
<span class="prop-label">Giessen (Tage)</span>
<input
type="number"
class="prop-input"
bind:value={editWateringFrequencyDays}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="—"
min="1"
/>
</div>
<div class="prop-row">
<span class="prop-label">Erworben</span>
<input
type="date"
class="prop-input"
bind:value={editAcquiredAt}
onfocus={() => (focused = true)}
onblur={saveField}
/>
</div>
</div>
<!-- Care Notes -->
<div class="section">
<span class="section-label">Pflegehinweise</span>
<textarea
class="description-input"
bind:value={editCareNotes}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Pflegehinweise hinzufuegen..."
rows={3}
></textarea>
</div>
<!-- Metadata -->
<div class="meta">
<span>Erstellt: {new Date(plant.createdAt ?? '').toLocaleDateString('de')}</span>
{#if plant.updatedAt}
<span>Bearbeitet: {new Date(plant.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<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;
}
</style>

View file

@ -6,6 +6,9 @@
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalQuestion, LocalCollection } from './types';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
let { navigate, goBack, params }: ViewProps = $props();
let questions = $state<LocalQuestion[]>([]);
let collections = $state<LocalCollection[]>([]);
@ -63,8 +66,14 @@
<div class="flex-1 overflow-auto">
{#each sorted as question (question.id)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
<button
onclick={() =>
navigate('detail', {
questionId: question.id,
_siblingIds: sorted.map((q) => q.id),
_siblingKey: 'questionId',
})}
class="mb-2 w-full text-left rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5 cursor-pointer"
>
<div class="flex items-start justify-between gap-2">
<p class="text-sm font-medium text-white/80">{question.title}</p>
@ -84,7 +93,7 @@
{/each}
</div>
{/if}
</div>
</button>
{/each}
{#if sorted.length === 0}

View file

@ -0,0 +1,398 @@
<!--
Questions — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalQuestion, QuestionStatus, QuestionPriority, ResearchDepth } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let questionId = $derived(params.questionId as string);
let question = $state<LocalQuestion | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editTitle = $state('');
let editDescription = $state('');
let editStatus = $state<QuestionStatus>('open');
let editPriority = $state<QuestionPriority>('normal');
let editResearchDepth = $state<ResearchDepth>('standard');
let focused = $state(false);
$effect(() => {
questionId;
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalQuestion>('questions').get(questionId)).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() {
focused = false;
await db.table('questions').update(questionId, {
title: editTitle.trim() || question?.title || 'Ohne Titel',
description: editDescription.trim() || undefined,
status: editStatus,
priority: editPriority,
researchDepth: editResearchDepth,
updatedAt: new Date().toISOString(),
});
}
async function handleSelectChange() {
await db.table('questions').update(questionId, {
status: editStatus,
priority: editPriority,
researchDepth: editResearchDepth,
updatedAt: new Date().toISOString(),
});
}
async function deleteQuestion() {
await db.table('questions').update(questionId, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
goBack();
}
const statusLabels: Record<QuestionStatus, string> = {
open: 'Offen',
researching: 'Recherche',
answered: 'Beantwortet',
archived: 'Archiviert',
};
const priorityLabels: Record<QuestionPriority, string> = {
low: 'Niedrig',
normal: 'Normal',
high: 'Hoch',
urgent: 'Dringend',
};
const priorityColors: Record<QuestionPriority, string> = {
low: '#9ca3af',
normal: '#3b82f6',
high: '#f59e0b',
urgent: '#ef4444',
};
const depthLabels: Record<ResearchDepth, string> = {
quick: 'Schnell',
standard: 'Standard',
deep: 'Tiefgehend',
};
</script>
<div class="detail-view">
{#if !question}
<p class="empty">Frage nicht gefunden</p>
{:else}
<!-- Title -->
<input
class="title-input"
bind:value={editTitle}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Titel..."
/>
<!-- Properties -->
<div class="properties">
<div class="prop-row">
<span class="prop-label">Status</span>
<select class="prop-select" bind:value={editStatus} onchange={handleSelectChange}>
{#each ['open', 'researching', 'answered', 'archived'] as const as s}
<option value={s}>{statusLabels[s]}</option>
{/each}
</select>
</div>
<div class="prop-row">
<span class="prop-label">Priorität</span>
<select
class="prop-select"
bind:value={editPriority}
onchange={handleSelectChange}
style="color: {priorityColors[editPriority]}"
>
{#each ['low', 'normal', 'high', 'urgent'] as const as p}
<option value={p}>{priorityLabels[p]}</option>
{/each}
</select>
</div>
<div class="prop-row">
<span class="prop-label">Recherchetiefe</span>
<select class="prop-select" bind:value={editResearchDepth} onchange={handleSelectChange}>
{#each ['quick', 'standard', 'deep'] as const as d}
<option value={d}>{depthLabels[d]}</option>
{/each}
</select>
</div>
</div>
<!-- Description -->
<div class="section">
<span class="section-label">Beschreibung</span>
<textarea
class="description-input"
bind:value={editDescription}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Beschreibung hinzufügen..."
rows={3}
></textarea>
</div>
<!-- Tags -->
{#if question.tags.length > 0}
<div class="section">
<span class="section-label">Tags</span>
<div class="tag-list">
{#each question.tags as tag}
<span class="tag">{tag}</span>
{/each}
</div>
</div>
{/if}
<!-- Metadata -->
<div class="meta">
<span>Erstellt: {new Date(question.createdAt ?? '').toLocaleDateString('de')}</span>
{#if question.updatedAt}
<span>Bearbeitet: {new Date(question.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<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>
.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 {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tag {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
background: rgba(0, 0, 0, 0.04);
color: #6b7280;
}
:global(.dark) .tag {
background: rgba(255, 255, 255, 0.06);
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;
}
</style>

View file

@ -5,9 +5,12 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalSkill, LocalActivity } from './types';
import { LEVEL_NAMES, BRANCH_INFO, xpProgress, type SkillBranch } from './types';
let { navigate, goBack, params }: ViewProps = $props();
let skills = $state<LocalSkill[]>([]);
let activities = $state<LocalActivity[]>([]);
@ -52,8 +55,14 @@
{#each skills as skill (skill.id)}
{@const branch = BRANCH_INFO[skill.branch as SkillBranch]}
{@const progress = xpProgress(skill.currentXp, skill.level)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
<button
onclick={() =>
navigate('detail', {
skillId: skill.id,
_siblingIds: skills.map((s) => s.id),
_siblingKey: 'skillId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
@ -71,7 +80,7 @@
<p class="mt-0.5 text-[10px] text-white/30">
{branch?.name ?? skill.branch}{LEVEL_NAMES[skill.level] ?? 'Unbekannt'}
</p>
</div>
</button>
{/each}
{#if skills.length === 0}

View file

@ -0,0 +1,508 @@
<!--
SkillTree — DetailView (inline editable overlay)
Skill details with XP display and quick add XP. Auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { skillStore } from '../stores/skills.svelte';
import { Trash, Lightning } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalSkill, SkillBranch } from '../types';
import { BRANCH_INFO, LEVEL_NAMES, xpProgress, xpForNextLevel } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let skillId = $derived(params.skillId as string);
let skill = $state<LocalSkill | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editName = $state('');
let editDescription = $state('');
let editBranch = $state<SkillBranch>('custom');
let editIcon = $state('');
let editColor = $state('');
// Add XP
let addXpAmount = $state(10);
let addXpDescription = $state('');
let levelUpMessage = $state('');
let focused = $state(false);
$effect(() => {
skillId;
confirmDelete = false;
focused = false;
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() {
focused = false;
await skillStore.updateSkill(skillId, {
name: editName.trim() || skill?.name || 'Unbenannt',
description: editDescription.trim() || '',
branch: editBranch,
icon: editIcon.trim() || 'star',
color: editColor.trim() || null,
});
}
async function handleBranchChange() {
await skillStore.updateSkill(skillId, { branch: editBranch });
}
async function handleAddXp() {
if (addXpAmount <= 0) return;
const result = await skillStore.addXp(
skillId,
addXpAmount,
addXpDescription.trim() || 'Manuell hinzugefuegt'
);
addXpDescription = '';
if (result.leveledUp) {
levelUpMessage = `Level Up! ${LEVEL_NAMES[result.newLevel] ?? 'Level ' + result.newLevel}`;
setTimeout(() => (levelUpMessage = ''), 3000);
}
}
async function deleteSkill() {
await skillStore.deleteSkill(skillId);
goBack();
}
</script>
<div class="detail-view">
{#if !skill}
<p class="empty">Skill nicht gefunden</p>
{:else}
<!-- Icon + Title -->
<div class="title-row">
<span class="title-icon">{skill.icon}</span>
<input
class="title-input"
bind:value={editName}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Name..."
/>
</div>
<!-- XP Progress -->
<div class="xp-section">
<div class="xp-header">
<span class="xp-level">Level {skill.level}{LEVEL_NAMES[skill.level] ?? 'Unbekannt'}</span
>
<span class="xp-numbers"
>{skill.currentXp} / {xpForNextLevel(skill.level) === Infinity
? '∞'
: xpForNextLevel(skill.level)} XP</span
>
</div>
<div class="xp-bar">
<div class="xp-fill" style="width: {xpProgress(skill.currentXp, skill.level)}%"></div>
</div>
<span class="xp-total">{skill.totalXp} XP gesamt</span>
</div>
{#if levelUpMessage}
<div class="level-up">{levelUpMessage}</div>
{/if}
<!-- Quick Add XP -->
<div class="section">
<span class="section-label">XP hinzufuegen</span>
<div class="add-xp-row">
<input type="number" class="xp-input" bind:value={addXpAmount} min="1" placeholder="XP" />
<input class="xp-desc-input" bind:value={addXpDescription} placeholder="Beschreibung..." />
<button class="xp-btn" onclick={handleAddXp}>
<Lightning size={14} /> Hinzufuegen
</button>
</div>
</div>
<!-- Properties -->
<div class="properties">
<div class="prop-row">
<span class="prop-label">Branch</span>
<select class="prop-select" bind:value={editBranch} onchange={handleBranchChange}>
{#each Object.entries(BRANCH_INFO) as [key, info]}
<option value={key}>{info.name}</option>
{/each}
</select>
</div>
<div class="prop-row">
<span class="prop-label">Icon</span>
<input
class="prop-input"
bind:value={editIcon}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="z.B. &#11088;"
/>
</div>
<div class="prop-row">
<span class="prop-label">Farbe</span>
<input
class="prop-input"
bind:value={editColor}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="z.B. #D946EF"
/>
</div>
</div>
<!-- Description -->
<div class="section">
<span class="section-label">Beschreibung</span>
<textarea
class="description-input"
bind:value={editDescription}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Beschreibung hinzufuegen..."
rows={3}
></textarea>
</div>
<!-- Metadata -->
<div class="meta">
<span>Erstellt: {new Date(skill.createdAt ?? '').toLocaleDateString('de')}</span>
{#if skill.updatedAt}
<span>Bearbeitet: {new Date(skill.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<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>
.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 {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.xp-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.xp-level {
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .xp-level {
color: #e5e7eb;
}
.xp-numbers {
font-size: 0.75rem;
color: #9ca3af;
}
.xp-bar {
height: 6px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.08);
overflow: hidden;
}
:global(.dark) .xp-bar {
background: rgba(255, 255, 255, 0.1);
}
.xp-fill {
height: 100%;
border-radius: 3px;
background: #8b5cf6;
transition: width 0.3s ease;
}
.xp-total {
font-size: 0.6875rem;
color: #9ca3af;
}
.level-up {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
background: #8b5cf6;
color: white;
font-size: 0.8125rem;
font-weight: 500;
text-align: center;
}
/* Add XP */
.add-xp-row {
display: flex;
gap: 0.375rem;
align-items: center;
}
.xp-input {
width: 4rem;
font-size: 0.8125rem;
padding: 0.25rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
color: #374151;
outline: none;
}
:global(.dark) .xp-input {
color: #e5e7eb;
border-color: rgba(255, 255, 255, 0.1);
}
.xp-desc-input {
flex: 1;
font-size: 0.8125rem;
padding: 0.25rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
color: #374151;
outline: none;
}
.xp-desc-input::placeholder {
color: #c0bfba;
}
:global(.dark) .xp-desc-input {
color: #e5e7eb;
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .xp-desc-input::placeholder {
color: #4b5563;
}
.xp-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
border: none;
background: #8b5cf6;
color: white;
font-size: 0.75rem;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s;
}
.xp-btn:hover {
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;
}
</style>

View file

@ -6,6 +6,9 @@
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalLink, LocalFolder } from './types';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
let { navigate, goBack, params }: ViewProps = $props();
let links = $state<LocalLink[]>([]);
let folders = $state<LocalFolder[]>([]);
@ -58,7 +61,15 @@
<div class="flex-1 overflow-auto">
{#each sorted as link (link.id)}
<div class="mb-1 rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<button
onclick={() =>
navigate('detail', {
linkId: link.id,
_siblingIds: sorted.map((l) => l.id),
_siblingKey: 'linkId',
})}
class="mb-1 w-full text-left rounded-md px-3 py-2 transition-colors hover:bg-white/5 cursor-pointer"
>
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium text-white/80">
{link.title || link.shortCode}
@ -69,7 +80,7 @@
{#if link.customCode}
<p class="text-xs text-blue-400/60">/{link.customCode}</p>
{/if}
</div>
</button>
{/each}
{#if sorted.length === 0}

View file

@ -0,0 +1,397 @@
<!--
uLoad — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalLink } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let linkId = $derived(params.linkId as string);
let link = $state<LocalLink | null>(null);
let confirmDelete = $state(false);
// Edit fields
let editTitle = $state('');
let editOriginalUrl = $state('');
let editCustomCode = $state('');
let editDescription = $state('');
let editIsActive = $state(true);
let editExpiresAt = $state('');
let focused = $state(false);
$effect(() => {
linkId;
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalLink>('links').get(linkId)).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() {
focused = false;
await db.table('links').update(linkId, {
title: editTitle.trim() || undefined,
originalUrl: editOriginalUrl.trim() || link?.originalUrl || '',
customCode: editCustomCode.trim() || undefined,
description: editDescription.trim() || undefined,
isActive: editIsActive,
expiresAt: editExpiresAt ? new Date(editExpiresAt).toISOString() : null,
updatedAt: new Date().toISOString(),
});
}
async function handleActiveToggle() {
await db.table('links').update(linkId, {
isActive: editIsActive,
updatedAt: new Date().toISOString(),
});
}
async function deleteLink() {
await db.table('links').update(linkId, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
goBack();
}
</script>
<div class="detail-view">
{#if !link}
<p class="empty">Link nicht gefunden</p>
{:else}
<!-- Title -->
<input
class="title-input"
bind:value={editTitle}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Titel..."
/>
<!-- Properties -->
<div class="properties">
<div class="prop-row">
<span class="prop-label">URL</span>
<input
class="prop-input url-input"
bind:value={editOriginalUrl}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="https://..."
/>
</div>
<div class="prop-row">
<span class="prop-label">Kurzcode</span>
<input
class="prop-input"
bind:value={editCustomCode}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="custom-code"
/>
</div>
{#if link.shortCode}
<div class="prop-row">
<span class="prop-label">Short Code</span>
<span class="prop-value">{link.shortCode}</span>
</div>
{/if}
<div class="prop-row">
<span class="prop-label">Aktiv</span>
<label class="toggle-label">
<input
type="checkbox"
class="toggle-input"
bind:checked={editIsActive}
onchange={handleActiveToggle}
/>
<span class="toggle-text">{editIsActive ? 'Ja' : 'Nein'}</span>
</label>
</div>
<div class="prop-row">
<span class="prop-label">Klicks</span>
<span class="prop-value">{link.clickCount}</span>
</div>
<div class="prop-row">
<span class="prop-label">Ablaufdatum</span>
<input
type="date"
class="prop-input"
bind:value={editExpiresAt}
onfocus={() => (focused = true)}
onblur={saveField}
/>
</div>
</div>
<!-- Description -->
<div class="section">
<span class="section-label">Beschreibung</span>
<textarea
class="description-input"
bind:value={editDescription}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Beschreibung hinzufügen..."
rows={3}
></textarea>
</div>
<!-- Metadata -->
<div class="meta">
<span>Erstellt: {new Date(link.createdAt ?? '').toLocaleDateString('de')}</span>
{#if link.updatedAt}
<span>Bearbeitet: {new Date(link.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<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;
}
</style>