fix: reduce gallery first paint payload
This commit is contained in:
@@ -109,7 +109,7 @@ Important generation helpers:
|
||||
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | None | Latest 300 completed user works as `records`, including optional `thumbnailUrl`; missing image thumbnails are lazily generated into local `thumbnails/works`. |
|
||||
| POST | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Single record or `{ records: [...] }`; image records may include `thumbnailUrl` | Inserts/deduplicates completed works into `works`, storing `thumbnail_url` when supplied or generating it for image works. |
|
||||
| DELETE | `/api/creation-history?id=...` | User | `src/app/api/creation-history/route.ts` | Optional `id`; omit to delete all user history | Deletes user's private history rows by `id` and `user_id`. Creation detail deletion waits for this server delete before refreshing local history. |
|
||||
| GET | `/api/gallery` | Public | `src/app/api/gallery/route.ts` | Query `type=image|video`, `category=text2img|img2img|text2video|img2video`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`; missing public image thumbnails are lazily generated into local `thumbnails/gallery`. Responses allow short private browser caching while the gallery page also keeps a bounded localStorage cache for instant first paint. |
|
||||
| GET | `/api/gallery` | Public | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | Query `type=image|video`, `category=text2img|img2img|text2video|img2video`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`; missing public image thumbnails are lazily generated into local `thumbnails/gallery`. Public list rows filter `data:` and oversized `publisherAvatarUrl` values to keep responses and browser caches small. Responses allow short private browser caching while the gallery page also keeps a bounded localStorage cache for instant first paint. |
|
||||
| DELETE | `/api/gallery` | Admin | `src/app/api/gallery/route.ts` | Query `id` or body `{ ids: [...] }` | Unpublishes up to 100 works by setting `is_public=false`. |
|
||||
| POST | `/api/gallery/publish` | User | `src/app/api/gallery/publish/route.ts` | Work metadata, `resultUrl`, optional thumbnail/reference/model fields | Copies image originals to object-backed gallery storage, ensures local gallery thumbnails, and inserts public completed work. |
|
||||
| GET | `/api/admin/gallery/works` | Admin | `src/app/api/admin/gallery/works/route.ts` | Query `q`, `type=all|image|video|text2img|img2img|text2video|img2video`, `page`, `pageSize`, legacy `limit`, `offset`, `sort` | Admin gallery-management list of public completed works with author email/nickname, prompt, media URL, thumbnail, `total`, `page`, `pageSize`, `totalPages`, legacy `nextOffset`, and `hasMore`. |
|
||||
|
||||
@@ -184,7 +184,7 @@ Image originals and previews have separate storage rules. `src/lib/media-storage
|
||||
|
||||
Gallery detail metadata must not load original images just to compute size. `ImageMetadataBadge` accepts stored `width`/`height`; gallery detail passes those values with `loadMetadata={false}` so preview surfaces stay thumbnail-only and original requests are reserved for fullscreen, download, copy, edit, and share.
|
||||
|
||||
The public gallery page should use server gallery rows only. It must not merge `miaojing_published_gallery` or `miaojing_creation_history` from browser localStorage into the gallery feed, and it must not auto-sync historical local published records into Supabase on page load. `/api/gallery` is the authority for all gallery views, including all/category filters and search, and should only return stable platform media URLs under `/api/local-storage/...`; legacy external import URLs are not public gallery candidates. To keep reopen latency low, `src/app/gallery/page.tsx` caches bounded page data in browser localStorage for instant first paint, prunes entries after 7 days or when the cache cap is exceeded, and immediately revalidates the first page in the background so published/deleted works replace cached rows. It should request small pages and append via IntersectionObserver as the user scrolls, not load the entire public gallery into the DOM.
|
||||
The public gallery page should use server gallery rows only. It must not merge `miaojing_published_gallery` or `miaojing_creation_history` from browser localStorage into the gallery feed, and it must not auto-sync historical local published records into Supabase on page load. `/api/gallery` is the authority for all gallery views, including all/category filters and search, and should only return stable platform media URLs under `/api/local-storage/...`; legacy external import URLs are not public gallery candidates. To keep reopen latency low, `src/app/gallery/page.tsx` caches bounded page data in browser localStorage for instant first paint, uses cached rows up to the 7-day prune window while revalidating page 0 in the background, and shows a masonry skeleton instead of a blocking centered loading message when no cache exists. Public gallery serialization in `src/lib/gallery-response.ts` filters generated default `data:` avatars and oversized avatar URLs so repeated `publisherAvatarUrl` fields do not bloat `/api/gallery` responses or exceed localStorage quota. It should request small pages and append via IntersectionObserver as the user scrolls, not load the entire public gallery into the DOM.
|
||||
|
||||
Admin gallery moderation is separate from the public gallery page. `src/components/admin/gallery-management-tab.tsx` lists public completed works through `/api/admin/gallery/works` with page/pageSize pagination; `src/lib/admin-gallery-works-pagination.ts` keeps the route compatible with older limit/offset callers. Prompt edits go through `/api/admin/gallery/prompt` and `src/lib/admin-gallery-prompt-service.ts`. The service enforces the moderation rule that the author notification email must send successfully before `works.prompt` is updated. Platform logs record the admin, work, author, reason key, prompt length changes, and notification result, but must not store the full original or edited prompt text.
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Reference image upload too large or fails | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/browser-image-compression.ts`, `src/lib/server-image-compression.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Browser compression, `MAX_UPSTREAM_REFERENCE_IMAGE_BYTES`, data URL conversion. Uploaded reference thumbnails should single-click into the no-container `BareImagePreview`; blank area closes it. |
|
||||
| Generated result previews but does not persist | `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts`, `src/lib/local-storage.ts`, `src/app/api/creation-history/route.ts` | Media copied through the storage adapter, stable `/api/local-storage/<key>` URL returned, history POST called. In object storage mode, verify `STORAGE_MODE` and `OBJECT_STORAGE_*` health. |
|
||||
| Image preview cards load slowly, look blurry in detail, or fetch full originals | `src/lib/media-storage.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/app/api/gallery/route.ts`, `src/app/api/gallery/publish/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/app/gallery/page.tsx` | New generated image originals should be object-only, while WEBP thumbnails should be local-only under `thumbnails/...`. Current thumbnails should have the `m1280q86` suffix and come from the 1280px/Lanczos/sharpened profile. Cards and detail preview surfaces use `thumbnailUrl || url`; fullscreen, right-click copy/download/edit, and share must use original `url`. `GET /api/creation-history` and `GET /api/gallery` should queue missing or old-profile legacy thumbnails in the background, not block the list response. `/api/local-storage/thumbnails/...` must read local disk directly in dual mode instead of checking object storage first; original image keys should 302 to a short-lived signed object-storage URL so fullscreen does not wait for Next.js to buffer the full file. |
|
||||
| Gallery shows `加载中...` for seconds on every visit or loads too many images at once | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/proxy.ts` | The page should show cached `miaojing:gallery:v3` rows immediately when fresh, revalidate page 0 in the background, debounce search, request small `limit/offset` pages, and append more rows only through the scroll sentinel. Do not restore the old `limit=300` full-gallery request. Thumbnail URLs under `/api/local-storage/thumbnails/...` should return long immutable cache headers so browser image cache is actually used; if curl still shows `no-store`, check `src/proxy.ts` because the global `/api` cache header can override the route response. |
|
||||
| Gallery shows `加载中...` for seconds on every visit or loads too many images at once | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts`, `src/lib/gallery-cache-policy.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/proxy.ts` | The page should show cached `miaojing:gallery:v3` rows immediately when available, even when older than the short freshness TTL, then revalidate page 0 in the background. It should show the masonry skeleton instead of the old centered `加载中...` when no cache exists, debounce search, request small `limit/offset` pages, and append more rows only through the scroll sentinel. Check `/api/gallery` response size with curl; generated default avatar `data:` URLs in `publisherAvatarUrl` can make every page hundreds of KB larger and can break localStorage caching, so public gallery serialization must filter `data:`/oversized avatars. Do not restore the old `limit=300` full-gallery request. Thumbnail URLs under `/api/local-storage/thumbnails/...` should return long immutable cache headers so browser image cache is actually used; if curl still shows `no-store`, check `src/proxy.ts` because the global `/api` cache header can override the route response. |
|
||||
| `/api/health` or page probes are slow after object migration | `src/app/api/health/route.ts`, `src/lib/local-storage.ts` | Health checks call `getStorageHealthStatus()`. Object bucket checks should be cached briefly and bounded with an abort timeout so a slow S3-compatible endpoint does not hold request threads for many seconds. |
|
||||
| Logs repeatedly show `must be owner of table ...` on normal requests | `src/lib/generation-job-estimates.ts`, `src/lib/email-service.ts`, `src/lib/profile-preferences.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/server-api-config.ts` | Optional runtime schema checks can hit `42501` when the production app user is not the table owner. Treat existing-schema `42501` as a one-time warning and cache the skip; apply real schema migrations through deployment/DB owner operations rather than request-time DDL. |
|
||||
| Fullscreen/preview/download/right-click image actions broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. Image result and history/detail previews should open on single click. Right-click copy, download, edit, and share actions must use the uncompressed original image URL, not a thumbnail, preview cache, or compressed reference blob. Fullscreen components should receive a thumbnail fallback so the preview appears immediately while the original object-storage image loads. Share links should open `/image-viewer?url=...` as a standalone original-image fullscreen page. Image result and history/detail previews should show upper-right actual aspect ratio and natural resolution via `ImageMetadataBadge`. |
|
||||
|
||||
@@ -107,8 +107,8 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Feature | Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download, and one-click reuse. It requests `/api/gallery` in small pages instead of fetching the full gallery, uses a bounded `miaojing:gallery:v3` browser localStorage cache for instant reopen, revalidates page 0 in the background, debounces search, and uses an IntersectionObserver sentinel to append the next page only when the user scrolls near it. Cached entries expire quickly for freshness and are pruned after 7 days or when the entry cap is exceeded. Image cards and detail display use `thumbnailUrl || url`, while fullscreen, download, copy/share, and reuse actions use original `url`. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. Mobile gallery must keep at least two masonry columns; `masonryColumnCount` bottoms out at 2 and `.gallery-masonry-grid`/card CSS trims spacing and metadata density on phones. |
|
||||
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`, queues missing or old-profile image thumbnails for background backfill without delaying the response, admin DELETE unpublishes. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css`, `src/lib/gallery-cache-policy.ts` | Lists public works, search/sort/filter, preview/download, and one-click reuse. It requests `/api/gallery` in small pages instead of fetching the full gallery, uses a bounded `miaojing:gallery:v3` browser localStorage cache for instant reopen, revalidates page 0 in the background, debounces search, and uses an IntersectionObserver sentinel to append the next page only when the user scrolls near it. Cached rows remain usable for instant first paint until the 7-day prune window; page 0 is refreshed in the background for freshness, and a masonry skeleton replaces the old centered `加载中...` state when no cache exists. Image cards and detail display use `thumbnailUrl || url`, while fullscreen, download, copy/share, and reuse actions use original `url`. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. Mobile gallery must keep at least two masonry columns; `masonryColumnCount` bottoms out at 2 and `.gallery-masonry-grid`/card CSS trims spacing and metadata density on phones. |
|
||||
| Public gallery API | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | GET public works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`, queues missing or old-profile image thumbnails for background backfill without delaying the response, admin DELETE unpublishes. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. Public list serialization filters `data:` and oversized `publisherAvatarUrl` values so generated default avatars do not bloat the gallery JSON payload or localStorage cache. |
|
||||
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies image originals into object-backed gallery folders, stores local thumbnails, and inserts public work. |
|
||||
| Admin gallery prompt moderation | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/admin-gallery-works-pagination.ts`, `scripts/test-admin-gallery-prompt-service.mjs` | Console-only workflow for editing public gallery `works.prompt`. The management table uses page/pageSize pagination while the list API keeps limit/offset compatibility. Admins must send an email notification to the author; the service sends email before updating the prompt and logs metadata without storing full prompt text. |
|
||||
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works, `thumbnailUrl`, and published state. Missing image thumbnails are queued for background backfill instead of blocking the history response. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"lint": "eslint",
|
||||
"start": "bash ./scripts/start.sh",
|
||||
"test:admin-gallery-prompt": "node --no-warnings ./scripts/test-admin-gallery-prompt-service.mjs",
|
||||
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
|
||||
"pm2:restart": "pm2 startOrReload ecosystem.config.cjs --update-env",
|
||||
"pm2:save": "pm2 save",
|
||||
"migration:check": "node ./scripts/migration-integrity-check.mjs",
|
||||
|
||||
99
scripts/test-gallery-response.mjs
Normal file
99
scripts/test-gallery-response.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getPublicGalleryAvatarUrl,
|
||||
toPublicGalleryWork,
|
||||
} from '../src/lib/gallery-response.ts';
|
||||
import {
|
||||
GALLERY_CACHE_MAX_AGE_MS,
|
||||
GALLERY_CACHE_TTL_MS,
|
||||
isGalleryCacheEntryFresh,
|
||||
isGalleryCacheEntryUsable,
|
||||
} from '../src/lib/gallery-cache-policy.ts';
|
||||
|
||||
function createGalleryRow(overrides = {}) {
|
||||
return {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
type: 'text2img',
|
||||
title: 'public work',
|
||||
prompt: 'prompt',
|
||||
negative_prompt: null,
|
||||
result_url: '/api/local-storage/gallery/image.webp',
|
||||
thumbnail_url: '/api/local-storage/thumbnails/gallery/image.webp',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
duration: null,
|
||||
likes_count: 7,
|
||||
credits_cost: 2,
|
||||
params: {
|
||||
creationMode: 'text2img',
|
||||
referenceImages: ['/api/local-storage/reference.webp', ''],
|
||||
},
|
||||
user_id: '22222222-2222-2222-2222-222222222222',
|
||||
nickname: 'login-name',
|
||||
display_nickname: '公开昵称',
|
||||
email: 'user@example.com',
|
||||
avatar_url: null,
|
||||
created_at: '2026-05-20T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('filters data URL avatars from public gallery rows', () => {
|
||||
const dataAvatar = `data:image/svg+xml;base64,${'a'.repeat(40000)}`;
|
||||
const work = toPublicGalleryWork(createGalleryRow({ avatar_url: dataAvatar }));
|
||||
|
||||
assert.equal(work.publisherAvatarUrl, null);
|
||||
assert.equal(JSON.stringify(work).includes(dataAvatar), false);
|
||||
});
|
||||
|
||||
await runTest('keeps short URL avatars for public gallery rows', () => {
|
||||
assert.equal(
|
||||
getPublicGalleryAvatarUrl('/api/local-storage/avatars/user.webp'),
|
||||
'/api/local-storage/avatars/user.webp',
|
||||
);
|
||||
assert.equal(
|
||||
getPublicGalleryAvatarUrl('https://example.com/avatar.webp'),
|
||||
'https://example.com/avatar.webp',
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('uses display nickname before login nickname', () => {
|
||||
const work = toPublicGalleryWork(createGalleryRow());
|
||||
|
||||
assert.equal(work.publisherNickname, '公开昵称');
|
||||
});
|
||||
|
||||
await runTest('maps reference images without blank entries', () => {
|
||||
const work = toPublicGalleryWork(createGalleryRow());
|
||||
|
||||
assert.deepEqual(work.referenceImages, ['/api/local-storage/reference.webp']);
|
||||
assert.equal(work.referenceImage, '/api/local-storage/reference.webp');
|
||||
});
|
||||
|
||||
await runTest('allows stale gallery cache rows for instant first paint', () => {
|
||||
const now = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||
const staleButUsable = now - GALLERY_CACHE_TTL_MS - 1;
|
||||
|
||||
assert.equal(isGalleryCacheEntryFresh(staleButUsable, now), false);
|
||||
assert.equal(isGalleryCacheEntryUsable(staleButUsable, now), true);
|
||||
});
|
||||
|
||||
await runTest('rejects gallery cache rows older than max age', () => {
|
||||
const now = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||
const expired = now - GALLERY_CACHE_MAX_AGE_MS - 1;
|
||||
|
||||
assert.equal(isGalleryCacheEntryUsable(expired, now), false);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
@@ -2,20 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { ensureLocalImageThumbnail, isCurrentLocalImageThumbnail } from '@/lib/media-storage';
|
||||
import { MAX_PUBLIC_GALLERY_AVATAR_URL_LENGTH, toPublicGalleryWork } from '@/lib/gallery-response';
|
||||
|
||||
const galleryThumbnailQueue = new Map<string, Record<string, unknown>>();
|
||||
let galleryThumbnailProcessing = false;
|
||||
|
||||
function getReferenceImages(params: Record<string, unknown>) {
|
||||
const referenceImages = Array.isArray(params.referenceImages)
|
||||
? params.referenceImages.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
const referenceImage = typeof params.referenceImage === 'string' && params.referenceImage.trim()
|
||||
? params.referenceImage
|
||||
: referenceImages[0];
|
||||
return { referenceImage, referenceImages };
|
||||
}
|
||||
|
||||
async function ensureGalleryThumbnail(client: Awaited<ReturnType<typeof getDbClient>>, row: Record<string, unknown>) {
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || typeof row.result_url !== 'string') return row;
|
||||
const type = String(row.type || '');
|
||||
@@ -55,7 +46,9 @@ function scheduleGalleryThumbnail(row: Record<string, unknown>) {
|
||||
console.warn('[gallery] scheduled thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
} finally {
|
||||
galleryThumbnailProcessing = false;
|
||||
if (galleryThumbnailQueue.size > 0) scheduleGalleryThumbnail(galleryThumbnailQueue.values().next().value as Record<string, unknown>);
|
||||
if (galleryThumbnailQueue.size > 0) {
|
||||
scheduleGalleryThumbnail(galleryThumbnailQueue.values().next().value as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -117,7 +110,12 @@ export async function GET(request: NextRequest) {
|
||||
SELECT w.id, w.type, w.title, w.prompt, w.negative_prompt, w.result_url, w.thumbnail_url,
|
||||
w.width, w.height, w.duration, w.is_public, w.likes_count, w.credits_cost,
|
||||
w.status, w.created_at, w.user_id, w.params,
|
||||
p.nickname, p.display_nickname, p.email, p.avatar_url
|
||||
p.nickname, p.display_nickname, p.email,
|
||||
CASE
|
||||
WHEN p.avatar_url IS NULL OR p.avatar_url = '' THEN NULL
|
||||
WHEN p.avatar_url LIKE 'data:%' OR length(p.avatar_url) > ${MAX_PUBLIC_GALLERY_AVATAR_URL_LENGTH} THEN NULL
|
||||
ELSE p.avatar_url
|
||||
END AS avatar_url
|
||||
FROM works w
|
||||
LEFT JOIN profiles p ON p.id = w.user_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
@@ -142,31 +140,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
for (const row of result.rows || []) scheduleGalleryThumbnail(row);
|
||||
const rows = result.rows || [];
|
||||
const works = rows.map((w: Record<string, unknown>) => {
|
||||
const workParams = (w.params || {}) as Record<string, unknown>;
|
||||
const references = getReferenceImages(workParams);
|
||||
return {
|
||||
id: w.id,
|
||||
type: w.type,
|
||||
title: w.title,
|
||||
prompt: w.prompt,
|
||||
negativePrompt: w.negative_prompt,
|
||||
url: w.result_url,
|
||||
thumbnailUrl: w.thumbnail_url,
|
||||
width: w.width,
|
||||
height: w.height,
|
||||
duration: w.duration,
|
||||
likes: w.likes_count || 0,
|
||||
creditsCost: w.credits_cost || 0,
|
||||
params: workParams,
|
||||
referenceImage: references.referenceImage,
|
||||
referenceImages: references.referenceImages,
|
||||
publisherId: w.user_id,
|
||||
publisherNickname: (w.display_nickname as string) || (w.nickname as string) || ((w.email as string) || '').split('@')[0] || '匿名用户',
|
||||
publisherAvatarUrl: (w.avatar_url as string | null) || null,
|
||||
publishedAt: w.created_at,
|
||||
};
|
||||
});
|
||||
const works = rows.map((row: Record<string, unknown>) => toPublicGalleryWork(row));
|
||||
|
||||
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||
const nextOffset = offset + works.length;
|
||||
|
||||
@@ -30,6 +30,8 @@ import { FullscreenPreview } from '@/components/fullscreen-preview';
|
||||
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
|
||||
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';
|
||||
import { getPublicGalleryAvatarUrl } from '@/lib/gallery-response';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -43,8 +45,6 @@ const CATEGORIES = [
|
||||
const GALLERY_PAGE_SIZE = 18;
|
||||
const GALLERY_CACHE_KEY = 'miaojing:gallery:v3';
|
||||
const GALLERY_CACHE_VERSION = 3;
|
||||
const GALLERY_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const GALLERY_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const GALLERY_CACHE_MAX_ENTRIES = 12;
|
||||
const GALLERY_CACHE_MAX_WORKS_PER_ENTRY = 72;
|
||||
|
||||
@@ -92,6 +92,19 @@ interface GalleryPageResponse {
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
function sanitizeGalleryWorkForBrowserCache(work: GalleryWork): GalleryWork {
|
||||
const avatarUrl = getPublicGalleryAvatarUrl(work.publisherAvatarUrl);
|
||||
if (avatarUrl === (work.publisherAvatarUrl ?? null)) return work;
|
||||
return { ...work, publisherAvatarUrl: avatarUrl };
|
||||
}
|
||||
|
||||
function sanitizeGalleryCacheEntry(entry: GalleryCacheEntry): GalleryCacheEntry {
|
||||
return {
|
||||
...entry,
|
||||
works: entry.works.map(sanitizeGalleryWorkForBrowserCache),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGalleryCacheSignature(category: string, sortBy: string, searchQuery: string): string {
|
||||
return JSON.stringify({
|
||||
category,
|
||||
@@ -135,6 +148,7 @@ function cleanupGalleryCache(store: GalleryCacheStore | null = readGalleryCacheS
|
||||
const now = Date.now();
|
||||
const validEntries = Object.entries(store.entries)
|
||||
.filter(([, entry]) => now - Number(entry.savedAt || 0) <= GALLERY_CACHE_MAX_AGE_MS)
|
||||
.map(([key, entry]) => [key, sanitizeGalleryCacheEntry(entry)] as const)
|
||||
.sort((a, b) => Number(b[1].savedAt || 0) - Number(a[1].savedAt || 0))
|
||||
.slice(0, GALLERY_CACHE_MAX_ENTRIES);
|
||||
|
||||
@@ -150,8 +164,8 @@ function cleanupGalleryCache(store: GalleryCacheStore | null = readGalleryCacheS
|
||||
function getGalleryCacheEntry(signature: string): GalleryCacheEntry | null {
|
||||
const store = cleanupGalleryCache();
|
||||
const entry = store?.entries?.[signature];
|
||||
if (!entry || Date.now() - Number(entry.savedAt || 0) > GALLERY_CACHE_TTL_MS) return null;
|
||||
return entry;
|
||||
if (!entry || !isGalleryCacheEntryUsable(entry.savedAt)) return null;
|
||||
return sanitizeGalleryCacheEntry(entry);
|
||||
}
|
||||
|
||||
function saveGalleryCacheEntry(signature: string, entry: GalleryCacheEntry) {
|
||||
@@ -161,7 +175,7 @@ function saveGalleryCacheEntry(signature: string, entry: GalleryCacheEntry) {
|
||||
savedAt: now,
|
||||
entries: {},
|
||||
};
|
||||
const cachedWorks = entry.works.slice(0, GALLERY_CACHE_MAX_WORKS_PER_ENTRY);
|
||||
const cachedWorks = entry.works.slice(0, GALLERY_CACHE_MAX_WORKS_PER_ENTRY).map(sanitizeGalleryWorkForBrowserCache);
|
||||
store.entries[signature] = {
|
||||
...entry,
|
||||
works: cachedWorks,
|
||||
@@ -513,6 +527,46 @@ function getEstimatedWorkHeight(work: GalleryWork, measuredSize?: MediaSize): nu
|
||||
return imageHeight + 152 + 16;
|
||||
}
|
||||
|
||||
function GalleryLoadingSkeleton({ columnCount }: { columnCount: number }) {
|
||||
const safeColumnCount = Math.max(2, Math.min(columnCount, 4));
|
||||
const columns = Array.from({ length: safeColumnCount }, (_, columnIndex) => {
|
||||
const heights = [300, 380, 260, 340, 420, 280];
|
||||
return Array.from({ length: 3 }, (_, itemIndex) => heights[(columnIndex + itemIndex) % heights.length]);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="gallery-masonry-grid grid gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${safeColumnCount}, minmax(0, 1fr))` }}
|
||||
aria-label="画廊正在加载"
|
||||
>
|
||||
{columns.map((columnHeights, columnIndex) => (
|
||||
<div key={columnIndex} className="flex min-w-0 flex-col gap-4">
|
||||
{columnHeights.map((height, itemIndex) => (
|
||||
<div key={`${columnIndex}-${itemIndex}`} className="gallery-work-shell">
|
||||
<div className="overflow-hidden rounded-[14px] border border-white/[0.08] bg-white/[0.035] shadow-[0_16px_36px_rgba(0,0,0,0.16)] light:border-amber-900/14 light:bg-white/46">
|
||||
<div
|
||||
className="animate-pulse bg-gradient-to-br from-muted/70 via-muted/35 to-muted/55"
|
||||
style={{ height }}
|
||||
/>
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-6 animate-pulse rounded-full bg-muted/70" />
|
||||
<div className="h-3 w-24 animate-pulse rounded-full bg-muted/70" />
|
||||
</div>
|
||||
<div className="h-3 w-full animate-pulse rounded-full bg-muted/60" />
|
||||
<div className="h-3 w-4/5 animate-pulse rounded-full bg-muted/50" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded-full bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const galleryGlassPanel =
|
||||
'liquid-glass';
|
||||
const galleryGlassCard =
|
||||
@@ -909,10 +963,7 @@ export default function GalleryPage() {
|
||||
|
||||
{/* Gallery Grid */}
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<Sparkles className="h-12 w-12 mb-4 animate-pulse opacity-30" />
|
||||
<p className="text-lg font-serif">加载中...</p>
|
||||
</div>
|
||||
<GalleryLoadingSkeleton columnCount={masonryColumnCount} />
|
||||
) : filteredWorks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<LayoutGrid className="h-16 w-16 mb-4 opacity-30" />
|
||||
|
||||
12
src/lib/gallery-cache-policy.ts
Normal file
12
src/lib/gallery-cache-policy.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const GALLERY_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
export const GALLERY_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function isGalleryCacheEntryFresh(savedAt: unknown, now = Date.now()): boolean {
|
||||
const savedAtMs = Number(savedAt || 0);
|
||||
return Number.isFinite(savedAtMs) && now - savedAtMs <= GALLERY_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
export function isGalleryCacheEntryUsable(savedAt: unknown, now = Date.now()): boolean {
|
||||
const savedAtMs = Number(savedAt || 0);
|
||||
return Number.isFinite(savedAtMs) && now - savedAtMs <= GALLERY_CACHE_MAX_AGE_MS;
|
||||
}
|
||||
79
src/lib/gallery-response.ts
Normal file
79
src/lib/gallery-response.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export const MAX_PUBLIC_GALLERY_AVATAR_URL_LENGTH = 2048;
|
||||
|
||||
export interface PublicGalleryWork {
|
||||
id: unknown;
|
||||
type: unknown;
|
||||
title: unknown;
|
||||
prompt: unknown;
|
||||
negativePrompt: unknown;
|
||||
url: unknown;
|
||||
thumbnailUrl: unknown;
|
||||
width: unknown;
|
||||
height: unknown;
|
||||
duration: unknown;
|
||||
likes: unknown;
|
||||
creditsCost: unknown;
|
||||
params: Record<string, unknown>;
|
||||
referenceImage?: string;
|
||||
referenceImages: string[];
|
||||
publisherId: unknown;
|
||||
publisherNickname: string;
|
||||
publisherAvatarUrl: string | null;
|
||||
publishedAt: unknown;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function getPublicGalleryAvatarUrl(value: unknown): string | null {
|
||||
const avatarUrl = normalizeString(value);
|
||||
if (!avatarUrl) return null;
|
||||
if (avatarUrl.startsWith('data:')) return null;
|
||||
if (avatarUrl.length > MAX_PUBLIC_GALLERY_AVATAR_URL_LENGTH) return null;
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
export function getGalleryReferenceImages(params: Record<string, unknown>) {
|
||||
const referenceImages = Array.isArray(params.referenceImages)
|
||||
? params.referenceImages
|
||||
.map(normalizeString)
|
||||
.filter((item): item is string => item.length > 0)
|
||||
: [];
|
||||
const referenceImage = normalizeString(params.referenceImage) || referenceImages[0];
|
||||
return { referenceImage, referenceImages };
|
||||
}
|
||||
|
||||
export function toPublicGalleryWork(row: Record<string, unknown>): PublicGalleryWork {
|
||||
const workParams = asRecord(row.params);
|
||||
const references = getGalleryReferenceImages(workParams);
|
||||
const emailPrefix = normalizeString(row.email).split('@')[0];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
prompt: row.prompt,
|
||||
negativePrompt: row.negative_prompt,
|
||||
url: row.result_url,
|
||||
thumbnailUrl: row.thumbnail_url,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
duration: row.duration,
|
||||
likes: row.likes_count || 0,
|
||||
creditsCost: row.credits_cost || 0,
|
||||
params: workParams,
|
||||
referenceImage: references.referenceImage,
|
||||
referenceImages: references.referenceImages,
|
||||
publisherId: row.user_id,
|
||||
publisherNickname: normalizeString(row.display_nickname) || normalizeString(row.nickname) || emailPrefix || '匿名用户',
|
||||
publisherAvatarUrl: getPublicGalleryAvatarUrl(row.avatar_url),
|
||||
publishedAt: row.created_at,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user