mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
a08f1501f2
commit
650dea5e1d
17 changed files with 3361 additions and 24 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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. 📦"
|
||||
/>
|
||||
</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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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. ⭐"
|
||||
/>
|
||||
</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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue