mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(manacore/web): add overlay detail views for cards, storage, presi
Add inline-editable DetailViews with auto-save for: - cards: deck details with color, description, public toggle - storage: file details with rename, favorite, size/type info - presi: presentation deck details with slide count Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
650dea5e1d
commit
4cb1bda852
7 changed files with 1041 additions and 9 deletions
|
|
@ -75,6 +75,10 @@ export const APP_REGISTRY: AppEntry[] = [
|
|||
name: 'Cards',
|
||||
color: '#EF4444',
|
||||
load: () => import('$lib/modules/cards/AppView.svelte'),
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/cards/AppView.svelte') },
|
||||
detail: { load: () => import('$lib/modules/cards/views/DetailView.svelte') },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'picture',
|
||||
|
|
@ -103,6 +107,10 @@ export const APP_REGISTRY: AppEntry[] = [
|
|||
name: 'Storage',
|
||||
color: '#6B7280',
|
||||
load: () => import('$lib/modules/storage/AppView.svelte'),
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/storage/AppView.svelte') },
|
||||
detail: { load: () => import('$lib/modules/storage/views/DetailView.svelte') },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'nutriphi',
|
||||
|
|
@ -125,6 +133,10 @@ export const APP_REGISTRY: AppEntry[] = [
|
|||
name: 'Presi',
|
||||
color: '#A855F7',
|
||||
load: () => import('$lib/modules/presi/AppView.svelte'),
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/presi/AppView.svelte') },
|
||||
detail: { load: () => import('$lib/modules/presi/views/DetailView.svelte') },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inventar',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalDeck, LocalCard } from './types';
|
||||
import type { ViewProps } from '$lib/components/workbench/nav-stack';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
let decks = $state<LocalDeck[]>([]);
|
||||
let cards = $state<LocalCard[]>([]);
|
||||
|
|
@ -52,8 +55,14 @@
|
|||
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#each decks as deck (deck.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', {
|
||||
deckId: deck.id,
|
||||
_siblingIds: decks.map((d) => d.id),
|
||||
_siblingKey: 'deckId',
|
||||
})}
|
||||
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">
|
||||
<div class="h-3 w-3 rounded" style="background: {deck.color}"></div>
|
||||
|
|
@ -63,7 +72,7 @@
|
|||
{#if deck.description}
|
||||
<p class="mt-1 truncate text-xs text-white/40">{deck.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if decks.length === 0}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,376 @@
|
|||
<!--
|
||||
Cards — 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 { deckStore } from '../stores/decks.svelte';
|
||||
import { Trash } from '@manacore/shared-icons';
|
||||
import type { ViewProps } from '$lib/components/workbench/nav-stack';
|
||||
import type { LocalDeck, LocalCard } from '../types';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
let deckId = $derived(params.deckId as string);
|
||||
|
||||
let deck = $state<LocalDeck | null>(null);
|
||||
let cardCount = $state(0);
|
||||
let confirmDelete = $state(false);
|
||||
|
||||
// Edit fields
|
||||
let editName = $state('');
|
||||
let editDescription = $state('');
|
||||
let editColor = $state('#6366f1');
|
||||
let editIsPublic = $state(false);
|
||||
|
||||
let focused = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
deckId;
|
||||
confirmDelete = false;
|
||||
focused = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(() => db.table<LocalDeck>('decks').get(deckId)).subscribe((val) => {
|
||||
deck = val ?? null;
|
||||
if (val && !focused) {
|
||||
editName = val.name;
|
||||
editDescription = val.description ?? '';
|
||||
editColor = val.color ?? '#6366f1';
|
||||
editIsPublic = val.isPublic;
|
||||
}
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
return db
|
||||
.table<LocalCard>('cards')
|
||||
.where('deckId')
|
||||
.equals(deckId)
|
||||
.filter((c) => !c.deletedAt)
|
||||
.count();
|
||||
}).subscribe((val) => {
|
||||
cardCount = val ?? 0;
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
async function saveField() {
|
||||
focused = false;
|
||||
await deckStore.updateDeck(deckId, {
|
||||
title: editName.trim() || deck?.name || 'Unbenannt',
|
||||
description: editDescription.trim() || undefined,
|
||||
isPublic: editIsPublic,
|
||||
});
|
||||
// Color is not in UpdateDeckInput, update directly
|
||||
await db.table('decks').update(deckId, {
|
||||
color: editColor,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePublicToggle() {
|
||||
editIsPublic = !editIsPublic;
|
||||
await deckStore.updateDeck(deckId, { isPublic: editIsPublic });
|
||||
}
|
||||
|
||||
async function deleteDeck() {
|
||||
await deckStore.deleteDeck(deckId);
|
||||
goBack();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="detail-view">
|
||||
{#if !deck}
|
||||
<p class="empty">Deck nicht gefunden</p>
|
||||
{:else}
|
||||
<!-- Name -->
|
||||
<input
|
||||
class="title-input"
|
||||
bind:value={editName}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
placeholder="Deck-Name..."
|
||||
/>
|
||||
|
||||
<!-- Properties -->
|
||||
<div class="properties">
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Farbe</span>
|
||||
<input
|
||||
type="color"
|
||||
class="color-input"
|
||||
bind:value={editColor}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Öffentlich</span>
|
||||
<button class="toggle-btn" class:active={editIsPublic} onclick={handlePublicToggle}>
|
||||
{editIsPublic ? 'Ja' : 'Nein'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Karten</span>
|
||||
<span class="prop-value">{cardCount}</span>
|
||||
</div>
|
||||
|
||||
{#if deck.lastStudied}
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Zuletzt gelernt</span>
|
||||
<span class="prop-value">{new Date(deck.lastStudied).toLocaleDateString('de')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</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(deck.createdAt ?? '').toLocaleDateString('de')}</span>
|
||||
{#if deck.updatedAt}
|
||||
<span>Bearbeitet: {new Date(deck.updatedAt).toLocaleDateString('de')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<div class="danger-zone">
|
||||
{#if confirmDelete}
|
||||
<p class="confirm-text">Deck wirklich löschen?</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="action-btn danger" onclick={deleteDeck}>Löschen</button>
|
||||
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
|
||||
<Trash size={14} /> Löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.detail-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.empty {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.title-input {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
color: #374151;
|
||||
padding: 0.125rem 0;
|
||||
border-radius: 0.25rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.title-input:hover,
|
||||
.title-input:focus {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
:global(.dark) .title-input {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
:global(.dark) .title-input:hover,
|
||||
:global(.dark) .title-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Properties */
|
||||
.properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.prop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.prop-label {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.prop-value {
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .prop-value {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.color-input {
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
.color-input:hover {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
:global(.dark) .color-input:hover {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.toggle-btn {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.toggle-btn.active {
|
||||
color: #22c55e;
|
||||
}
|
||||
:global(.dark) .toggle-btn {
|
||||
color: #6b7280;
|
||||
}
|
||||
:global(.dark) .toggle-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
:global(.dark) .toggle-btn.active {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.description-input {
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
padding: 0.5rem;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.description-input:hover,
|
||||
.description-input:focus {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.description-input::placeholder {
|
||||
color: #c0bfba;
|
||||
}
|
||||
:global(.dark) .description-input {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
:global(.dark) .description-input:hover,
|
||||
:global(.dark) .description-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
:global(.dark) .description-input::placeholder {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* Meta & actions */
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
:global(.dark) .meta {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.danger-zone {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.confirm-text {
|
||||
font-size: 0.8125rem;
|
||||
color: #ef4444;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #374151;
|
||||
}
|
||||
.action-btn.danger {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.action-btn.danger-subtle {
|
||||
color: #ef4444;
|
||||
border-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
:global(.dark) .action-btn {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,6 +6,9 @@
|
|||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalDeck, LocalSlide } from './types';
|
||||
import type { ViewProps } from '$lib/components/workbench/nav-stack';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
let decks = $state<LocalDeck[]>([]);
|
||||
let slides = $state<LocalSlide[]>([]);
|
||||
|
|
@ -44,8 +47,14 @@
|
|||
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#each decks as deck (deck.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', {
|
||||
deckId: deck.id,
|
||||
_siblingIds: decks.map((d) => d.id),
|
||||
_siblingKey: 'deckId',
|
||||
})}
|
||||
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5"
|
||||
>
|
||||
<p class="truncate text-sm font-medium text-white/80">{deck.title}</p>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-white/40">
|
||||
|
|
@ -57,7 +66,7 @@
|
|||
{#if deck.description}
|
||||
<p class="mt-1 truncate text-xs text-white/30">{deck.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if decks.length === 0}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,336 @@
|
|||
<!--
|
||||
Presi — DetailView (inline editable overlay)
|
||||
Presentation deck details. All fields auto-save on blur.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decksStore } from '../stores/decks.svelte';
|
||||
import { Trash } from '@manacore/shared-icons';
|
||||
import type { ViewProps } from '$lib/components/workbench/nav-stack';
|
||||
import type { LocalDeck, LocalSlide } from '../types';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
let deckId = $derived(params.deckId as string);
|
||||
|
||||
let deck = $state<LocalDeck | null>(null);
|
||||
let slideCount = $state(0);
|
||||
let confirmDelete = $state(false);
|
||||
|
||||
// Edit fields
|
||||
let editTitle = $state('');
|
||||
let editDescription = $state('');
|
||||
let editIsPublic = $state(false);
|
||||
|
||||
let focused = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
deckId;
|
||||
confirmDelete = false;
|
||||
focused = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(() => db.table<LocalDeck>('presiDecks').get(deckId)).subscribe((val) => {
|
||||
deck = val ?? null;
|
||||
if (val && !focused) {
|
||||
editTitle = val.title;
|
||||
editDescription = val.description ?? '';
|
||||
editIsPublic = val.isPublic;
|
||||
}
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
return db
|
||||
.table<LocalSlide>('slides')
|
||||
.where('deckId')
|
||||
.equals(deckId)
|
||||
.filter((s) => !s.deletedAt)
|
||||
.count();
|
||||
}).subscribe((val) => {
|
||||
slideCount = val ?? 0;
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
async function saveField() {
|
||||
focused = false;
|
||||
await decksStore.updateDeck(deckId, {
|
||||
title: editTitle.trim() || deck?.title || 'Unbenannt',
|
||||
description: editDescription.trim() || undefined,
|
||||
isPublic: editIsPublic,
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePublicToggle() {
|
||||
editIsPublic = !editIsPublic;
|
||||
await decksStore.updateDeck(deckId, { isPublic: editIsPublic });
|
||||
}
|
||||
|
||||
async function deleteDeck() {
|
||||
await decksStore.deleteDeck(deckId);
|
||||
goBack();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="detail-view">
|
||||
{#if !deck}
|
||||
<p class="empty">Präsentation 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">Öffentlich</span>
|
||||
<button class="toggle-btn" class:active={editIsPublic} onclick={handlePublicToggle}>
|
||||
{editIsPublic ? 'Ja' : 'Nein'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Folien</span>
|
||||
<span class="prop-value">{slideCount}</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 hinzufügen..."
|
||||
rows={3}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="meta">
|
||||
<span>Erstellt: {new Date(deck.createdAt ?? '').toLocaleDateString('de')}</span>
|
||||
{#if deck.updatedAt}
|
||||
<span>Bearbeitet: {new Date(deck.updatedAt).toLocaleDateString('de')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<div class="danger-zone">
|
||||
{#if confirmDelete}
|
||||
<p class="confirm-text">Präsentation wirklich löschen?</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="action-btn danger" onclick={deleteDeck}>Löschen</button>
|
||||
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
|
||||
<Trash size={14} /> Löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.detail-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.empty {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.title-input {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
color: #374151;
|
||||
padding: 0.125rem 0;
|
||||
border-radius: 0.25rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.title-input:hover,
|
||||
.title-input:focus {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
:global(.dark) .title-input {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
:global(.dark) .title-input:hover,
|
||||
:global(.dark) .title-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Properties */
|
||||
.properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.prop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.prop-label {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.prop-value {
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .prop-value {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.toggle-btn {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.toggle-btn.active {
|
||||
color: #22c55e;
|
||||
}
|
||||
:global(.dark) .toggle-btn {
|
||||
color: #6b7280;
|
||||
}
|
||||
:global(.dark) .toggle-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
:global(.dark) .toggle-btn.active {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.description-input {
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
padding: 0.5rem;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.description-input:hover,
|
||||
.description-input:focus {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.description-input::placeholder {
|
||||
color: #c0bfba;
|
||||
}
|
||||
:global(.dark) .description-input {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
:global(.dark) .description-input:hover,
|
||||
:global(.dark) .description-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
:global(.dark) .description-input::placeholder {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* Meta & actions */
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
:global(.dark) .meta {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.danger-zone {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.confirm-text {
|
||||
font-size: 0.8125rem;
|
||||
color: #ef4444;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #374151;
|
||||
}
|
||||
.action-btn.danger {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.action-btn.danger-subtle {
|
||||
color: #ef4444;
|
||||
border-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
:global(.dark) .action-btn {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,6 +6,9 @@
|
|||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalFile, LocalFolder } from './types';
|
||||
import type { ViewProps } from '$lib/components/workbench/nav-stack';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
let files = $state<LocalFile[]>([]);
|
||||
let folders = $state<LocalFolder[]>([]);
|
||||
|
|
@ -76,13 +79,19 @@
|
|||
<!-- Recent files -->
|
||||
<h3 class="mb-2 mt-3 text-xs font-medium text-white/50">Zuletzt</h3>
|
||||
{#each recentFiles as file (file.id)}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
|
||||
<button
|
||||
onclick={() =>
|
||||
navigate('detail', {
|
||||
fileId: file.id,
|
||||
_siblingIds: recentFiles.map((f) => f.id),
|
||||
_siblingKey: 'fileId',
|
||||
})}
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-white/5"
|
||||
>
|
||||
<span class="text-sm">{@html fileIcon(file.mimeType)}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-white/70">{file.name}</span>
|
||||
<span class="shrink-0 text-xs text-white/30">{formatSize(file.size)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if recentFiles.length === 0}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,281 @@
|
|||
<!--
|
||||
Storage — DetailView (inline editable overlay)
|
||||
File details with editable name, favorite toggle. Auto-save on blur.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { filesStore } from '../stores/files.svelte';
|
||||
import { Heart, Trash } from '@manacore/shared-icons';
|
||||
import type { ViewProps } from '$lib/components/workbench/nav-stack';
|
||||
import type { LocalFile } from '../types';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
let fileId = $derived(params.fileId as string);
|
||||
|
||||
let file = $state<LocalFile | null>(null);
|
||||
let confirmDelete = $state(false);
|
||||
|
||||
// Edit fields
|
||||
let editName = $state('');
|
||||
|
||||
let focused = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
fileId;
|
||||
confirmDelete = false;
|
||||
focused = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(() => db.table<LocalFile>('files').get(fileId)).subscribe((val) => {
|
||||
file = val ?? null;
|
||||
if (val && !focused) {
|
||||
editName = val.name;
|
||||
}
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
async function saveField() {
|
||||
focused = false;
|
||||
const name = editName.trim() || file?.name || 'Unbenannt';
|
||||
await filesStore.renameFile(fileId, name);
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
await filesStore.toggleFileFavorite(fileId);
|
||||
}
|
||||
|
||||
async function deleteFile() {
|
||||
await filesStore.deleteFile(fileId);
|
||||
goBack();
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="detail-view">
|
||||
{#if !file}
|
||||
<p class="empty">Datei nicht gefunden</p>
|
||||
{:else}
|
||||
<!-- Name -->
|
||||
<input
|
||||
class="title-input"
|
||||
bind:value={editName}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={saveField}
|
||||
placeholder="Dateiname..."
|
||||
/>
|
||||
|
||||
<!-- Properties -->
|
||||
<div class="properties">
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Originalname</span>
|
||||
<span class="prop-value">{file.originalName}</span>
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Typ</span>
|
||||
<span class="prop-value">{file.mimeType}</span>
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Größe</span>
|
||||
<span class="prop-value">{formatSize(file.size)}</span>
|
||||
</div>
|
||||
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Favorit</span>
|
||||
<button class="fav-btn" class:active={file.isFavorite} onclick={toggleFavorite}>
|
||||
<Heart size={16} weight={file.isFavorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if file.checksum}
|
||||
<div class="prop-row">
|
||||
<span class="prop-label">Prüfsumme</span>
|
||||
<span class="prop-value mono">{file.checksum.slice(0, 16)}...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="meta">
|
||||
<span>Erstellt: {new Date(file.createdAt ?? '').toLocaleDateString('de')}</span>
|
||||
{#if file.updatedAt}
|
||||
<span>Bearbeitet: {new Date(file.updatedAt).toLocaleDateString('de')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete -->
|
||||
<div class="danger-zone">
|
||||
{#if confirmDelete}
|
||||
<p class="confirm-text">Datei wirklich löschen?</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="action-btn danger" onclick={deleteFile}>Löschen</button>
|
||||
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
|
||||
<Trash size={14} /> Löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.detail-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.empty {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.title-input {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
color: #374151;
|
||||
padding: 0.125rem 0;
|
||||
border-radius: 0.25rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.title-input:hover,
|
||||
.title-input:focus {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
:global(.dark) .title-input {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
:global(.dark) .title-input:hover,
|
||||
:global(.dark) .title-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Properties */
|
||||
.properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.prop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.prop-label {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.prop-value {
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
max-width: 60%;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prop-value.mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
:global(.dark) .prop-value {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.fav-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0.125rem;
|
||||
color: #9ca3af;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.fav-btn:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
.fav-btn.active {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Meta & actions */
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
:global(.dark) .meta {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.danger-zone {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.confirm-text {
|
||||
font-size: 0.8125rem;
|
||||
color: #ef4444;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #374151;
|
||||
}
|
||||
.action-btn.danger {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.action-btn.danger-subtle {
|
||||
color: #ef4444;
|
||||
border-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
:global(.dark) .action-btn {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue