feat: gallery masonry layout, favorites system, left-right detail dialog, bump v0.17.0
Gallery UI overhaul: - GalleryGrid: replace equal-size grid with CSS columns masonry layout (6 columns), pure image cards without text/date info, hover gray ring highlight, favorited items show red heart badge - GalleryDetail: left-right layout dialog (70% image / 30% info), nearly full-screen size with minimal margins, black background for image area, absolute-positioned image container for correct sizing across all aspect ratios, favorite toggle button replaces tag manager, removed outer border - Gallery page: added "favorites only" filter button, replaced load-more button with IntersectionObserver infinite scroll, removed tag system references Favorites system: - db.ts: added `favorited` column to media_generations with migration - New API: PUT /api/media/[id]/favorite for toggle - gallery/route.ts: support favoritesOnly=1 filter, return favorited field - i18n: added favorites-related translations (en/zh) Other: - Default image model changed from Nano Banana Pro to Nano Banana 2 - providers/models/route.ts: skip media provider types in chat model selector Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
5ac4eb5eb6
commit
a5e2cedc12
13 files changed
+284
-262
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDb } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function PUT(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const db = getDb();
|
||||
|
||||
const row = db.prepare('SELECT favorited FROM media_generations WHERE id = ?').get(id) as { favorited: number } | undefined;
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const newValue = row.favorited ? 0 : 1;
|
||||
db.prepare('UPDATE media_generations SET favorited = ? WHERE id = ?').run(newValue, id);
|
||||
|
||||
return NextResponse.json({ favorited: newValue });
|
||||
} catch (error) {
|
||||
console.error('[media/favorite] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to toggle favorite' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface DbRow {
|
||||
local_path: string;
|
||||
tags: string;
|
||||
metadata: string;
|
||||
favorited: number;
|
||||
created_at: string;
|
||||
session_id: string | null;
|
||||
[key: string]: unknown;
|
||||
@@ -56,6 +57,7 @@ function mapRow(row: DbRow) {
|
||||
aspectRatio: row.aspect_ratio,
|
||||
imageSize: row.image_size,
|
||||
tags,
|
||||
favorited: !!row.favorited,
|
||||
created_at: row.created_at,
|
||||
session_id: row.session_id || undefined,
|
||||
referenceImages,
|
||||
@@ -68,6 +70,7 @@ export async function GET(request: NextRequest) {
|
||||
const tags = searchParams.get('tags');
|
||||
const dateFrom = searchParams.get('dateFrom');
|
||||
const dateTo = searchParams.get('dateTo');
|
||||
const favoritesOnly = searchParams.get('favoritesOnly') === '1';
|
||||
const sort = searchParams.get('sort') || 'newest';
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10);
|
||||
@@ -77,6 +80,10 @@ export async function GET(request: NextRequest) {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (favoritesOnly) {
|
||||
conditions.push("mg.favorited = 1");
|
||||
}
|
||||
|
||||
if (tags) {
|
||||
const tagList = tags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
for (const tag of tagList) {
|
||||
|
||||
@@ -84,8 +84,12 @@ export async function GET() {
|
||||
});
|
||||
}
|
||||
|
||||
// Provider types that are not LLMs (e.g. image generation) — skip in chat model selector
|
||||
const MEDIA_PROVIDER_TYPES = new Set(['gemini-image']);
|
||||
|
||||
// Build a group for each configured provider
|
||||
for (const provider of providers) {
|
||||
if (MEDIA_PROVIDER_TYPES.has(provider.provider_type)) continue;
|
||||
const matched = PROVIDER_MODEL_LABELS[provider.base_url];
|
||||
const rawModels = matched || DEFAULT_MODELS;
|
||||
const models = deduplicateModels(rawModels);
|
||||
|
||||
+58
-58
@@ -1,18 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import {
|
||||
PaintBrush01Icon,
|
||||
SortingDownIcon,
|
||||
FilterIcon,
|
||||
Loading02Icon,
|
||||
FavouriteIcon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GalleryGrid, type GalleryItem } from '@/components/gallery/GalleryGrid';
|
||||
import { GalleryDetail } from '@/components/gallery/GalleryDetail';
|
||||
import { TagManager, useTags, type Tag } from '@/components/gallery/TagManager';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { TranslationKey } from '@/i18n';
|
||||
|
||||
@@ -22,7 +22,6 @@ type SortOrder = 'newest' | 'oldest';
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { t } = useTranslation();
|
||||
const { tags, loading: tagsLoading, addTag, removeTag, fetchTags } = useTags();
|
||||
|
||||
const [items, setItems] = useState<GalleryItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -30,11 +29,11 @@ export default function GalleryPage() {
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
// Filters
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [sort, setSort] = useState<SortOrder>('newest');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
||||
|
||||
// Detail
|
||||
const [selectedItem, setSelectedItem] = useState<GalleryItem | null>(null);
|
||||
@@ -44,9 +43,9 @@ export default function GalleryPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedTags.length > 0) params.set('tags', selectedTags.join(','));
|
||||
if (dateFrom) params.set('dateFrom', dateFrom);
|
||||
if (dateTo) params.set('dateTo', dateTo);
|
||||
if (favoritesOnly) params.set('favoritesOnly', '1');
|
||||
params.set('sort', sort);
|
||||
params.set('limit', String(PAGE_SIZE));
|
||||
params.set('offset', reset ? '0' : String(offset));
|
||||
@@ -68,22 +67,14 @@ export default function GalleryPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedTags, dateFrom, dateTo, sort, offset]);
|
||||
}, [dateFrom, dateTo, sort, offset, favoritesOnly]);
|
||||
|
||||
// Initial load and reload on filter changes
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
fetchItems(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTags, dateFrom, dateTo, sort]);
|
||||
|
||||
const handleToggleTag = useCallback((tagId: string) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tagId)
|
||||
? prev.filter((t) => t !== tagId)
|
||||
: [...prev, tagId]
|
||||
);
|
||||
}, []);
|
||||
}, [dateFrom, dateTo, sort, favoritesOnly]);
|
||||
|
||||
const handleSelect = useCallback((item: GalleryItem) => {
|
||||
setSelectedItem(item);
|
||||
@@ -102,22 +93,19 @@ export default function GalleryPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTagsChange = useCallback(async (id: string, newTags: string[]) => {
|
||||
const handleToggleFavorite = useCallback(async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/media/${id}/tags`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tags: newTags }),
|
||||
});
|
||||
const res = await fetch(`/api/media/${id}/favorite`, { method: 'PUT' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const favorited = !!data.favorited;
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, tags: newTags } : item
|
||||
item.id === id ? { ...item, favorited } : item
|
||||
)
|
||||
);
|
||||
// Update selected item too
|
||||
setSelectedItem((prev) =>
|
||||
prev && prev.id === id ? { ...prev, tags: newTags } : prev
|
||||
prev && prev.id === id ? { ...prev, favorited } : prev
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -126,6 +114,29 @@ export default function GalleryPage() {
|
||||
}, []);
|
||||
|
||||
const hasMore = items.length < total;
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
// Infinite scroll via IntersectionObserver
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loadingRef.current) {
|
||||
loadingRef.current = true;
|
||||
fetchItems(false).finally(() => {
|
||||
loadingRef.current = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, fetchItems]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
@@ -136,6 +147,20 @@ export default function GalleryPage() {
|
||||
{t('gallery.title' as TranslationKey)}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Favorites toggle */}
|
||||
<Button
|
||||
variant={favoritesOnly ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setFavoritesOnly((v) => !v)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={FavouriteIcon}
|
||||
className={cn('h-3.5 w-3.5', favoritesOnly && 'text-red-500')}
|
||||
fill={favoritesOnly ? 'currentColor' : 'none'}
|
||||
/>
|
||||
{t('gallery.favoritesOnly' as TranslationKey)}
|
||||
</Button>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<Button
|
||||
variant={showFilters ? 'secondary' : 'ghost'}
|
||||
@@ -163,18 +188,6 @@ export default function GalleryPage() {
|
||||
{/* Filter bar */}
|
||||
{showFilters && (
|
||||
<div className="mt-3 space-y-2.5">
|
||||
{/* Tag filter */}
|
||||
{!tagsLoading && tags.length > 0 && (
|
||||
<div>
|
||||
<TagManager
|
||||
tags={tags}
|
||||
selectedTags={selectedTags}
|
||||
onToggleTag={handleToggleTag}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date range */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
@@ -195,14 +208,13 @@ export default function GalleryPage() {
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
{(dateFrom || dateTo || selectedTags.length > 0) && (
|
||||
{(dateFrom || dateTo) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setDateFrom('');
|
||||
setDateTo('');
|
||||
setSelectedTags([]);
|
||||
}}
|
||||
>
|
||||
{t('gallery.clearFilters' as TranslationKey)}
|
||||
@@ -226,28 +238,17 @@ export default function GalleryPage() {
|
||||
<p className="text-xs opacity-70">{t('gallery.emptyHint' as TranslationKey)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<GalleryGrid
|
||||
items={items}
|
||||
tags={tags}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetchItems(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t('gallery.loadMore' as TranslationKey)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Sentinel for infinite scroll */}
|
||||
<div ref={sentinelRef} className="flex justify-center py-4">
|
||||
{loading && (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -255,11 +256,10 @@ export default function GalleryPage() {
|
||||
{/* Detail dialog */}
|
||||
<GalleryDetail
|
||||
item={selectedItem}
|
||||
tags={tags}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
onDelete={handleDelete}
|
||||
onTagsChange={handleTagsChange}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Message02Icon,
|
||||
ArrowLeft01Icon,
|
||||
ArrowRight01Icon,
|
||||
FavouriteIcon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
import { cn, parseDBDate } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -19,18 +20,16 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { TagManager, type Tag } from './TagManager';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { TranslationKey } from '@/i18n';
|
||||
import type { GalleryItem } from './GalleryGrid';
|
||||
|
||||
interface GalleryDetailProps {
|
||||
item: GalleryItem | null;
|
||||
tags: Tag[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onTagsChange?: (id: string, tags: string[]) => void;
|
||||
onToggleFavorite?: (id: string) => void;
|
||||
}
|
||||
|
||||
function imageUrl(img: GalleryItem['images'][0]): string {
|
||||
@@ -60,11 +59,10 @@ function formatDate(dateStr: string): string {
|
||||
|
||||
export function GalleryDetail({
|
||||
item,
|
||||
tags,
|
||||
open,
|
||||
onOpenChange,
|
||||
onDelete,
|
||||
onTagsChange,
|
||||
onToggleFavorite,
|
||||
}: GalleryDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -113,15 +111,6 @@ export function GalleryDetail({
|
||||
setConfirmDelete(false);
|
||||
}, [item, confirmDelete, onDelete, onOpenChange]);
|
||||
|
||||
const handleToggleTag = useCallback((tagId: string) => {
|
||||
if (!item) return;
|
||||
const current = item.tags || [];
|
||||
const next = current.includes(tagId)
|
||||
? current.filter((t) => t !== tagId)
|
||||
: [...current, tagId];
|
||||
onTagsChange?.(item.id, next);
|
||||
}, [item, onTagsChange]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const currentImage = item.images[currentImageIndex];
|
||||
@@ -129,146 +118,158 @@ export function GalleryDetail({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto p-0 gap-0" showCloseButton>
|
||||
<DialogContent className="max-w-[calc(100vw-4rem)] sm:max-w-[calc(100vw-4rem)] max-h-[calc(100vh-4rem)] w-full h-[calc(100vh-4rem)] overflow-hidden p-0 gap-0 border-0" showCloseButton>
|
||||
<DialogTitle className="sr-only">
|
||||
{t('gallery.imageDetail' as TranslationKey)}
|
||||
</DialogTitle>
|
||||
|
||||
{/* Image preview */}
|
||||
<div className="relative bg-muted/20 flex items-center justify-center min-h-[300px]">
|
||||
{currentImage && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imageUrl(currentImage)}
|
||||
alt={item.prompt}
|
||||
className="max-w-full max-h-[60vh] object-contain"
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasMultipleImages && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentImageIndex((i) => (i > 0 ? i - 1 : item.images.length - 1))}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70 transition"
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentImageIndex((i) => (i < item.images.length - 1 ? i + 1 : 0))}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70 transition"
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-xs text-white">
|
||||
{currentImageIndex + 1} / {item.images.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Prompt */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('gallery.prompt' as TranslationKey)}
|
||||
<div className="flex flex-row h-full">
|
||||
{/* Left: Image preview */}
|
||||
<div className="relative w-[70%] shrink-0 bg-black">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{currentImage && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imageUrl(currentImage)}
|
||||
alt={item.prompt}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground leading-relaxed">{item.prompt}</p>
|
||||
</div>
|
||||
|
||||
{/* Metadata badges */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{item.model && (
|
||||
<Badge variant="secondary" className="text-[10px] gap-1">
|
||||
<HugeiconsIcon icon={PaintBrush01Icon} className="h-3 w-3" />
|
||||
{item.model}
|
||||
</Badge>
|
||||
)}
|
||||
{item.aspectRatio && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{item.aspectRatio}
|
||||
</Badge>
|
||||
)}
|
||||
{item.imageSize && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{item.imageSize}
|
||||
</Badge>
|
||||
{hasMultipleImages && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentImageIndex((i) => (i > 0 ? i - 1 : item.images.length - 1))}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70 transition z-10"
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentImageIndex((i) => (i < item.images.length - 1 ? i + 1 : 0))}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70 transition z-10"
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-xs text-white z-10">
|
||||
{currentImageIndex + 1} / {item.images.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reference images (垫图) */}
|
||||
{item.referenceImages && item.referenceImages.length > 0 && (
|
||||
{/* Right: Info panel */}
|
||||
<div className="flex-1 min-w-0 border-l border-border/50 overflow-y-auto p-6 space-y-5">
|
||||
{/* Favorite button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleFavorite?.(item.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-sm transition-colors',
|
||||
item.favorited
|
||||
? 'text-red-500'
|
||||
: 'text-muted-foreground hover:text-red-500',
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={FavouriteIcon}
|
||||
className="h-5 w-5"
|
||||
fill={item.favorited ? 'currentColor' : 'none'}
|
||||
/>
|
||||
{item.favorited
|
||||
? t('gallery.removeFromFavorites' as TranslationKey)
|
||||
: t('gallery.addToFavorites' as TranslationKey)}
|
||||
</button>
|
||||
|
||||
{/* Prompt */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{t('imageGen.referenceImages' as TranslationKey)}
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('gallery.prompt' as TranslationKey)}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{item.referenceImages.map((ref, i) => {
|
||||
const src = ref.localPath
|
||||
? `/api/media/serve?path=${encodeURIComponent(ref.localPath)}`
|
||||
: '';
|
||||
if (!src) return null;
|
||||
return (
|
||||
<div key={i} className="w-14 h-14 rounded-md border border-border/30 overflow-hidden bg-muted/30">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt={`Reference ${i + 1}`} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-sm text-foreground leading-relaxed">{item.prompt}</p>
|
||||
</div>
|
||||
|
||||
{/* Metadata badges */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{item.model && (
|
||||
<Badge variant="secondary" className="text-[10px] gap-1">
|
||||
<HugeiconsIcon icon={PaintBrush01Icon} className="h-3 w-3" />
|
||||
{item.model}
|
||||
</Badge>
|
||||
)}
|
||||
{item.aspectRatio && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{item.aspectRatio}
|
||||
</Badge>
|
||||
)}
|
||||
{item.imageSize && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{item.imageSize}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reference images */}
|
||||
{item.referenceImages && item.referenceImages.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{t('imageGen.referenceImages' as TranslationKey)}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{item.referenceImages.map((ref, i) => {
|
||||
const src = ref.localPath
|
||||
? `/api/media/serve?path=${encodeURIComponent(ref.localPath)}`
|
||||
: '';
|
||||
if (!src) return null;
|
||||
return (
|
||||
<div key={i} className="w-14 h-14 rounded-md border border-border/30 overflow-hidden bg-muted/30">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt={`Reference ${i + 1}`} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(item.created_at)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{t('gallery.tags' as TranslationKey)}
|
||||
</div>
|
||||
<TagManager
|
||||
tags={tags}
|
||||
selectedTags={item.tags}
|
||||
onToggleTag={handleToggleTag}
|
||||
editable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||
{item.session_id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
router.push(`/chat/${item.session_id}`);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Message02Icon} className="h-3.5 w-3.5" />
|
||||
{t('gallery.openChat' as TranslationKey)}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<HugeiconsIcon icon={Download04Icon} className="h-3.5 w-3.5" />
|
||||
{t('gallery.download' as TranslationKey)}
|
||||
</Button>
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant={confirmDelete ? 'destructive' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="h-3.5 w-3.5" />
|
||||
{confirmDelete
|
||||
? t('gallery.confirmDelete' as TranslationKey)
|
||||
: t('gallery.delete' as TranslationKey)}
|
||||
|
||||
{/* Date */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(item.created_at)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50 flex-wrap">
|
||||
{item.session_id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
router.push(`/chat/${item.session_id}`);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Message02Icon} className="h-3.5 w-3.5" />
|
||||
{t('gallery.openChat' as TranslationKey)}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<HugeiconsIcon icon={Download04Icon} className="h-3.5 w-3.5" />
|
||||
{t('gallery.download' as TranslationKey)}
|
||||
</Button>
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant={confirmDelete ? 'destructive' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="h-3.5 w-3.5" />
|
||||
{confirmDelete
|
||||
? t('gallery.confirmDelete' as TranslationKey)
|
||||
: t('gallery.delete' as TranslationKey)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { PaintBrush01Icon } from '@hugeicons/core-free-icons';
|
||||
import { parseDBDate } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Tag } from './TagManager';
|
||||
import { PaintBrush01Icon, FavouriteIcon } from '@hugeicons/core-free-icons';
|
||||
|
||||
export interface GalleryItem {
|
||||
id: string;
|
||||
@@ -14,6 +11,7 @@ export interface GalleryItem {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
tags: string[];
|
||||
favorited?: boolean;
|
||||
created_at: string;
|
||||
session_id?: string;
|
||||
referenceImages?: Array<{ mimeType: string; localPath: string }>;
|
||||
@@ -21,7 +19,6 @@ export interface GalleryItem {
|
||||
|
||||
interface GalleryGridProps {
|
||||
items: GalleryItem[];
|
||||
tags: Tag[];
|
||||
onSelect: (item: GalleryItem) => void;
|
||||
}
|
||||
|
||||
@@ -37,49 +34,36 @@ function thumbnailUrl(item: GalleryItem): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
try {
|
||||
const date = parseDBDate(dateStr);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export function GalleryGrid({ items, tags, onSelect }: GalleryGridProps) {
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
|
||||
export function GalleryGrid({ items, onSelect }: GalleryGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div
|
||||
className="gap-3"
|
||||
style={{
|
||||
columnCount: 6,
|
||||
columnGap: '12px',
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const url = thumbnailUrl(item);
|
||||
const itemTags = item.tags
|
||||
.map((tid) => tagMap.get(tid))
|
||||
.filter((t): t is Tag => !!t);
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="mb-3 cursor-pointer rounded-lg overflow-hidden ring-0 hover:ring-2 hover:ring-border transition-all"
|
||||
style={{ breakInside: 'avoid' }}
|
||||
onClick={() => onSelect(item)}
|
||||
className="group text-left rounded-lg border border-border/50 bg-card overflow-hidden hover:border-border transition-colors"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="relative aspect-square bg-muted/30 overflow-hidden">
|
||||
<div className="relative bg-muted/30">
|
||||
{url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={url}
|
||||
alt={item.prompt}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
||||
className="block w-full h-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex aspect-square items-center justify-center">
|
||||
<HugeiconsIcon icon={PaintBrush01Icon} className="h-8 w-8 text-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
@@ -88,35 +72,13 @@ export function GalleryGrid({ items, tags, onSelect }: GalleryGridProps) {
|
||||
{item.images.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-2.5 space-y-1.5">
|
||||
<p className="text-xs text-foreground/80 line-clamp-2 leading-relaxed">
|
||||
{item.prompt}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatDate(item.created_at)}
|
||||
{item.favorited && (
|
||||
<span className="absolute top-1.5 left-1.5">
|
||||
<HugeiconsIcon icon={FavouriteIcon} className="h-4 w-4 text-red-500 drop-shadow" fill="currentColor" />
|
||||
</span>
|
||||
{itemTags.length > 0 && (
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
{itemTags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-block h-2 w-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tag.color || '#6b7280' }}
|
||||
title={tag.name}
|
||||
/>
|
||||
))}
|
||||
{itemTags.length > 2 && (
|
||||
<span className="text-[9px] text-muted-foreground">+{itemTags.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -264,7 +264,7 @@ const GEMINI_IMAGE_MODELS = [
|
||||
{ value: 'gemini-2.5-flash-image', label: 'Nano Banana' },
|
||||
];
|
||||
|
||||
const DEFAULT_GEMINI_IMAGE_MODEL = 'gemini-3-pro-image-preview';
|
||||
const DEFAULT_GEMINI_IMAGE_MODEL = 'gemini-3.1-flash-image-preview';
|
||||
|
||||
function getGeminiImageModel(provider: ApiProvider): string {
|
||||
try {
|
||||
|
||||
@@ -412,6 +412,10 @@ const en = {
|
||||
'gallery.imageDetail': 'Image Detail',
|
||||
'gallery.prompt': 'Prompt',
|
||||
'gallery.download': 'Download',
|
||||
'gallery.favorites': 'Favorites',
|
||||
'gallery.favoritesOnly': 'Favorites',
|
||||
'gallery.addToFavorites': 'Add to favorites',
|
||||
'gallery.removeFromFavorites': 'Remove from favorites',
|
||||
|
||||
// ── Provider: Gemini Image ──────────────────────────────────
|
||||
'provider.chatProviders': 'Chat Providers',
|
||||
|
||||
@@ -409,6 +409,10 @@ const zh: Record<TranslationKey, string> = {
|
||||
'gallery.imageDetail': '图片详情',
|
||||
'gallery.prompt': '提示词',
|
||||
'gallery.download': '下载',
|
||||
'gallery.favorites': '收藏',
|
||||
'gallery.favoritesOnly': '只看收藏',
|
||||
'gallery.addToFavorites': '添加到收藏',
|
||||
'gallery.removeFromFavorites': '取消收藏',
|
||||
|
||||
// ── Provider: Gemini Image ──────────────────────────────────
|
||||
'provider.chatProviders': '聊天服务商',
|
||||
|
||||
@@ -124,6 +124,7 @@ function initDb(db: Database.Database): void {
|
||||
message_id TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
favorited INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
@@ -310,6 +311,7 @@ function migrateDb(db: Database.Database): void {
|
||||
message_id TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
favorited INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
@@ -387,6 +389,13 @@ function migrateDb(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_media_context_events_job_id ON media_context_events(job_id);
|
||||
`);
|
||||
|
||||
// Add favorited column to media_generations if missing
|
||||
try {
|
||||
db.exec("ALTER TABLE media_generations ADD COLUMN favorited INTEGER NOT NULL DEFAULT 0");
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Recover stale jobs: mark 'running' jobs as 'paused' after process restart
|
||||
db.exec(`
|
||||
UPDATE media_jobs SET status = 'paused', updated_at = datetime('now')
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function generateSingleImage(params: GenerateSingleImageParams): Pr
|
||||
}
|
||||
|
||||
// Read configured model from extra_env, fall back to default
|
||||
let configuredModel = 'gemini-3-pro-image-preview';
|
||||
let configuredModel = 'gemini-3.1-flash-image-preview';
|
||||
try {
|
||||
const env = JSON.parse(provider.extra_env || '{}');
|
||||
if (env.GEMINI_IMAGE_MODEL) configuredModel = env.GEMINI_IMAGE_MODEL;
|
||||
|
||||
Reference in new issue
Block a user