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:
7418andClaude Opus 4.6 committed 2026-02-27 11:00:58 +08:00
1 parent 5ac4eb5eb6
commit a5e2cedc12
13 files changed
+284 -262

No files matched your search

+31
View File
@@ -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 },
);
}
}
+7
View File
@@ -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) {
+4
View File
@@ -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
View File
@@ -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>
);
+141 -140
View File
@@ -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>
+21 -59
View File
@@ -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>
+1 -1
View File
@@ -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 {
+4
View File
@@ -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',
+4
View File
@@ -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': '聊天服务商',
+9
View File
@@ -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')
+1 -1
View File
@@ -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;