Skip to content

Commit a5e2ced

Browse files
7418claude
andcommitted
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>
1 parent 5ac4eb5 commit a5e2ced

File tree

13 files changed

+281
-259
lines changed

13 files changed

+281
-259
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.16.0",
3+
"version": "0.17.0",
44
"private": true,
55
"author": {
66
"name": "op7418",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getDb } from '@/lib/db';
3+
4+
export const runtime = 'nodejs';
5+
export const dynamic = 'force-dynamic';
6+
7+
export async function PUT(
8+
_request: NextRequest,
9+
{ params }: { params: Promise<{ id: string }> },
10+
) {
11+
try {
12+
const { id } = await params;
13+
const db = getDb();
14+
15+
const row = db.prepare('SELECT favorited FROM media_generations WHERE id = ?').get(id) as { favorited: number } | undefined;
16+
if (!row) {
17+
return NextResponse.json({ error: 'Not found' }, { status: 404 });
18+
}
19+
20+
const newValue = row.favorited ? 0 : 1;
21+
db.prepare('UPDATE media_generations SET favorited = ? WHERE id = ?').run(newValue, id);
22+
23+
return NextResponse.json({ favorited: newValue });
24+
} catch (error) {
25+
console.error('[media/favorite] Error:', error);
26+
return NextResponse.json(
27+
{ error: error instanceof Error ? error.message : 'Failed to toggle favorite' },
28+
{ status: 500 },
29+
);
30+
}
31+
}

src/app/api/media/gallery/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface DbRow {
1313
local_path: string;
1414
tags: string;
1515
metadata: string;
16+
favorited: number;
1617
created_at: string;
1718
session_id: string | null;
1819
[key: string]: unknown;
@@ -56,6 +57,7 @@ function mapRow(row: DbRow) {
5657
aspectRatio: row.aspect_ratio,
5758
imageSize: row.image_size,
5859
tags,
60+
favorited: !!row.favorited,
5961
created_at: row.created_at,
6062
session_id: row.session_id || undefined,
6163
referenceImages,
@@ -68,6 +70,7 @@ export async function GET(request: NextRequest) {
6870
const tags = searchParams.get('tags');
6971
const dateFrom = searchParams.get('dateFrom');
7072
const dateTo = searchParams.get('dateTo');
73+
const favoritesOnly = searchParams.get('favoritesOnly') === '1';
7174
const sort = searchParams.get('sort') || 'newest';
7275
const limit = parseInt(searchParams.get('limit') || '50', 10);
7376
const offset = parseInt(searchParams.get('offset') || '0', 10);
@@ -77,6 +80,10 @@ export async function GET(request: NextRequest) {
7780
const conditions: string[] = [];
7881
const params: unknown[] = [];
7982

83+
if (favoritesOnly) {
84+
conditions.push("mg.favorited = 1");
85+
}
86+
8087
if (tags) {
8188
const tagList = tags.split(',').map(t => t.trim()).filter(Boolean);
8289
for (const tag of tagList) {

src/app/api/providers/models/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,12 @@ export async function GET() {
8484
});
8585
}
8686

87+
// Provider types that are not LLMs (e.g. image generation) — skip in chat model selector
88+
const MEDIA_PROVIDER_TYPES = new Set(['gemini-image']);
89+
8790
// Build a group for each configured provider
8891
for (const provider of providers) {
92+
if (MEDIA_PROVIDER_TYPES.has(provider.provider_type)) continue;
8993
const matched = PROVIDER_MODEL_LABELS[provider.base_url];
9094
const rawModels = matched || DEFAULT_MODELS;
9195
const models = deduplicateModels(rawModels);

src/app/gallery/page.tsx

Lines changed: 58 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
'use client';
22

3-
import { useState, useEffect, useCallback } from 'react';
3+
import { useState, useEffect, useCallback, useRef } from 'react';
44
import { HugeiconsIcon } from '@hugeicons/react';
55
import {
66
PaintBrush01Icon,
77
SortingDownIcon,
88
FilterIcon,
99
Loading02Icon,
10+
FavouriteIcon,
1011
} from '@hugeicons/core-free-icons';
1112
import { cn } from '@/lib/utils';
1213
import { Button } from '@/components/ui/button';
1314
import { GalleryGrid, type GalleryItem } from '@/components/gallery/GalleryGrid';
1415
import { GalleryDetail } from '@/components/gallery/GalleryDetail';
15-
import { TagManager, useTags, type Tag } from '@/components/gallery/TagManager';
1616
import { useTranslation } from '@/hooks/useTranslation';
1717
import type { TranslationKey } from '@/i18n';
1818

@@ -22,19 +22,18 @@ type SortOrder = 'newest' | 'oldest';
2222

2323
export default function GalleryPage() {
2424
const { t } = useTranslation();
25-
const { tags, loading: tagsLoading, addTag, removeTag, fetchTags } = useTags();
2625

2726
const [items, setItems] = useState<GalleryItem[]>([]);
2827
const [total, setTotal] = useState(0);
2928
const [loading, setLoading] = useState(true);
3029
const [offset, setOffset] = useState(0);
3130

3231
// Filters
33-
const [selectedTags, setSelectedTags] = useState<string[]>([]);
3432
const [dateFrom, setDateFrom] = useState('');
3533
const [dateTo, setDateTo] = useState('');
3634
const [sort, setSort] = useState<SortOrder>('newest');
3735
const [showFilters, setShowFilters] = useState(false);
36+
const [favoritesOnly, setFavoritesOnly] = useState(false);
3837

3938
// Detail
4039
const [selectedItem, setSelectedItem] = useState<GalleryItem | null>(null);
@@ -44,9 +43,9 @@ export default function GalleryPage() {
4443
setLoading(true);
4544
try {
4645
const params = new URLSearchParams();
47-
if (selectedTags.length > 0) params.set('tags', selectedTags.join(','));
4846
if (dateFrom) params.set('dateFrom', dateFrom);
4947
if (dateTo) params.set('dateTo', dateTo);
48+
if (favoritesOnly) params.set('favoritesOnly', '1');
5049
params.set('sort', sort);
5150
params.set('limit', String(PAGE_SIZE));
5251
params.set('offset', reset ? '0' : String(offset));
@@ -68,22 +67,14 @@ export default function GalleryPage() {
6867
} finally {
6968
setLoading(false);
7069
}
71-
}, [selectedTags, dateFrom, dateTo, sort, offset]);
70+
}, [dateFrom, dateTo, sort, offset, favoritesOnly]);
7271

7372
// Initial load and reload on filter changes
7473
useEffect(() => {
7574
setOffset(0);
7675
fetchItems(true);
7776
// eslint-disable-next-line react-hooks/exhaustive-deps
78-
}, [selectedTags, dateFrom, dateTo, sort]);
79-
80-
const handleToggleTag = useCallback((tagId: string) => {
81-
setSelectedTags((prev) =>
82-
prev.includes(tagId)
83-
? prev.filter((t) => t !== tagId)
84-
: [...prev, tagId]
85-
);
86-
}, []);
77+
}, [dateFrom, dateTo, sort, favoritesOnly]);
8778

8879
const handleSelect = useCallback((item: GalleryItem) => {
8980
setSelectedItem(item);
@@ -102,22 +93,19 @@ export default function GalleryPage() {
10293
}
10394
}, []);
10495

105-
const handleTagsChange = useCallback(async (id: string, newTags: string[]) => {
96+
const handleToggleFavorite = useCallback(async (id: string) => {
10697
try {
107-
const res = await fetch(`/api/media/${id}/tags`, {
108-
method: 'PUT',
109-
headers: { 'Content-Type': 'application/json' },
110-
body: JSON.stringify({ tags: newTags }),
111-
});
98+
const res = await fetch(`/api/media/${id}/favorite`, { method: 'PUT' });
11299
if (res.ok) {
100+
const data = await res.json();
101+
const favorited = !!data.favorited;
113102
setItems((prev) =>
114103
prev.map((item) =>
115-
item.id === id ? { ...item, tags: newTags } : item
104+
item.id === id ? { ...item, favorited } : item
116105
)
117106
);
118-
// Update selected item too
119107
setSelectedItem((prev) =>
120-
prev && prev.id === id ? { ...prev, tags: newTags } : prev
108+
prev && prev.id === id ? { ...prev, favorited } : prev
121109
);
122110
}
123111
} catch {
@@ -126,6 +114,29 @@ export default function GalleryPage() {
126114
}, []);
127115

128116
const hasMore = items.length < total;
117+
const sentinelRef = useRef<HTMLDivElement>(null);
118+
const loadingRef = useRef(false);
119+
120+
// Infinite scroll via IntersectionObserver
121+
useEffect(() => {
122+
const sentinel = sentinelRef.current;
123+
if (!sentinel) return;
124+
125+
const observer = new IntersectionObserver(
126+
(entries) => {
127+
if (entries[0].isIntersecting && hasMore && !loadingRef.current) {
128+
loadingRef.current = true;
129+
fetchItems(false).finally(() => {
130+
loadingRef.current = false;
131+
});
132+
}
133+
},
134+
{ rootMargin: '200px' },
135+
);
136+
137+
observer.observe(sentinel);
138+
return () => observer.disconnect();
139+
}, [hasMore, fetchItems]);
129140

130141
return (
131142
<div className="flex h-full flex-col overflow-hidden">
@@ -136,6 +147,20 @@ export default function GalleryPage() {
136147
{t('gallery.title' as TranslationKey)}
137148
</h1>
138149
<div className="flex items-center gap-2">
150+
{/* Favorites toggle */}
151+
<Button
152+
variant={favoritesOnly ? 'secondary' : 'ghost'}
153+
size="sm"
154+
onClick={() => setFavoritesOnly((v) => !v)}
155+
>
156+
<HugeiconsIcon
157+
icon={FavouriteIcon}
158+
className={cn('h-3.5 w-3.5', favoritesOnly && 'text-red-500')}
159+
fill={favoritesOnly ? 'currentColor' : 'none'}
160+
/>
161+
{t('gallery.favoritesOnly' as TranslationKey)}
162+
</Button>
163+
139164
{/* Filter toggle */}
140165
<Button
141166
variant={showFilters ? 'secondary' : 'ghost'}
@@ -163,18 +188,6 @@ export default function GalleryPage() {
163188
{/* Filter bar */}
164189
{showFilters && (
165190
<div className="mt-3 space-y-2.5">
166-
{/* Tag filter */}
167-
{!tagsLoading && tags.length > 0 && (
168-
<div>
169-
<TagManager
170-
tags={tags}
171-
selectedTags={selectedTags}
172-
onToggleTag={handleToggleTag}
173-
compact
174-
/>
175-
</div>
176-
)}
177-
178191
{/* Date range */}
179192
<div className="flex items-center gap-2">
180193
<label className="text-xs text-muted-foreground">
@@ -195,14 +208,13 @@ export default function GalleryPage() {
195208
onChange={(e) => setDateTo(e.target.value)}
196209
className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
197210
/>
198-
{(dateFrom || dateTo || selectedTags.length > 0) && (
211+
{(dateFrom || dateTo) && (
199212
<Button
200213
variant="ghost"
201214
size="xs"
202215
onClick={() => {
203216
setDateFrom('');
204217
setDateTo('');
205-
setSelectedTags([]);
206218
}}
207219
>
208220
{t('gallery.clearFilters' as TranslationKey)}
@@ -226,40 +238,28 @@ export default function GalleryPage() {
226238
<p className="text-xs opacity-70">{t('gallery.emptyHint' as TranslationKey)}</p>
227239
</div>
228240
) : (
229-
<div className="space-y-4">
241+
<div>
230242
<GalleryGrid
231243
items={items}
232-
tags={tags}
233244
onSelect={handleSelect}
234245
/>
235-
{hasMore && (
236-
<div className="flex justify-center py-4">
237-
<Button
238-
variant="outline"
239-
size="sm"
240-
onClick={() => fetchItems(false)}
241-
disabled={loading}
242-
>
243-
{loading ? (
244-
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin" />
245-
) : (
246-
t('gallery.loadMore' as TranslationKey)
247-
)}
248-
</Button>
249-
</div>
250-
)}
246+
{/* Sentinel for infinite scroll */}
247+
<div ref={sentinelRef} className="flex justify-center py-4">
248+
{loading && (
249+
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin text-muted-foreground" />
250+
)}
251+
</div>
251252
</div>
252253
)}
253254
</div>
254255

255256
{/* Detail dialog */}
256257
<GalleryDetail
257258
item={selectedItem}
258-
tags={tags}
259259
open={detailOpen}
260260
onOpenChange={setDetailOpen}
261261
onDelete={handleDelete}
262-
onTagsChange={handleTagsChange}
262+
onToggleFavorite={handleToggleFavorite}
263263
/>
264264
</div>
265265
);

0 commit comments

Comments
 (0)