From a5e2cedc1269fce1947465e876f3a592eab082c8 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Fri, 27 Feb 2026 11:00:58 +0800 Subject: [PATCH] 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 --- package-lock.json | 4 +- package.json | 2 +- src/app/api/media/[id]/favorite/route.ts | 31 +++ src/app/api/media/gallery/route.ts | 7 + src/app/api/providers/models/route.ts | 4 + src/app/gallery/page.tsx | 116 ++++---- src/components/gallery/GalleryDetail.tsx | 281 ++++++++++---------- src/components/gallery/GalleryGrid.tsx | 80 ++---- src/components/settings/ProviderManager.tsx | 2 +- src/i18n/en.ts | 4 + src/i18n/zh.ts | 4 + src/lib/db.ts | 9 + src/lib/image-generator.ts | 2 +- 13 files changed, 284 insertions(+), 262 deletions(-) create mode 100644 src/app/api/media/[id]/favorite/route.ts diff --git a/package-lock.json b/package-lock.json index 0f84172..d435780 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.16.0", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.16.0", + "version": "0.17.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.47", "@ai-sdk/google": "^3.0.31", diff --git a/package.json b/package.json index 23878ea..d01538c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.16.0", + "version": "0.17.0", "private": true, "author": { "name": "op7418", diff --git a/src/app/api/media/[id]/favorite/route.ts b/src/app/api/media/[id]/favorite/route.ts new file mode 100644 index 0000000..64c7caf --- /dev/null +++ b/src/app/api/media/[id]/favorite/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/media/gallery/route.ts b/src/app/api/media/gallery/route.ts index b76eeea..d91ed39 100644 --- a/src/app/api/media/gallery/route.ts +++ b/src/app/api/media/gallery/route.ts @@ -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) { diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts index 307cc34..d40eed1 100644 --- a/src/app/api/providers/models/route.ts +++ b/src/app/api/providers/models/route.ts @@ -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); diff --git a/src/app/gallery/page.tsx b/src/app/gallery/page.tsx index 1432fa0..ff3f818 100644 --- a/src/app/gallery/page.tsx +++ b/src/app/gallery/page.tsx @@ -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([]); const [total, setTotal] = useState(0); @@ -30,11 +29,11 @@ export default function GalleryPage() { const [offset, setOffset] = useState(0); // Filters - const [selectedTags, setSelectedTags] = useState([]); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const [sort, setSort] = useState('newest'); const [showFilters, setShowFilters] = useState(false); + const [favoritesOnly, setFavoritesOnly] = useState(false); // Detail const [selectedItem, setSelectedItem] = useState(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(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 (
@@ -136,6 +147,20 @@ export default function GalleryPage() { {t('gallery.title' as TranslationKey)}
+ {/* Favorites toggle */} + + {/* Filter toggle */}
) : ( -
+
- {hasMore && ( -
- -
- )} + {/* Sentinel for infinite scroll */} +
+ {loading && ( + + )} +
)}
@@ -255,11 +256,10 @@ export default function GalleryPage() { {/* Detail dialog */}
); diff --git a/src/components/gallery/GalleryDetail.tsx b/src/components/gallery/GalleryDetail.tsx index e9d2fb1..68eaab6 100644 --- a/src/components/gallery/GalleryDetail.tsx +++ b/src/components/gallery/GalleryDetail.tsx @@ -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 ( - + {t('gallery.imageDetail' as TranslationKey)} - {/* Image preview */} -
- {currentImage && ( - // eslint-disable-next-line @next/next/no-img-element - {item.prompt} - )} - - {hasMultipleImages && ( - <> - - -
- {currentImageIndex + 1} / {item.images.length} -
- - )} -
- - {/* Details */} -
- {/* Prompt */} -
-
- {t('gallery.prompt' as TranslationKey)} +
+ {/* Left: Image preview */} +
+
+ {currentImage && ( + // eslint-disable-next-line @next/next/no-img-element + {item.prompt} + )}
-

{item.prompt}

-
- {/* Metadata badges */} -
- {item.model && ( - - - {item.model} - - )} - {item.aspectRatio && ( - - {item.aspectRatio} - - )} - {item.imageSize && ( - - {item.imageSize} - + {hasMultipleImages && ( + <> + + +
+ {currentImageIndex + 1} / {item.images.length} +
+ )}
- {/* Reference images (垫图) */} - {item.referenceImages && item.referenceImages.length > 0 && ( + {/* Right: Info panel */} +
+ {/* Favorite button */} + + + {/* Prompt */}
-
- {t('imageGen.referenceImages' as TranslationKey)} +
+ {t('gallery.prompt' as TranslationKey)}
-
- {item.referenceImages.map((ref, i) => { - const src = ref.localPath - ? `/api/media/serve?path=${encodeURIComponent(ref.localPath)}` - : ''; - if (!src) return null; - return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`Reference -
- ); - })} +

{item.prompt}

+
+ + {/* Metadata badges */} +
+ {item.model && ( + + + {item.model} + + )} + {item.aspectRatio && ( + + {item.aspectRatio} + + )} + {item.imageSize && ( + + {item.imageSize} + + )} +
+ + {/* Reference images */} + {item.referenceImages && item.referenceImages.length > 0 && ( +
+
+ {t('imageGen.referenceImages' as TranslationKey)} +
+
+ {item.referenceImages.map((ref, i) => { + const src = ref.localPath + ? `/api/media/serve?path=${encodeURIComponent(ref.localPath)}` + : ''; + if (!src) return null; + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Reference +
+ ); + })} +
-
- )} - - {/* Date */} -
- {formatDate(item.created_at)} -
- - {/* Tags */} -
-
- {t('gallery.tags' as TranslationKey)} -
- -
- - {/* Actions */} -
- {item.session_id && ( - )} - -
- + )} + +
+ +
diff --git a/src/components/gallery/GalleryGrid.tsx b/src/components/gallery/GalleryGrid.tsx index 0ae063f..cb3e26b 100644 --- a/src/components/gallery/GalleryGrid.tsx +++ b/src/components/gallery/GalleryGrid.tsx @@ -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 ( -
+
{items.map((item) => { const url = thumbnailUrl(item); - const itemTags = item.tags - .map((tid) => tagMap.get(tid)) - .filter((t): t is Tag => !!t); return ( - +
); })}
diff --git a/src/components/settings/ProviderManager.tsx b/src/components/settings/ProviderManager.tsx index cd0cbca..ad55583 100644 --- a/src/components/settings/ProviderManager.tsx +++ b/src/components/settings/ProviderManager.tsx @@ -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 { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1150ae4..c0ca9e4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index a3ec806..0583372 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -409,6 +409,10 @@ const zh: Record = { 'gallery.imageDetail': '图片详情', 'gallery.prompt': '提示词', 'gallery.download': '下载', + 'gallery.favorites': '收藏', + 'gallery.favoritesOnly': '只看收藏', + 'gallery.addToFavorites': '添加到收藏', + 'gallery.removeFromFavorites': '取消收藏', // ── Provider: Gemini Image ────────────────────────────────── 'provider.chatProviders': '聊天服务商', diff --git a/src/lib/db.ts b/src/lib/db.ts index d88befb..78f2b37 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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') diff --git a/src/lib/image-generator.ts b/src/lib/image-generator.ts index ea4275c..3f76468 100644 --- a/src/lib/image-generator.ts +++ b/src/lib/image-generator.ts @@ -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;