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

+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 {