fix: show gallery reference images
This commit is contained in:
@@ -27,6 +27,7 @@ function createGalleryRow(overrides = {}) {
|
||||
params: {
|
||||
creationMode: 'text2img',
|
||||
referenceImages: ['/api/local-storage/reference.webp', ''],
|
||||
referenceImageThumbnails: ['/api/local-storage/thumbnails/reference.webp', ''],
|
||||
},
|
||||
user_id: '22222222-2222-2222-2222-222222222222',
|
||||
nickname: 'login-name',
|
||||
@@ -79,6 +80,7 @@ await runTest('maps reference images without blank entries', () => {
|
||||
|
||||
assert.deepEqual(work.referenceImages, ['/api/local-storage/reference.webp']);
|
||||
assert.equal(work.referenceImage, '/api/local-storage/reference.webp');
|
||||
assert.deepEqual(work.referenceImageThumbnails, ['/api/local-storage/thumbnails/reference.webp']);
|
||||
});
|
||||
|
||||
await runTest('allows stale gallery cache rows for instant first paint', () => {
|
||||
|
||||
@@ -74,6 +74,9 @@ await runTest('gallery publish persists reference images as stable local-storage
|
||||
await runTest('gallery detail shows reference images but does not expose reference downloads', () => {
|
||||
const source = read('src/app/gallery/page.tsx');
|
||||
assert.match(source, /getWorkReferenceImages/);
|
||||
assert.match(source, /getWorkReferenceImageThumbnails/);
|
||||
assert.match(source, /ReferencePreviewImage/);
|
||||
assert.match(source, /thumbnailSrc=\{selectedReferenceImageThumbnails\[index\]\}/);
|
||||
assert.match(source, /参考图/);
|
||||
assert.match(source, /referencePreviewSrc/);
|
||||
assert.match(source, /disableContextMenu/);
|
||||
@@ -81,6 +84,13 @@ await runTest('gallery detail shows reference images but does not expose referen
|
||||
assert.doesNotMatch(source, /handleDownload\([^)]*reference/i);
|
||||
});
|
||||
|
||||
await runTest('gallery api merges reference metadata from duplicate result rows', () => {
|
||||
const route = read('src/app/api/gallery/route.ts');
|
||||
assert.match(route, /mergeGalleryRowMetadata/);
|
||||
assert.match(route, /dedupeGalleryRowsByResultUrl/);
|
||||
assert.match(route, /referenceImageThumbnails/);
|
||||
});
|
||||
|
||||
await runTest('inspiration reuse preserves original reference images when available', () => {
|
||||
const reuseSource = read('src/lib/creation-reuse.ts');
|
||||
assert.match(reuseSource, /explicitReferences/);
|
||||
|
||||
@@ -12,6 +12,47 @@ import { MAX_PUBLIC_GALLERY_AVATAR_URL_LENGTH, toPublicGalleryWork } from '@/lib
|
||||
const galleryThumbnailQueue = new Map<string, Record<string, unknown>>();
|
||||
let galleryThumbnailProcessing = false;
|
||||
|
||||
function hasGalleryReferenceMetadata(params: Record<string, unknown>) {
|
||||
return typeof params.referenceImage === 'string' && params.referenceImage.trim().length > 0
|
||||
|| (Array.isArray(params.referenceImages) && params.referenceImages.length > 0);
|
||||
}
|
||||
|
||||
function mergeGalleryRowMetadata(target: Record<string, unknown>, source: Record<string, unknown>) {
|
||||
const targetParams = (target.params || {}) as Record<string, unknown>;
|
||||
const sourceParams = (source.params || {}) as Record<string, unknown>;
|
||||
if (!target.thumbnail_url && source.thumbnail_url) target.thumbnail_url = source.thumbnail_url;
|
||||
if (!target.width && source.width) target.width = source.width;
|
||||
if (!target.height && source.height) target.height = source.height;
|
||||
if (!hasGalleryReferenceMetadata(targetParams) && hasGalleryReferenceMetadata(sourceParams)) {
|
||||
target.params = {
|
||||
...targetParams,
|
||||
referenceImage: sourceParams.referenceImage,
|
||||
referenceImages: sourceParams.referenceImages,
|
||||
referenceImageThumbnails: sourceParams.referenceImageThumbnails,
|
||||
refImageCount: sourceParams.refImageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeGalleryRowsByResultUrl(rows: Record<string, unknown>[], metadataRows: Record<string, unknown>[] = []) {
|
||||
const byUrl = new Map<string, Record<string, unknown>[]>();
|
||||
for (const row of [...rows, ...metadataRows]) {
|
||||
if (typeof row.result_url !== 'string' || !row.result_url.trim()) continue;
|
||||
const group = byUrl.get(row.result_url) || [];
|
||||
group.push(row);
|
||||
byUrl.set(row.result_url, group);
|
||||
}
|
||||
return rows.map(row => {
|
||||
if (typeof row.result_url !== 'string' || !row.result_url.trim()) return row;
|
||||
const group = byUrl.get(row.result_url) || [];
|
||||
for (const candidate of group) {
|
||||
if (candidate.user_id && row.user_id && candidate.user_id !== row.user_id) continue;
|
||||
if (candidate !== row) mergeGalleryRowMetadata(row, candidate);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureGalleryThumbnail(client: Awaited<ReturnType<typeof getDbClient>>, row: Record<string, unknown>) {
|
||||
const type = String(row.type || '');
|
||||
if (typeof row.result_url !== 'string') return row;
|
||||
@@ -161,7 +202,22 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
|
||||
for (const row of result.rows || []) scheduleGalleryThumbnail(row);
|
||||
const rows = result.rows || [];
|
||||
const resultRows = result.rows || [];
|
||||
const resultUrls = [...new Set(resultRows
|
||||
.map((row: Record<string, unknown>) => typeof row.result_url === 'string' ? row.result_url.trim() : '')
|
||||
.filter(Boolean))];
|
||||
let metadataRows: Record<string, unknown>[] = [];
|
||||
if (resultUrls.length > 0) {
|
||||
const metadataResult = await client.query(
|
||||
`SELECT id, result_url, thumbnail_url, width, height, user_id, params
|
||||
FROM works
|
||||
WHERE status = $1
|
||||
AND result_url = ANY($2::text[])`,
|
||||
['completed', resultUrls],
|
||||
);
|
||||
metadataRows = metadataResult.rows || [];
|
||||
}
|
||||
const rows = dedupeGalleryRowsByResultUrl(resultRows, metadataRows);
|
||||
const works = rows.map((row: Record<string, unknown>) => toPublicGalleryWork(row));
|
||||
|
||||
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { copyTextToClipboard, downloadFile, getImageDownloadExtension, triggerDo
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import { FullscreenPreview } from '@/components/fullscreen-preview';
|
||||
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
|
||||
import { ReferencePreviewImage } from '@/components/reference-preview-image';
|
||||
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
|
||||
import { buildCreationReuseDraft, type CreationReuseTarget, writeCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
import { GALLERY_CACHE_MAX_AGE_MS, isGalleryCacheEntryUsable } from '@/lib/gallery-cache-policy';
|
||||
@@ -43,8 +44,8 @@ const CATEGORIES = [
|
||||
];
|
||||
|
||||
const GALLERY_PAGE_SIZE = 18;
|
||||
const GALLERY_CACHE_KEY = 'miaojing:gallery:v3';
|
||||
const GALLERY_CACHE_VERSION = 3;
|
||||
const GALLERY_CACHE_KEY = 'miaojing:gallery:v4';
|
||||
const GALLERY_CACHE_VERSION = 4;
|
||||
const GALLERY_CACHE_MAX_ENTRIES = 12;
|
||||
const GALLERY_CACHE_MAX_WORKS_PER_ENTRY = 72;
|
||||
|
||||
@@ -65,6 +66,7 @@ interface GalleryWork {
|
||||
params: Record<string, unknown>;
|
||||
referenceImage?: string | null;
|
||||
referenceImages?: string[];
|
||||
referenceImageThumbnails?: string[];
|
||||
publisherId: string;
|
||||
publisherNickname: string;
|
||||
publisherAvatarUrl?: string | null;
|
||||
@@ -316,6 +318,14 @@ function getWorkReferenceImages(work: GalleryWork): string[] {
|
||||
return [...new Set([...single, ...fromArray, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
|
||||
}
|
||||
|
||||
function getWorkReferenceImageThumbnails(work: GalleryWork): string[] {
|
||||
const fromArray = Array.isArray(work.referenceImageThumbnails) ? work.referenceImageThumbnails : [];
|
||||
const fromParams = Array.isArray(work.params?.referenceImageThumbnails)
|
||||
? (work.params.referenceImageThumbnails as unknown[]).filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
return [...new Set([...fromArray, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
|
||||
}
|
||||
|
||||
async function copyGalleryText(text: string, successMessage: string) {
|
||||
const copyResult = await copyTextToClipboard(text);
|
||||
if (copyResult === 'copied') {
|
||||
@@ -856,6 +866,10 @@ export default function GalleryPage() {
|
||||
() => selectedWork ? getWorkReferenceImages(selectedWork) : [],
|
||||
[selectedWork],
|
||||
);
|
||||
const selectedReferenceImageThumbnails = useMemo(
|
||||
() => selectedWork ? getWorkReferenceImageThumbnails(selectedWork) : [],
|
||||
[selectedWork],
|
||||
);
|
||||
|
||||
const toggleLike = (id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
@@ -1380,7 +1394,8 @@ export default function GalleryPage() {
|
||||
<div className="grid max-h-[240px] grid-cols-2 gap-2 overflow-y-auto pr-1">
|
||||
{selectedReferenceImages.map((url, index) => (
|
||||
<div key={`${url}-${index}`} className={`${detailGlassInner} group relative overflow-hidden`}>
|
||||
<img
|
||||
<ReferencePreviewImage
|
||||
thumbnailSrc={selectedReferenceImageThumbnails[index]}
|
||||
src={url}
|
||||
alt={`参考图 ${index + 1}`}
|
||||
className="aspect-square w-full cursor-zoom-in object-cover"
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface PublicGalleryWork {
|
||||
params: Record<string, unknown>;
|
||||
referenceImage?: string;
|
||||
referenceImages: string[];
|
||||
referenceImageThumbnails: string[];
|
||||
publisherId: unknown;
|
||||
publisherNickname: string;
|
||||
publisherAvatarUrl: string | null;
|
||||
@@ -50,9 +51,18 @@ export function getGalleryReferenceImages(params: Record<string, unknown>) {
|
||||
return { referenceImage, referenceImages };
|
||||
}
|
||||
|
||||
export function getGalleryReferenceImageThumbnails(params: Record<string, unknown>) {
|
||||
return Array.isArray(params.referenceImageThumbnails)
|
||||
? params.referenceImageThumbnails
|
||||
.map(normalizeString)
|
||||
.filter((item): item is string => item.length > 0 && !item.startsWith('data:') && !item.startsWith('['))
|
||||
: [];
|
||||
}
|
||||
|
||||
export function toPublicGalleryWork(row: Record<string, unknown>): PublicGalleryWork {
|
||||
const workParams = asRecord(row.params);
|
||||
const references = getGalleryReferenceImages(workParams);
|
||||
const referenceImageThumbnails = getGalleryReferenceImageThumbnails(workParams);
|
||||
const emailPrefix = normalizeString(row.email).split('@')[0];
|
||||
|
||||
return {
|
||||
@@ -71,6 +81,7 @@ export function toPublicGalleryWork(row: Record<string, unknown>): PublicGallery
|
||||
params: workParams,
|
||||
referenceImage: references.referenceImage,
|
||||
referenceImages: references.referenceImages,
|
||||
referenceImageThumbnails,
|
||||
publisherId: row.user_id,
|
||||
publisherNickname: normalizeString(row.display_nickname) || normalizeString(row.nickname) || emailPrefix || '匿名用户',
|
||||
publisherAvatarUrl: getPublicGalleryAvatarUrl(row.avatar_url),
|
||||
|
||||
Reference in New Issue
Block a user