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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user