mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
e21f2145de
commit
2d86c6d429
2 changed files with 479 additions and 399 deletions
|
|
@ -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">★</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue