refactor(picture): unify ListView for carousel + route contexts

Merges the feature-rich gallery (search, tag filters, favorites toggle,
view-mode toggles, detail modal) that previously lived in
routes/(app)/picture/+page.svelte INTO modules/picture/ListView.svelte,
and keeps the upload affordances (drag-and-drop, upload button, progress
chips) from the old ListView.

Route shrinks to a 3-liner: <RoutePage appId="picture"><ListView /></RoutePage>.

Responsive behaviour uses CSS container queries (@container inline-size)
on the ListView root. Below ~560px (carousel card width) the search bar,
tag chips and view-mode toggles hide; action-strip buttons drop to
icon-only. Above that breakpoint (route context, ≥~720px up to the
layout's max-w-7xl) everything is visible.

Drag-over handler distinguishes file drags from cross-module drag data
via dataTransfer.types.includes('Files'), so the upload overlay only
appears for real file drops — workbench card-to-card drags pass through
to the wrapping AppPage's dropTarget.

Data source changes from context-based (getContext('allImages')) to
direct Dexie live-queries via ./queries, so the component works in both
the carousel (no layout context) and the route (layout still provides
context for /picture/archive and /picture/board).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 00:38:14 +02:00
parent e21f2145de
commit 2d86c6d429
2 changed files with 479 additions and 399 deletions

View file

@ -1,33 +1,93 @@
<!--
Picture — Workbench ListView
Recent images grid with favorites + inline upload (button + drag-and-drop).
Picture — Unified ListView
Single ListView that serves both the workbench card (homepage carousel,
narrow width) AND the full /picture route (wide width). Layout adapts
via container queries: at ≥ 600px the full toolbar (search, tag
filters, view-mode toggles) shows; below that it collapses to a
minimal strip so the card stays uncluttered.
Data source: direct Dexie hooks from ./queries. Neither carousel nor
route layout needs to inject contexts for this component.
-->
<script lang="ts">
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import { UploadSimple, Check, X } from '@mana/shared-icons';
import {
UploadSimple,
Check,
X,
Heart,
SquaresFour,
Rows,
GridFour,
Plus,
MagnifyingGlass,
Archive,
} from '@mana/shared-icons';
import { imagesStore } from './stores/images.svelte';
import type { LocalImage } from './types';
import { pictureViewStore } from './stores/view.svelte';
import {
useAllImages,
useAllImageTags,
useAllPictureTags,
getFavoriteImages,
getImagesByTags,
} from './queries';
import type { Image, LocalImage } from './types';
const MEDIA_URL = import.meta.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3015';
const imagesQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalImage>('images').toArray();
const visible = all.filter((i) => !i.deletedAt && !i.isArchived);
return decryptRecords('images', visible);
}, [] as LocalImage[]);
// ─── Data (direct Dexie queries — works in carousel + route) ────────
const imagesQuery = useAllImages();
const tagsQuery = useAllPictureTags();
const imageTagsQuery = useAllImageTags();
const images = $derived(imagesQuery.value);
const allImages = $derived(imagesQuery.value);
const allTags = $derived(tagsQuery.value);
const allImageTags = $derived(imageTagsQuery.value);
const sorted = $derived(
[...images].sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')).slice(0, 20)
// ─── Filter state ─────────────────────────────────────────────────
let searchQuery = $state('');
let selectedTagIds = $state<string[]>([]);
const filteredImages = $derived.by(() => {
let result: Image[] = allImages;
if (imagesStore.showFavoritesOnly) result = getFavoriteImages(result);
if (selectedTagIds.length > 0) result = getImagesByTags(result, allImageTags, selectedTagIds);
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter((img) => img.prompt.toLowerCase().includes(q));
}
return result;
});
const favoriteCount = $derived(allImages.filter((i) => i.isFavorite).length);
const gridClass = $derived(
pictureViewStore.viewMode === 'single'
? 'grid-cols-1 max-w-2xl mx-auto'
: pictureViewStore.viewMode === 'grid3'
? 'grid-cols-2 @md:grid-cols-3'
: 'grid-cols-3 @md:grid-cols-4 @lg:grid-cols-5'
);
const favoriteCount = $derived(images.filter((i) => i.isFavorite).length);
function toggleTag(tagId: string) {
selectedTagIds = selectedTagIds.includes(tagId)
? selectedTagIds.filter((id) => id !== tagId)
: [...selectedTagIds, tagId];
}
// ─── Upload State ────────────────────────────────────────
// ─── Detail modal ─────────────────────────────────────────────────
let selectedImage = $state<Image | null>(null);
async function handleToggleFavorite(img: Image) {
await imagesStore.toggleFavorite(img.id);
}
async function handleArchive(img: Image) {
await imagesStore.archiveImage(img.id);
selectedImage = null;
}
// ─── Upload ───────────────────────────────────────────────────────
interface UploadFile {
file: File;
preview: string;
@ -40,24 +100,27 @@
let uploading = $state(false);
let fileInput: HTMLInputElement;
// Distinguish file drags (uploads) from cross-module drags (app-page-wrapper
// handles those). Only show our upload overlay if the drag carries real files.
function isFileDrag(e: DragEvent): boolean {
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
}
function handleDragOver(e: DragEvent) {
if (!isFileDrag(e)) return;
e.preventDefault();
dragActive = true;
}
function handleDragLeave(e: DragEvent) {
if (!isFileDrag(e)) return;
e.preventDefault();
if (e.currentTarget === e.target) dragActive = false;
}
function handleDrop(e: DragEvent) {
if (!isFileDrag(e)) return;
e.preventDefault();
dragActive = false;
if (e.dataTransfer?.files) {
addFiles(Array.from(e.dataTransfer.files));
}
if (e.dataTransfer?.files) addFiles(Array.from(e.dataTransfer.files));
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files) {
@ -65,31 +128,25 @@
input.value = '';
}
}
function addFiles(files: File[]) {
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) return;
const newFiles: UploadFile[] = imageFiles.map((file) => ({
file,
preview: URL.createObjectURL(file),
status: 'pending',
}));
uploadFiles = [...uploadFiles, ...newFiles];
uploadAll();
}
function stripExt(name: string): string {
const i = name.lastIndexOf('.');
return i > 0 ? name.slice(0, i) : name;
}
function extOf(name: string): string | null {
const i = name.lastIndexOf('.');
return i > 0 ? name.slice(i + 1).toLowerCase() : null;
}
async function dimensionsOf(file: File): Promise<{ width: number; height: number } | null> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
@ -105,24 +162,18 @@
img.src = url;
});
}
async function uploadAll() {
if (uploading) return;
uploading = true;
for (let i = 0; i < uploadFiles.length; i++) {
if (uploadFiles[i].status !== 'pending') continue;
uploadFiles[i].status = 'uploading';
const uf = uploadFiles[i];
try {
const dims = await dimensionsOf(uf.file);
const formData = new FormData();
formData.append('file', uf.file);
formData.append('app', 'picture');
const response = await fetch(`${MEDIA_URL}/api/v1/media/upload`, {
method: 'POST',
body: formData,
@ -132,7 +183,6 @@
id: string;
urls: { original: string; thumbnail?: string };
};
const nowIso = new Date().toISOString();
const local: LocalImage = {
id: crypto.randomUUID(),
@ -150,7 +200,6 @@
createdAt: nowIso,
updatedAt: nowIso,
};
await imagesStore.insert(local);
uploadFiles[i].status = 'success';
} catch (e) {
@ -158,9 +207,7 @@
uploadFiles[i].error = e instanceof Error ? e.message : 'Upload failed';
}
}
uploading = false;
setTimeout(() => {
uploadFiles
.filter((f) => f.status === 'success')
@ -168,7 +215,6 @@
uploadFiles = uploadFiles.filter((f) => f.status !== 'success');
}, 1500);
}
function removeUpload(index: number) {
URL.revokeObjectURL(uploadFiles[index].preview);
uploadFiles = uploadFiles.filter((_, i) => i !== index);
@ -176,7 +222,7 @@
</script>
<div
class="picture-list-view"
class="picture-list @container"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
@ -198,11 +244,93 @@
</div>
{/if}
<button class="upload-btn" onclick={() => fileInput?.click()}>
<UploadSimple size={16} />
<span>Bilder hochladen</span>
</button>
<!-- Primary action strip: always visible. Upload + Generate + Favorites + View-mode. -->
<div class="action-strip">
<button
type="button"
class="action-btn action-btn-upload"
onclick={() => fileInput?.click()}
title="Bilder hochladen"
>
<UploadSimple size={14} />
<span class="action-label">Upload</span>
</button>
<a href="/picture/generate" class="action-btn action-btn-primary" title="Neues Bild generieren">
<Plus size={14} />
<span class="action-label">Generieren</span>
</a>
<button
type="button"
onclick={() => imagesStore.toggleFavoritesFilter()}
class="action-btn"
class:action-btn-active={imagesStore.showFavoritesOnly}
title="Nur Favoriten anzeigen"
>
<Heart size={12} weight={imagesStore.showFavoritesOnly ? 'fill' : 'regular'} />
<span class="action-label">Favoriten</span>
{#if favoriteCount > 0}<span class="action-count">{favoriteCount}</span>{/if}
</button>
<div class="action-spacer"></div>
<!-- View-mode toggle (wide-only) -->
<div class="view-mode wide-only">
<button
onclick={() => pictureViewStore.setViewMode('single')}
class="view-btn"
class:active={pictureViewStore.viewMode === 'single'}
title="Liste"
>
<Rows size={12} />
</button>
<button
onclick={() => pictureViewStore.setViewMode('grid3')}
class="view-btn"
class:active={pictureViewStore.viewMode === 'grid3'}
title="Mittel"
>
<GridFour size={12} />
</button>
<button
onclick={() => pictureViewStore.setViewMode('grid5')}
class="view-btn"
class:active={pictureViewStore.viewMode === 'grid5'}
title="Klein"
>
<SquaresFour size={12} />
</button>
</div>
</div>
<!-- Search + tag filters (wide-only — collapses on narrow card) -->
<div class="search-bar wide-only">
<div class="relative flex-1">
<MagnifyingGlass
size={14}
class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
bind:value={searchQuery}
placeholder="Prompts durchsuchen…"
class="w-full rounded-md border border-border bg-background py-1 pl-8 pr-2.5 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
{#each allTags as tag (tag.id)}
<button
onclick={() => toggleTag(tag.id)}
class="tag-chip"
class:tag-chip-active={selectedTagIds.includes(tag.id)}
style={selectedTagIds.includes(tag.id) ? `background-color: ${tag.color || '#6b7280'}` : ''}
>
{tag.name}
</button>
{/each}
</div>
<!-- Upload progress chips -->
{#if uploadFiles.length > 0}
<div class="upload-grid">
{#each uploadFiles as uf, i (uf.preview)}
@ -213,13 +341,9 @@
>
<img src={uf.preview} alt="" />
{#if uf.status === 'uploading'}
<div class="upload-indicator">
<div class="spinner"></div>
</div>
<div class="upload-indicator"><div class="spinner"></div></div>
{:else if uf.status === 'success'}
<div class="upload-indicator success">
<Check size={14} weight="bold" />
</div>
<div class="upload-indicator success"><Check size={14} weight="bold" /></div>
{:else if uf.status === 'error'}
<button class="upload-indicator error" onclick={() => removeUpload(i)} title={uf.error}>
<X size={14} weight="bold" />
@ -230,46 +354,124 @@
</div>
{/if}
<BaseListView
items={sorted}
getKey={(i) => i.id}
emptyTitle="Keine Bilder"
listClass="grid grid-cols-2 sm:grid-cols-3 gap-1.5 content-start"
>
{#snippet header()}
<span class="flex-1">{images.length} Bilder</span>
<span>{favoriteCount} Favoriten</span>
{/snippet}
{#snippet item(image)}
<div class="group relative aspect-square overflow-hidden rounded-md bg-muted/50">
{#if image.publicUrl}
<img
src={image.publicUrl}
alt={image.prompt}
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<div class="flex h-full items-center justify-center text-muted-foreground/60 text-xs">
{image.format ?? 'img'}
</div>
{/if}
{#if image.isFavorite}
<span class="absolute right-1 top-1 text-xs text-warning">&#9733;</span>
<!-- Gallery -->
<div class="gallery">
{#if filteredImages.length === 0}
<div class="empty-state">
<SquaresFour size={48} weight="thin" class="text-muted-foreground/30" />
<h3>{allImages.length === 0 ? 'Noch keine Bilder' : 'Keine Ergebnisse'}</h3>
<p>
{allImages.length === 0
? 'Generiere dein erstes Bild mit KI oder lade welche hoch'
: 'Passe deine Filter an'}
</p>
{#if allImages.length === 0}
<a href="/picture/generate" class="empty-cta">Erstes Bild generieren</a>
{/if}
</div>
{/snippet}
</BaseListView>
{:else}
<div class="grid gap-1.5 {gridClass}">
{#each filteredImages as img (img.id)}
<button onclick={() => (selectedImage = img)} class="thumb" title={img.prompt}>
{#if img.publicUrl}
<img
src={img.publicUrl}
alt={img.prompt}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
{:else}
<div class="thumb-fallback">{img.format ?? 'img'}</div>
{/if}
{#if img.isFavorite}
<Heart size={12} weight="fill" class="thumb-fav" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Detail modal (fixed overlay — stays outside the container-query root) -->
{#if selectedImage}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div
class="relative max-h-[90vh] w-full max-w-4xl overflow-auto rounded-xl border border-border bg-card"
>
<div class="relative flex items-center justify-center bg-black">
{#if selectedImage.publicUrl}
<img
src={selectedImage.publicUrl}
alt={selectedImage.prompt}
class="max-h-[60vh] w-full object-contain"
/>
{:else}
<div class="flex h-64 items-center justify-center">
<SquaresFour size={64} class="text-muted-foreground/30" />
</div>
{/if}
</div>
<div class="p-4">
<p class="text-sm text-foreground">{selectedImage.prompt}</p>
{#if selectedImage.model}
<p class="mt-1 text-xs text-muted-foreground">Modell: {selectedImage.model}</p>
{/if}
{#if selectedImage.width && selectedImage.height}
<p class="text-xs text-muted-foreground">
{selectedImage.width} × {selectedImage.height}
</p>
{/if}
<p class="text-xs text-muted-foreground">
{new Date(selectedImage.createdAt).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
<div class="mt-3 flex gap-2">
<button
onclick={() => selectedImage && handleToggleFavorite(selectedImage)}
class="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-muted transition-colors"
>
<Heart
size={14}
weight={selectedImage.isFavorite ? 'fill' : 'regular'}
class={selectedImage.isFavorite ? 'text-red-500' : 'text-muted-foreground'}
/>
{selectedImage.isFavorite ? 'Entfernen' : 'Favorit'}
</button>
<button
onclick={() => selectedImage && handleArchive(selectedImage)}
class="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-muted transition-colors"
>
<Archive size={14} class="text-muted-foreground" />
Archivieren
</button>
<div class="flex-1"></div>
<button
onclick={() => (selectedImage = null)}
class="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.picture-list-view {
.picture-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
padding: 0.5rem 0.75rem 0.75rem;
position: relative;
container-type: inline-size;
}
.hidden {
@ -278,14 +480,14 @@
.drop-overlay {
position: absolute;
inset: 0;
inset: 0.5rem;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: rgba(99, 102, 241, 0.15);
background: hsl(var(--color-primary) / 0.12);
border: 2px dashed hsl(var(--color-primary));
border-radius: 0.75rem;
color: hsl(var(--color-primary));
@ -294,33 +496,128 @@
pointer-events: none;
}
.upload-btn {
/* Action strip — always visible */
.action-strip {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px dashed hsl(var(--color-border));
gap: 0.375rem;
flex-wrap: wrap;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
}
.action-btn:hover {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-foreground));
}
.action-btn-active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.action-btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.action-btn-primary:hover {
background: hsl(var(--color-primary) / 0.9);
color: hsl(var(--color-primary-foreground));
}
.action-btn-upload {
border-style: dashed;
}
.action-count {
font-size: 0.6875rem;
font-weight: 600;
padding: 0 0.25rem;
border-radius: 9999px;
background: hsl(var(--color-foreground) / 0.08);
}
.action-spacer {
flex: 1;
}
/* View-mode toggle */
.view-mode {
display: inline-flex;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-background));
overflow: hidden;
}
.view-btn {
padding: 0.25rem 0.375rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.upload-btn:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
background: rgba(99, 102, 241, 0.05);
.view-btn:hover {
color: hsl(var(--color-foreground));
}
.view-btn.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
/* Search + tags */
.search-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.375rem;
}
.tag-chip {
border: none;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.tag-chip:hover {
color: hsl(var(--color-foreground));
}
.tag-chip-active {
color: hsl(var(--color-primary-foreground));
}
/* Narrow (card) widths: hide search + view-mode.
The @container query triggers against .picture-list's inline-size.
Below ~560px we collapse to a minimal action strip. */
@container (max-width: 560px) {
.wide-only {
display: none;
}
.action-label {
/* On narrow widths, action buttons shrink to icon-only. */
display: none;
}
}
/* Upload progress chips */
.upload-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 0.25rem;
}
.upload-thumb {
position: relative;
aspect-ratio: 1;
@ -341,38 +638,110 @@
outline: 2px solid hsl(var(--color-error));
outline-offset: -2px;
}
.upload-indicator {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
background: hsl(0 0% 0% / 0.4);
color: white;
border: none;
cursor: default;
}
.upload-indicator.success {
background: rgba(34, 197, 94, 0.5);
background: hsl(var(--color-success) / 0.5);
}
.upload-indicator.error {
background: rgba(239, 68, 68, 0.5);
background: hsl(var(--color-error) / 0.5);
cursor: pointer;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border: 2px solid hsl(0 0% 100% / 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Gallery */
.gallery {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
gap: 0.375rem;
}
.empty-state h3 {
margin: 0.25rem 0 0;
font-size: 0.95rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.empty-state p {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.empty-cta {
margin-top: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.8125rem;
font-weight: 500;
text-decoration: none;
}
.empty-cta:hover {
background: hsl(var(--color-primary) / 0.9);
}
.thumb {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
cursor: pointer;
padding: 0;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.thumb:hover {
border-color: hsl(var(--color-primary) / 0.5);
box-shadow: 0 4px 12px hsl(0 0% 0% / 0.15);
}
.thumb-fallback {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
color: hsl(var(--color-muted-foreground) / 0.6);
font-size: 0.75rem;
background: hsl(var(--color-muted));
}
:global(.thumb-fav) {
position: absolute;
top: 0.25rem;
right: 0.25rem;
color: hsl(var(--color-error));
filter: drop-shadow(0 1px 2px hsl(0 0% 0% / 0.6));
}
</style>

View file

@ -1,301 +1,12 @@
<script lang="ts">
import { getContext } from 'svelte';
import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
import { pictureViewStore } from '$lib/modules/picture/stores/view.svelte';
import { getFavoriteImages, getImagesByTags } from '$lib/modules/picture/queries';
import type { Image, LocalImageTag } from '$lib/modules/picture/types';
import type { Tag } from '@mana/shared-tags';
import {
Heart,
SquaresFour,
Rows,
GridFour,
Plus,
MagnifyingGlass,
Star,
Archive,
} from '@mana/shared-icons';
const allImages: { value: Image[] } = getContext('allImages');
const allPictureTags: { value: Tag[] } = getContext('pictureTags');
const allImageTags: { value: LocalImageTag[] } = getContext('allImageTags');
let searchQuery = $state('');
let selectedTagIds = $state<string[]>([]);
// Derive filtered images reactively
let filteredImages = $derived.by(() => {
let result = allImages.value;
if (imagesStore.showFavoritesOnly) {
result = getFavoriteImages(result);
}
if (selectedTagIds.length > 0) {
result = getImagesByTags(result, allImageTags.value, selectedTagIds);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter((img) => img.prompt.toLowerCase().includes(q));
}
return result;
});
function toggleTag(tagId: string) {
if (selectedTagIds.includes(tagId)) {
selectedTagIds = selectedTagIds.filter((id) => id !== tagId);
} else {
selectedTagIds = [...selectedTagIds, tagId];
}
}
// Grid columns based on view mode
let gridClass = $derived(
pictureViewStore.viewMode === 'single'
? 'grid-cols-1 max-w-2xl mx-auto'
: pictureViewStore.viewMode === 'grid3'
? 'grid-cols-2 sm:grid-cols-3'
: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5'
);
let selectedImage = $state<Image | null>(null);
async function handleToggleFavorite(img: Image) {
await imagesStore.toggleFavorite(img.id);
}
async function handleArchive(img: Image) {
await imagesStore.archiveImage(img.id);
selectedImage = null;
}
import ListView from '$lib/modules/picture/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Galerie - Picture - Mana</title>
<title>Picture - Mana</title>
</svelte:head>
<div class="flex h-full flex-col">
<!-- Header -->
<header class="border-b border-border px-4 py-3">
<div class="flex items-center justify-between mb-3">
<h1 class="text-lg font-semibold text-foreground">Galerie</h1>
<div class="flex items-center gap-2">
<!-- View Mode -->
<div class="flex rounded-lg border border-border bg-card">
<button
onclick={() => pictureViewStore.setViewMode('single')}
class="p-1.5 {pictureViewStore.viewMode === 'single'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'} rounded-l-lg transition-colors"
title="Liste"
>
<Rows size={16} />
</button>
<button
onclick={() => pictureViewStore.setViewMode('grid3')}
class="p-1.5 {pictureViewStore.viewMode === 'grid3'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'} transition-colors"
title="Mittel"
>
<GridFour size={16} />
</button>
<button
onclick={() => pictureViewStore.setViewMode('grid5')}
class="p-1.5 {pictureViewStore.viewMode === 'grid5'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'} rounded-r-lg transition-colors"
title="Klein"
>
<SquaresFour size={16} />
</button>
</div>
<a
href="/picture/generate"
class="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Generieren
</a>
</div>
</div>
<!-- Search & Filters -->
<div class="flex flex-wrap items-center gap-2">
<div class="relative flex-1 min-w-[200px]">
<MagnifyingGlass
size={16}
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
bind:value={searchQuery}
placeholder="Prompts durchsuchen..."
class="w-full rounded-lg border border-border bg-background py-1.5 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
<button
onclick={() => imagesStore.toggleFavoritesFilter()}
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors {imagesStore.showFavoritesOnly
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'}"
>
<Heart size={14} weight={imagesStore.showFavoritesOnly ? 'fill' : 'regular'} />
Favoriten
</button>
{#each allPictureTags.value as tag (tag.id)}
<button
onclick={() => toggleTag(tag.id)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {selectedTagIds.includes(
tag.id
)
? 'text-white'
: 'bg-muted text-muted-foreground hover:text-foreground'}"
style={selectedTagIds.includes(tag.id)
? `background-color: ${tag.color || '#6b7280'}`
: ''}
>
{tag.name}
</button>
{/each}
</div>
</header>
<!-- Gallery Grid -->
<div class="flex-1 overflow-auto p-4">
{#if filteredImages.length === 0}
<div class="flex flex-col items-center justify-center py-20">
<SquaresFour size={64} weight="thin" class="text-muted-foreground/30" />
<h3 class="mt-4 text-lg font-semibold text-foreground">
{allImages.value.length === 0 ? 'Noch keine Bilder' : 'Keine Ergebnisse'}
</h3>
<p class="mt-1 text-sm text-muted-foreground">
{allImages.value.length === 0
? 'Generiere dein erstes Bild mit KI'
: 'Passe deine Filter an'}
</p>
{#if allImages.value.length === 0}
<a
href="/picture/generate"
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Erstes Bild generieren
</a>
{/if}
</div>
{:else}
<div class="grid gap-3 {gridClass}">
{#each filteredImages as img (img.id)}
<button
onclick={() => (selectedImage = img)}
class="group relative overflow-hidden rounded-lg border border-border bg-card transition-[border-color,box-shadow] hover:shadow-lg hover:border-primary/50"
>
{#if img.publicUrl}
<img
src={img.publicUrl}
alt={img.prompt}
class="aspect-square w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
{:else}
<div class="flex aspect-square items-center justify-center bg-muted">
<SquaresFour size={32} class="text-muted-foreground/30" />
</div>
{/if}
<!-- Overlay on hover -->
<div
class="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity p-2"
>
<p class="text-xs text-white line-clamp-2">{img.prompt}</p>
</div>
<!-- Favorite indicator -->
{#if img.isFavorite}
<div class="absolute top-1.5 right-1.5">
<Heart size={16} weight="fill" class="text-red-500 drop-shadow" />
</div>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Image Detail Modal -->
{#if selectedImage}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div
class="relative max-h-[90vh] max-w-4xl w-full overflow-auto rounded-xl border border-border bg-card"
>
<!-- Image -->
<div class="relative bg-black flex items-center justify-center">
{#if selectedImage.publicUrl}
<img
src={selectedImage.publicUrl}
alt={selectedImage.prompt}
class="max-h-[60vh] w-full object-contain"
/>
{:else}
<div class="flex h-64 items-center justify-center">
<SquaresFour size={64} class="text-muted-foreground/30" />
</div>
{/if}
</div>
<!-- Info -->
<div class="p-4">
<p class="text-sm text-foreground">{selectedImage.prompt}</p>
{#if selectedImage.model}
<p class="mt-1 text-xs text-muted-foreground">Modell: {selectedImage.model}</p>
{/if}
{#if selectedImage.width && selectedImage.height}
<p class="text-xs text-muted-foreground">
{selectedImage.width} x {selectedImage.height}
</p>
{/if}
<p class="text-xs text-muted-foreground">
{new Date(selectedImage.createdAt).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
<div class="mt-3 flex gap-2">
<button
onclick={() => selectedImage && handleToggleFavorite(selectedImage)}
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium border border-border hover:bg-muted transition-colors"
>
<Heart
size={16}
weight={selectedImage.isFavorite ? 'fill' : 'regular'}
class={selectedImage.isFavorite ? 'text-red-500' : 'text-muted-foreground'}
/>
{selectedImage.isFavorite ? 'Entfernen' : 'Favorit'}
</button>
<button
onclick={() => selectedImage && handleArchive(selectedImage)}
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium border border-border hover:bg-muted transition-colors"
>
<Archive size={16} class="text-muted-foreground" />
Archivieren
</button>
<div class="flex-1"></div>
<button
onclick={() => (selectedImage = null)}
class="rounded-lg border border-border px-4 py-1.5 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
</div>
{/if}
<RoutePage appId="picture">
<ListView />
</RoutePage>