feat(gallery): add inspiration reuse and image actions

This commit is contained in:
Codex
2026-05-14 09:20:14 +00:00
parent 80a3d3aac8
commit fdee295098
17 changed files with 973 additions and 97 deletions

View File

@@ -30,7 +30,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Policy pages start mid-page after navigation | `src/components/site-policy-page.tsx`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` | Scroll reset behavior and shared policy page wrapper. |
| Site name/logo/favicon not updating | `src/components/site-config-sync.tsx`, `src/components/site-brand.tsx`, `src/app/api/site-config/route.ts`, `src/lib/local-storage.ts` | `site_config` row, base64 image save, generated `/api/local-storage/*` URL. |
| Page content leaves large unused horizontal margins, or wide screens look like the UI was simply enlarged | `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, page-level wrappers under `src/app/*/page.tsx`, `src/components/site-policy-page.tsx` | The viewport/background can be `w-full`, but product content should keep the original component scale and readable containers such as `max-w-7xl`, `max-w-4xl`, or `max-w-3xl`. Do not fix this by removing all max widths or scaling controls up on wide monitors. |
| Scrollbars look native or do not match glass UI in dialogs/pages | `src/app/globals.css` | Global `*::-webkit-scrollbar*` and Firefox `scrollbar-color` rules should provide rounded glass scrollbars for both light and dark themes. Avoid adding one-off scrollbar styles to individual components unless there is a real exception. |
| Scrollbars look native, stay visible when idle, or do not match glass UI in dialogs/pages | `src/app/globals.css`, `src/components/app-shell.tsx` | Global scrollbar styling is hidden by default and becomes visible only while wheel/touch scrolling through the `scrollbars-visible` class on `<html>`. `globals.css` owns both the hidden state and the rounded glass visible state for light/dark themes; `app-shell` owns the short-lived wheel/touch listener. Avoid adding one-off scrollbar styles to individual components unless there is a real exception. |
| Disabled canvas/`画布` appears again in public UI | `src/components/navbar.tsx`, `src/app/canvas/page.tsx`, `docs/codex-miaojing/feature-code-index.md` | Navbar should not include `/canvas`, and `/canvas` should continue to call `notFound()` unless the product explicitly re-enables the legacy canvas feature. |
| Announcement not popping up | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts`, `src/components/app-shell.tsx` | App shell includes popup, active date range, local/session dismissal behavior, GET payload shape. |
| Announcement admin edit fails | `src/components/admin/announcement-tab.tsx`, `src/app/api/announcements/route.ts` | Admin token, required fields, `starts_at`/`expires_at` compatibility. |
@@ -49,8 +49,8 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Wrong image size or aspect ratio | `src/lib/model-config.ts`, `src/app/api/generate/image/route.ts` | `resolveImageSize`, `resolveCustomApiImageSize`, New API/DALL-E size normalization, prompt aspect hint. |
| 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 to local storage, presigned URL returned, history POST called. |
| Fullscreen/preview/download broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. Image result and history/detail previews should show upper-right actual aspect ratio and natural resolution via `ImageMetadataBadge`. |
| Creation detail reuse/edit buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Detail actions should write the shared draft key/event, route to `/create?type=text2img` or `/create?type=img2img`, and the create page should react to query-param changes after navigation. Image-to-image edit should use the current output image as the first reference image. |
| 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. 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`. |
| Creation detail, gallery one-click reuse, or inspiration reuse buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Reuse actions should write the shared draft key/event for `text2img`, `img2img`, `text2video`, or `img2video`, route to the matching `/create?type=...` when leaving gallery, and already-mounted create panels should react to the event. Image-to-image and image-to-video reuse should include reference images from the work, falling back to the output image or thumbnail only when stored references are missing. |
| Image generation count dropdown too wide, options missing, or manual count input unavailable | `src/components/create/image-count-combobox.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Use the shared compact combobox instead of browser `datalist`; verify manual numeric entry and dropdown options in both text-to-image and image-to-image panels. |
| Style presets are hardcoded, missing, or not ordered by usage | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/app/api/generation-jobs/route.ts` | Presets should come from `image_style_presets`; `generation-jobs` increments `usage_count`; GET `/api/style-presets` should return active presets sorted by usage count. |
| Reverse prompt option missing | `src/components/create/reverse-prompt-panel.tsx`, `src/app/api/generate/reverse-prompt/route.ts` | UI option list and server `outputMode` handling both updated, app rebuilt/restarted if deployed. |

View File

@@ -8,7 +8,7 @@ Use this document to jump directly to code before broad searching.
| Feature | Primary Files | Notes |
| --- | --- | --- |
| Root layout and providers | `src/app/layout.tsx`, `src/components/app-shell.tsx`, `src/app/globals.css` | App shell wires navbar, site config sync, visit tracking, theme/account sync, toaster, and full-width page mounting. Keep product content at the original component scale; use centered responsive containers instead of stretching all content to viewport edges. Global scrollbars use a rounded glass style for light/dark themes in `globals.css`. |
| Root layout and providers | `src/app/layout.tsx`, `src/components/app-shell.tsx`, `src/app/globals.css` | App shell wires navbar, site config sync, visit tracking, theme/account sync, toaster, full-width page mounting, and transient scrollbar visibility. Keep product content at the original component scale; use centered responsive containers instead of stretching all content to viewport edges. Global scrollbars are hidden by default and briefly show the rounded glass style when wheel/touch scrolling adds `scrollbars-visible` on `<html>`. |
| Home page | `src/app/page.tsx` | Landing/dashboard-like public entry. Check site config dependencies when changing brand text. |
| Navbar | `src/components/navbar.tsx`, `src/components/site-brand.tsx` | Navigation, brand display, auth-aware links. User-facing nav intentionally excludes the disabled legacy canvas route. |
| Footer | `src/components/site-footer.tsx` | Uses site config for policy/help/about links and filing text; footer background spans browser width while inner content keeps the original `max-w-7xl` scale. |
@@ -23,6 +23,7 @@ Use this document to jump directly to code before broad searching.
| `/` | `src/app/page.tsx` | `src/components/app-shell.tsx` |
| `/create` | `src/app/create/page.tsx` | `src/components/create/*` |
| `/gallery` | `src/app/gallery/page.tsx` | `src/lib/creation-history-store.ts`, `src/app/api/gallery/route.ts` |
| `/image-viewer` | `src/app/image-viewer/page.tsx` | Fullscreen original-image share page opened from image right-click share links. |
| `/profile` | `src/app/profile/page.tsx` | `src/components/profile/*`, `src/app/api/profile/route.ts` |
| `/canvas` | `src/app/canvas/page.tsx` | Disabled legacy route; currently calls `notFound()` and must not be linked from navbar. |
| `/about` | `src/app/about/page.tsx` | `src/components/site-policy-page.tsx`, `src/lib/site-policy-defaults.ts` |
@@ -49,17 +50,17 @@ Use this document to jump directly to code before broad searching.
| Feature | Primary Files | Server/API Files |
| --- | --- | --- |
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. |
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx` inside the results column. It also consumes image reuse drafts from `src/lib/creation-reuse.ts` so creation details can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the text-to-image form. |
| Image to image | `src/components/create/image-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. Reference thumbnails single-click into a bare image overlay, and active jobs render through `src/components/create/generation-task-list.tsx`. It also consumes image reuse drafts from `src/lib/creation-reuse.ts` so creation details can place the selected output image as the reference image and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the image-to-image form. |
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx`. |
| Image to video | `src/components/create/image-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image, and active jobs render through `src/components/create/generation-task-list.tsx`. |
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx` inside the results column. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. |
| Image to image | `src/components/create/image-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. Reference thumbnails single-click into a bare image overlay, and active jobs render through `src/components/create/generation-task-list.tsx`. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. |
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx`. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-video works can fill prompt, negative prompt, model, ratio, duration, camera movement, and style. |
| Image to video | `src/components/create/image-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image, and active jobs render through `src/components/create/generation-task-list.tsx`. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-video works can place reference images and fill prompt, negative prompt, model, ratio, duration, and camera movement. |
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts` |
| Prompt textarea | `src/components/create/expandable-prompt-textarea.tsx` | Shared prompt input. |
| Image count input/dropdown | `src/components/create/image-count-combobox.tsx` | Shared compact count control for manual image count entry and common dropdown options. |
| Style presets | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/model-config.ts` | Style presets are stored in `image_style_presets`, seeded from defaults, sorted by `usage_count`, and incremented from image generation jobs. |
| Loading/error panels | `src/components/create/generation-loading-panel.tsx`, `src/components/create/generation-task-list.tsx`, `src/components/create/generation-error-panel.tsx` | Shared generation status UI. `generation-task-list` keeps multiple active job cards constrained to the results column. |
| Creation reuse drafts | `src/lib/creation-reuse.ts`, `src/app/create/page.tsx` | Shared localStorage/event bridge used by detail and reverse-prompt actions to prefill create panels. `/create?type=...` changes the active tab after navigation, so callers can route directly to text-to-image or image-to-image. |
| Lightbox/fullscreen/detail actions | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-metadata-badge.tsx` | Preview, copy, download, share, reuse config, edit output, and delete work. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. Image previews show actual natural resolution and computed aspect ratio in the upper-right metadata badge. `BareImagePreview` is the no-container overlay for uploaded reference image previews. |
| Creation reuse drafts | `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx` | Shared localStorage/event bridge used by detail, reverse-prompt, gallery, and inspiration actions to prefill create panels. It supports `text2img`, `img2img`, `text2video`, and `img2video` draft keys/events; `/create?type=...` changes the active tab after navigation, so callers can route directly to the matching creation mode. The inspiration dialog filters to the current mode, keeps per-card mode labels hidden, and offers a fuzzy search box that animates leftward from the header search icon; empty searches auto-collapse after the pointer leaves the search control for 1 second, while non-empty searches stay open until the dialog closes. |
| Lightbox/fullscreen/detail actions | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.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` | Image cards, detail images, reference thumbnails, and generation results should enter fullscreen preview on single click, not double-click. Detail and fullscreen images use the shared right-click image action menu for copy, download, edit-to-image-to-image, and share; these actions must receive the original image URL, not thumbnails or cached display blobs. Share copies a `/image-viewer?url=...` full-display link for the original image. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. Image previews show actual natural resolution and computed aspect ratio in the upper-right metadata badge. `BareImagePreview` is the no-container overlay for uploaded reference image previews. |
## Generation System
@@ -102,7 +103,7 @@ 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. 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 buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution. |
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download, and one-click reuse. 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. |
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works, admin DELETE unpublishes. |
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies media into gallery folders and inserts public work. |
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works and published state. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |

View File

@@ -29,6 +29,8 @@ import { usePublishedWorks, useCreationHistory, syncPublishedToSupabase } from '
import { useAuth } from '@/lib/auth-store';
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 { toast } from 'sonner';
const CATEGORIES = [
@@ -148,6 +150,10 @@ function getCreateUrlForCategory(category: string): string {
return `/create?type=${type}`;
}
function isCreationReuseTarget(value: string): value is CreationReuseTarget {
return value === 'text2img' || value === 'img2img' || value === 'text2video' || value === 'img2video';
}
type MediaSize = { width: number; height: number };
type GalleryCardPalette = {
accent1: string;
@@ -407,6 +413,7 @@ export default function GalleryPage() {
const [measuredMediaSizes, setMeasuredMediaSizes] = useState<Record<string, MediaSize>>({});
const [cardPalettes, setCardPalettes] = useState<Record<string, GalleryCardPalette>>({});
const [selectedGalleryIds, setSelectedGalleryIds] = useState<Set<string>>(new Set());
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
useEffect(() => {
const updateColumnCount = () => {
@@ -610,6 +617,22 @@ export default function GalleryPage() {
}
};
const handleReuseGalleryWork = (work: GalleryWork, e?: React.MouseEvent) => {
e?.stopPropagation();
const target = getCategoryFromWork(work);
if (!isCreationReuseTarget(target)) {
toast.error('该作品暂不支持一键复用');
return;
}
const draft = buildCreationReuseDraft(work, target, {
source: 'gallery',
useOutputAsReference: true,
});
writeCreationReuseDraft(target, draft);
toast.success('已带入创作参数');
window.location.href = getCreateUrlForCategory(target);
};
const toggleSelectGalleryWork = (id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
setSelectedGalleryIds(prev => {
@@ -782,7 +805,8 @@ export default function GalleryPage() {
className="block h-auto w-full object-contain"
loading="lazy"
onLoad={(e) => handleCardImageLoad(work.id, e)}
onDoubleClick={(e) => { e.stopPropagation(); setFullscreenSrc(work.url); }}
onClick={(e) => { e.stopPropagation(); setFullscreenSrc(work.url); }}
onContextMenu={(e) => openImageMenu(e, work.url)}
/>
) : (
<div className="flex aspect-square w-full flex-col items-center justify-center bg-gradient-to-br from-muted to-muted/50">
@@ -820,6 +844,15 @@ export default function GalleryPage() {
>
<Heart className={`h-4 w-4 ${likedIds.has(work.id) ? 'fill-current' : ''}`} />
</Button>
<Button
size="sm"
variant="secondary"
className="gallery-work-action-button pointer-events-auto h-9 w-9 p-0"
onClick={(e) => handleReuseGalleryWork(work, e)}
title="一键复用"
>
<Sparkles className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="secondary"
@@ -913,7 +946,8 @@ export default function GalleryPage() {
src={selectedWork.url}
alt={(selectedWork.prompt || '').slice(0, 30)}
className="relative z-10 h-full w-full cursor-zoom-in object-contain"
onDoubleClick={() => setFullscreenSrc(selectedWork.url)}
onClick={() => setFullscreenSrc(selectedWork.url)}
onContextMenu={(event) => openImageMenu(event, selectedWork.url)}
/>
)}
{selectedWork.type !== 'video' && selectedWork.type !== 'text2video' && selectedWork.type !== 'img2video' && (
@@ -1048,7 +1082,8 @@ export default function GalleryPage() {
src={url}
alt={`参考图 ${index + 1}`}
className="aspect-square w-full cursor-zoom-in object-cover"
onDoubleClick={() => setFullscreenSrc(url)}
onClick={() => setFullscreenSrc(url)}
onContextMenu={(event) => openImageMenu(event, url)}
/>
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-black/35 p-1 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
<button
@@ -1117,11 +1152,11 @@ export default function GalleryPage() {
</div>
)}
<div className="flex items-center justify-between gap-3 border-t border-white/[0.07] light:border-amber-900/14 pt-4">
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/[0.07] light:border-amber-900/14 pt-4">
<Button
size="sm"
variant={likedIds.has(selectedWork.id) ? 'default' : 'outline'}
className="h-9 min-w-[92px] gap-1.5 px-3 text-sm font-semibold"
className="mr-auto h-9 min-w-[92px] gap-1.5 px-3 text-sm font-semibold"
onClick={() => toggleLike(selectedWork.id)}
>
<Heart className={`h-3.5 w-3.5 ${likedIds.has(selectedWork.id) ? 'fill-current' : ''}`} />
@@ -1146,6 +1181,14 @@ export default function GalleryPage() {
</Button>
)}
<Button
size="sm"
className="h-9 min-w-[112px] gap-1.5 px-3 text-sm font-semibold"
onClick={(e) => handleReuseGalleryWork(selectedWork, e)}
>
<Sparkles className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
@@ -1161,6 +1204,7 @@ export default function GalleryPage() {
open={!!fullscreenSrc}
onClose={() => setFullscreenSrc(null)}
/>
{ImageActionsContextMenu}
</div>
);
}

View File

@@ -575,16 +575,26 @@
}
* {
scrollbar-width: none;
scrollbar-color: transparent transparent;
}
*::-webkit-scrollbar {
width: 0;
height: 0;
}
html.scrollbars-visible * {
scrollbar-width: thin;
scrollbar-color: rgb(151 114 58 / 0.58) rgb(255 255 255 / 0.22);
}
*::-webkit-scrollbar {
html.scrollbars-visible *::-webkit-scrollbar {
width: 14px;
height: 14px;
}
*::-webkit-scrollbar-track {
html.scrollbars-visible *::-webkit-scrollbar-track {
border: 1px solid rgb(116 88 43 / 0.16);
border-radius: 999px;
background:
@@ -598,7 +608,7 @@
-webkit-backdrop-filter: blur(14px) saturate(128%);
}
*::-webkit-scrollbar-thumb {
html.scrollbars-visible *::-webkit-scrollbar-thumb {
min-height: 48px;
min-width: 48px;
border: 4px solid transparent;
@@ -611,27 +621,27 @@
0 3px 9px rgb(92 69 32 / 0.18);
}
*::-webkit-scrollbar-thumb:hover {
html.scrollbars-visible *::-webkit-scrollbar-thumb:hover {
background:
linear-gradient(180deg, rgb(191 145 72 / 0.86), rgb(135 101 51 / 0.76))
padding-box;
}
*::-webkit-scrollbar-button {
html.scrollbars-visible *::-webkit-scrollbar-button {
display: none;
width: 0;
height: 0;
}
*::-webkit-scrollbar-corner {
html.scrollbars-visible *::-webkit-scrollbar-corner {
background: transparent;
}
.dark * {
.dark.scrollbars-visible * {
scrollbar-color: rgb(237 190 104 / 0.58) rgb(255 255 255 / 0.08);
}
.dark *::-webkit-scrollbar-track {
.dark.scrollbars-visible *::-webkit-scrollbar-track {
border-color: rgb(255 255 255 / 0.10);
background:
linear-gradient(180deg, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.045)),
@@ -642,7 +652,7 @@
0 8px 22px rgb(0 0 0 / 0.20);
}
.dark *::-webkit-scrollbar-thumb {
.dark.scrollbars-visible *::-webkit-scrollbar-thumb {
background:
linear-gradient(180deg, rgb(237 190 104 / 0.70), rgb(145 103 46 / 0.68))
padding-box;
@@ -651,7 +661,7 @@
0 3px 10px rgb(0 0 0 / 0.30);
}
.dark *::-webkit-scrollbar-thumb:hover {
.dark.scrollbars-visible *::-webkit-scrollbar-thumb:hover {
background:
linear-gradient(180deg, rgb(250 205 119 / 0.84), rgb(165 118 52 / 0.78))
padding-box;

View File

@@ -0,0 +1,56 @@
'use client';
import { Suspense, useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
function normalizeImageUrl(value: string | null): string {
if (!value) return '';
const trimmed = value.trim();
if (!trimmed || trimmed.startsWith('data:') || trimmed.startsWith('[')) return '';
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed;
try {
const url = new URL(trimmed);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : '';
} catch {
return '';
}
}
function ImageViewerContent() {
const searchParams = useSearchParams();
const imageUrl = useMemo(() => normalizeImageUrl(searchParams.get('url')), [searchParams]);
if (!imageUrl) {
return (
<main className="fixed inset-0 z-[2147483000] flex items-center justify-center bg-black text-sm text-white/70">
</main>
);
}
return (
<main className="fixed inset-0 z-[2147483000] flex items-center justify-center overflow-auto bg-black p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt="原图预览"
className="max-h-[calc(100vh-32px)] max-w-[calc(100vw-32px)] select-auto object-contain"
draggable
/>
</main>
);
}
export default function ImageViewerPage() {
return (
<Suspense
fallback={
<main className="fixed inset-0 z-[2147483000] flex items-center justify-center bg-black text-sm text-white/70">
</main>
}
>
<ImageViewerContent />
</Suspense>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import type { ReactNode } from 'react';
import { useEffect, useRef, type ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { Toaster } from '@/components/ui/sonner';
import { Navbar } from '@/components/navbar';
@@ -11,6 +11,32 @@ import { AccountThemeSync } from '@/components/account-theme-sync';
export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const isConsole = pathname === '/console' || pathname.startsWith('/console/');
const scrollbarTimerRef = useRef<number | null>(null);
useEffect(() => {
const root = document.documentElement;
const showScrollbars = () => {
root.classList.add('scrollbars-visible');
if (scrollbarTimerRef.current !== null) {
window.clearTimeout(scrollbarTimerRef.current);
}
scrollbarTimerRef.current = window.setTimeout(() => {
root.classList.remove('scrollbars-visible');
scrollbarTimerRef.current = null;
}, 900);
};
window.addEventListener('wheel', showScrollbars, { passive: true, capture: true });
window.addEventListener('touchmove', showScrollbars, { passive: true, capture: true });
return () => {
window.removeEventListener('wheel', showScrollbars, { capture: true });
window.removeEventListener('touchmove', showScrollbars, { capture: true });
if (scrollbarTimerRef.current !== null) {
window.clearTimeout(scrollbarTimerRef.current);
}
root.classList.remove('scrollbars-visible');
};
}, []);
return (
<>

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState, type CSSProperties, type ReactEventHandler } from 'react';
import { useEffect, useMemo, useState, type CSSProperties, type ReactEventHandler, type MouseEventHandler } from 'react';
type CachedPreviewImageProps = {
src: string;
@@ -10,6 +10,7 @@ type CachedPreviewImageProps = {
badgeClassName?: string;
onDoubleClick?: () => void;
onClick?: () => void;
onContextMenu?: MouseEventHandler<HTMLImageElement>;
onLoad?: ReactEventHandler<HTMLImageElement>;
};
@@ -160,6 +161,7 @@ export function CachedPreviewImage({
badgeClassName = 'absolute right-2 top-2 z-10',
onDoubleClick,
onClick,
onContextMenu,
onLoad,
}: CachedPreviewImageProps) {
const [previewUrl, setPreviewUrl] = useState('');
@@ -241,6 +243,7 @@ export function CachedPreviewImage({
style={style}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
onLoad={handleLoad}
loading="lazy"
decoding="async"

View File

@@ -47,6 +47,7 @@ import { StylePresetSelector } from '@/components/create/style-preset-selector';
import { useImageStylePresets } from '@/lib/style-presets-client';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
import { CachedPreviewImage } from '@/components/create/cached-preview-image';
import { InspirationGalleryDialog } from '@/components/create/inspiration-gallery-dialog';
import { IMAGE_TO_IMAGE_DRAFT_EVENT, IMAGE_TO_IMAGE_DRAFT_KEY, type ImageCreationReuseDraft } from '@/lib/creation-reuse';
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
@@ -90,6 +91,7 @@ export function ImageToImagePanel() {
const [results, setResults] = useState<string[]>([]);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const [inspirationOpen, setInspirationOpen] = useState(false);
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
const generating = activeTasks.length > 0;
@@ -556,7 +558,9 @@ export function ImageToImagePanel() {
toast.success('已分享到画廊');
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel]);
return (
return (
<>
<InspirationGalleryDialog mode="img2img" open={inspirationOpen} onOpenChange={setInspirationOpen} />
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
{/* Left: Settings */}
<div className="min-w-0 space-y-5 pb-8 pr-2">
@@ -640,15 +644,21 @@ export function ImageToImagePanel() {
{/* Prompt */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label></Label>
{textModelOptions.length > 0 && (
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{optimizing ? '优化中...' : '优化提示词'}
</Button>
)}
</div>
<div className="flex items-center justify-between">
<Label></Label>
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={() => setInspirationOpen(true)}>
<Sparkles className="h-3 w-3" />
</Button>
{textModelOptions.length > 0 && (
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{optimizing ? '优化中...' : '优化提示词'}
</Button>
)}
</div>
</div>
<ExpandablePromptTextarea
title="创作描述"
placeholder="描述你想要的图片变化..."
@@ -727,7 +737,7 @@ export function ImageToImagePanel() {
{/* Strength */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between">
<Label></Label>
<span className="text-xs text-muted-foreground">{strength.toFixed(2)}</span>
</div>
@@ -763,7 +773,7 @@ export function ImageToImagePanel() {
src={url}
alt={`生成结果 ${i + 1}`}
className="w-full aspect-square object-cover cursor-zoom-in"
onDoubleClick={() => setLightboxSrc(url)}
onClick={() => setLightboxSrc(url)}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<Button size="sm" variant="secondary" className="gap-1" onClick={() => setLightboxSrc(url)}><ImageIcon className="h-3.5 w-3.5" /></Button>
@@ -830,6 +840,7 @@ export function ImageToImagePanel() {
setSelectedHistoryRecord(null);
}}
/>
</div>
);
}
</div>
</>
);
}

View File

@@ -33,6 +33,8 @@ import { ExpandablePromptTextarea } from '@/components/create/expandable-prompt-
import { compressImageFileForUpload } from '@/lib/browser-image-compression';
import { BareImagePreview } from '@/components/lightbox';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
import { InspirationGalleryDialog } from '@/components/create/inspiration-gallery-dialog';
import { IMAGE_TO_VIDEO_DRAFT_EVENT, IMAGE_TO_VIDEO_DRAFT_KEY, type CreationReuseDraft } from '@/lib/creation-reuse';
interface RefImage {
id: string;
@@ -58,6 +60,7 @@ export function ImageToVideoPanel() {
const [results, setResults] = useState<string[]>([]);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const [inspirationOpen, setInspirationOpen] = useState(false);
const [referencePreviewSrc, setReferencePreviewSrc] = useState<string | null>(null);
const generating = activeTasks.length > 0;
@@ -80,13 +83,60 @@ export function ImageToVideoPanel() {
const hasModels = modelOptions.length > 0;
const [selectedModel, setSelectedModel] = useState('');
useEffect(() => {
if (modelOptions.length > 0 && !modelOptions.find(o => o.id === selectedModel)) {
const [selectedModel, setSelectedModel] = useState('');
useEffect(() => {
if (modelOptions.length > 0 && !modelOptions.find(o => o.id === selectedModel)) {
const customOpt = modelOptions.find(o => o.group === '自定义模型');
setSelectedModel(customOpt ? customOpt.id : modelOptions[0].id);
}
}, [modelOptions, selectedModel]);
}
}, [modelOptions, selectedModel]);
const applyImageToVideoDraft = useCallback((draft: unknown) => {
if (!draft || typeof draft !== 'object') return;
const data = draft as CreationReuseDraft;
if (typeof data.prompt === 'string') setPrompt(data.prompt);
if (typeof data.negativePrompt === 'string') setNegativePrompt(data.negativePrompt);
if (typeof data.model === 'string' && data.model.trim()) setSelectedModel(data.model.trim());
if (typeof data.aspectRatio === 'string' && data.aspectRatio.trim()) setAspectRatio(data.aspectRatio.trim());
if (typeof data.duration === 'string' && data.duration.trim()) setDuration(data.duration.trim());
if (typeof data.cameraMovement === 'string' && data.cameraMovement.trim()) setCameraMovement(data.cameraMovement.trim());
const rawReferences = Array.isArray(data.referenceImages)
? data.referenceImages
: typeof data.referenceImage === 'string'
? [data.referenceImage]
: [];
const references = rawReferences.filter((item): item is string => (
typeof item === 'string' && (
item.startsWith('data:image/') ||
/^https?:\/\/\S+/i.test(item) ||
item.startsWith('/api/local-storage/')
)
));
if (references.length > 0) {
setRefImages(references.map((dataUrl, index) => ({
id: `draft-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 6)}`,
dataUrl,
name: index === 0 ? '复用参考图' : `复用参考图 ${index + 1}`,
})));
}
}, []);
useEffect(() => {
try {
const raw = window.localStorage.getItem(IMAGE_TO_VIDEO_DRAFT_KEY);
if (raw) applyImageToVideoDraft(JSON.parse(raw));
} catch {
// Ignore malformed local draft data.
}
const handleDraft = (event: Event) => {
applyImageToVideoDraft((event as CustomEvent).detail);
};
window.addEventListener(IMAGE_TO_VIDEO_DRAFT_EVENT, handleDraft);
return () => window.removeEventListener(IMAGE_TO_VIDEO_DRAFT_EVENT, handleDraft);
}, [applyImageToVideoDraft]);
const textModelOptions = useMemo(() => [
...textKeys.map(k => ({ id: buildCustomModelId(k.id), label: `${k.modelName || k.provider} (自定义)`, config: { customApiKeyId: k.id, modelName: k.modelName } })),
@@ -344,7 +394,9 @@ export function ImageToVideoPanel() {
toast.success('已分享到画廊');
}, [prompt, selectedModel, getCurrentModelLabel]);
return (
return (
<>
<InspirationGalleryDialog mode="img2video" open={inspirationOpen} onOpenChange={setInspirationOpen} />
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
{/* Left: Settings */}
<div className="min-w-0 space-y-5 pb-8 pr-2">
@@ -427,15 +479,21 @@ export function ImageToVideoPanel() {
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label> <span className="text-muted-foreground text-xs">()</span></Label>
{textModelOptions.length > 0 && (
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{optimizing ? '优化中...' : '优化提示词'}
</Button>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Label> <span className="text-muted-foreground text-xs">()</span></Label>
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={() => setInspirationOpen(true)}>
<Sparkles className="h-3 w-3" />
</Button>
{textModelOptions.length > 0 && (
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{optimizing ? '优化中...' : '优化提示词'}
</Button>
)}
</div>
</div>
<ExpandablePromptTextarea
title="视频描述"
placeholder="描述你想要的视频效果..."
@@ -569,5 +627,6 @@ export function ImageToVideoPanel() {
/>
<BareImagePreview src={referencePreviewSrc || ''} open={!!referencePreviewSrc} onClose={() => setReferencePreviewSrc(null)} />
</div>
</>
);
}

View File

@@ -0,0 +1,327 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ArrowLeft, Image as ImageIcon, Loader2, Search, Sparkles, X } from 'lucide-react';
import { buildCreationReuseDraft, type CreationReuseTarget, writeCreationReuseDraft } from '@/lib/creation-reuse';
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
import { toast } from 'sonner';
type InspirationWork = {
id: string;
type: string;
prompt?: string | null;
negativePrompt?: string | null;
url: string;
thumbnailUrl?: string | null;
duration?: number | null;
params: Record<string, unknown>;
referenceImage?: string | null;
referenceImages?: string[];
publisherNickname?: string | null;
publishedAt?: string | null;
};
const MODE_LABELS: Record<CreationReuseTarget, string> = {
text2img: '文生图',
img2img: '图生图',
text2video: '文生视频',
img2video: '图生视频',
};
function getWorkMode(work: InspirationWork): CreationReuseTarget {
const mode = work.params?.creationMode || work.params?.workType || work.params?.mode;
if (mode === 'text2img' || mode === 'img2img' || mode === 'text2video' || mode === 'img2video') {
return mode;
}
if (work.type === 'text2video' || work.type === 'img2video' || work.type === 'img2img') {
return work.type;
}
const hasReference =
Boolean(work.referenceImage) ||
(Array.isArray(work.referenceImages) && work.referenceImages.length > 0) ||
Boolean(work.params?.referenceImage) ||
(Array.isArray(work.params?.referenceImages) && work.params.referenceImages.length > 0);
if (work.type === 'video' || work.duration) return hasReference ? 'img2video' : 'text2video';
return hasReference ? 'img2img' : 'text2img';
}
function isVideoWork(work: InspirationWork): boolean {
const mode = getWorkMode(work);
return mode === 'text2video' || mode === 'img2video';
}
function formatDate(iso?: string | null): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
} catch {
return iso;
}
}
export function InspirationGalleryDialog({
mode,
open,
onOpenChange,
}: {
mode: CreationReuseTarget;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [works, setWorks] = useState<InspirationWork[]>([]);
const [loading, setLoading] = useState(false);
const [selectedWork, setSelectedWork] = useState<InspirationWork | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
const searchCollapseTimerRef = useRef<number | null>(null);
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
const clearSearchCollapseTimer = useCallback(() => {
if (searchCollapseTimerRef.current !== null) {
window.clearTimeout(searchCollapseTimerRef.current);
searchCollapseTimerRef.current = null;
}
}, []);
const scheduleSearchCollapse = useCallback(() => {
clearSearchCollapseTimer();
if (searchQuery.trim()) return;
searchCollapseTimerRef.current = window.setTimeout(() => {
setSearchOpen(false);
searchCollapseTimerRef.current = null;
}, 1000);
}, [clearSearchCollapseTimer, searchQuery]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
fetch('/api/gallery?sort=newest&limit=300')
.then(res => res.ok ? res.json() : Promise.reject(new Error('画廊加载失败')))
.then(data => {
if (!cancelled) setWorks(Array.isArray(data.works) ? data.works : []);
})
.catch(err => {
if (!cancelled) toast.error(err instanceof Error ? err.message : '画廊加载失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [open]);
useEffect(() => {
if (!open) {
setSelectedWork(null);
setSearchOpen(false);
setSearchQuery('');
clearSearchCollapseTimer();
}
}, [clearSearchCollapseTimer, open]);
useEffect(() => {
if (!searchOpen) return;
searchInputRef.current?.focus();
}, [searchOpen]);
useEffect(() => {
return () => clearSearchCollapseTimer();
}, [clearSearchCollapseTimer]);
const modeWorks = useMemo(
() => works.filter(work => getWorkMode(work) === mode),
[works, mode],
);
const filteredWorks = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return modeWorks;
return modeWorks.filter(work => {
const haystack = [
work.prompt,
work.negativePrompt,
work.publisherNickname,
work.type,
work.params?.model,
work.params?.modelLabel,
work.params?.styleLabel,
].map(value => String(value || '').toLowerCase()).join('\n');
return haystack.includes(query);
});
}, [modeWorks, searchQuery]);
const handleReuse = useCallback((work: InspirationWork) => {
const draft = buildCreationReuseDraft(work, mode, {
source: 'inspiration-gallery',
useOutputAsReference: true,
});
writeCreationReuseDraft(mode, draft);
toast.success('已带入创作参数');
onOpenChange(false);
}, [mode, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[90vh] w-[min(96vw,1120px)] !max-w-[1120px] overflow-hidden p-0 sm:!max-w-[1120px]" showCloseButton={false}>
<div className="flex h-full min-h-0 flex-col">
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
</DialogTitle>
<DialogDescription>
{MODE_LABELS[mode]}
</DialogDescription>
</div>
<div
className="relative flex shrink-0 items-center gap-1.5"
onMouseEnter={clearSearchCollapseTimer}
onMouseLeave={scheduleSearchCollapse}
>
<div
className={`absolute right-11 top-1/2 z-10 flex h-10 -translate-y-1/2 origin-right items-center gap-2 overflow-hidden rounded-2xl border border-amber-900/12 bg-white/72 px-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.82),inset_0_0_0_1px_rgba(255,255,255,0.28),0_10px_28px_rgba(83,61,27,0.12)] backdrop-blur-xl transition-[width,opacity,transform] duration-300 ease-out focus-within:border-primary/35 focus-within:bg-white/82 dark:border-white/10 dark:bg-white/[0.07] dark:focus-within:bg-white/[0.10] ${
searchOpen ? 'w-[min(56vw,320px)] scale-x-100 opacity-100' : 'pointer-events-none w-0 scale-x-90 opacity-0'
}`}
>
<input
ref={searchInputRef}
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value);
if (event.target.value.trim()) clearSearchCollapseTimer();
}}
onFocus={clearSearchCollapseTimer}
placeholder={`搜索当前${MODE_LABELS[mode]}作品`}
className="gallery-search-input h-full min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-sm font-medium text-foreground shadow-none outline-none ring-0 placeholder:text-muted-foreground/62 focus:outline-none focus:ring-0"
/>
{searchQuery && (
<button
type="button"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => setSearchQuery('')}
aria-label="清空搜索"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSearchOpen(true);
clearSearchCollapseTimer();
}}
title="搜索"
>
<Search className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</DialogHeader>
{selectedWork ? (
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_380px]">
<div className="min-h-0 overflow-hidden bg-black/10 p-5">
<div className="mb-3 flex items-center justify-between gap-3">
<Button variant="ghost" size="sm" className="gap-1.5" onClick={() => setSelectedWork(null)}>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
<div className="flex h-[calc(100%-44px)] min-h-0 items-center justify-center overflow-hidden rounded-xl border border-border/70 bg-black/25">
{isVideoWork(selectedWork) ? (
<video src={selectedWork.url} controls className="h-full w-full object-contain" />
) : (
<img
src={selectedWork.url}
alt={selectedWork.prompt || '作品详情'}
className="h-full w-full object-contain"
onContextMenu={(event) => openImageMenu(event, selectedWork.url)}
/>
)}
</div>
</div>
<aside className="flex min-h-0 flex-col gap-3 overflow-hidden border-t border-border/60 bg-background/52 p-4 lg:border-l lg:border-t-0">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{selectedWork.publisherNickname || '画廊作品'}</span>
<span className="text-xs text-muted-foreground">{formatDate(selectedWork.publishedAt)}</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-xl border border-border/70 bg-background/50 p-3">
<p className="whitespace-pre-wrap break-words text-sm leading-6">{selectedWork.prompt || '无提示词'}</p>
{selectedWork.negativePrompt && (
<p className="mt-3 border-t border-border/60 pt-3 text-xs leading-5 text-muted-foreground">
{selectedWork.negativePrompt}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button size="sm" onClick={() => handleReuse(selectedWork)}>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
</Button>
</div>
</aside>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-5">
{loading ? (
<div className="flex h-full min-h-[420px] flex-col items-center justify-center text-muted-foreground">
<Loader2 className="mb-3 h-8 w-8 animate-spin" />
<span className="text-sm"></span>
</div>
) : filteredWorks.length === 0 ? (
<div className="flex h-full min-h-[420px] flex-col items-center justify-center text-muted-foreground">
<ImageIcon className="mb-3 h-10 w-10 opacity-30" />
<span className="text-sm">{searchQuery.trim() ? '没有匹配的作品' : `暂无可复用的${MODE_LABELS[mode]}作品`}</span>
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{filteredWorks.map(work => {
const previewUrl = work.thumbnailUrl || work.url;
const video = isVideoWork(work);
return (
<button
key={work.id}
type="button"
className="group overflow-hidden rounded-xl border border-border/70 bg-background/45 text-left transition hover:border-primary/50 hover:shadow-[0_14px_36px_rgba(0,0,0,0.16)]"
onClick={() => setSelectedWork(work)}
>
<div className="relative aspect-[4/5] overflow-hidden bg-muted">
{video && !work.thumbnailUrl ? (
<video src={previewUrl} className="h-full w-full object-cover" preload="metadata" />
) : (
<img src={previewUrl} alt={work.prompt || '画廊作品'} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" loading="lazy" />
)}
</div>
<div className="p-3">
<p className="line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">{work.prompt || '无提示词'}</p>
</div>
</button>
);
})}
</div>
)}
</div>
)}
{ImageActionsContextMenu}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -45,6 +45,7 @@ import { StylePresetSelector } from '@/components/create/style-preset-selector';
import { useImageStylePresets } from '@/lib/style-presets-client';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
import { CachedPreviewImage } from '@/components/create/cached-preview-image';
import { InspirationGalleryDialog } from '@/components/create/inspiration-gallery-dialog';
import { TEXT_TO_IMAGE_DRAFT_EVENT, TEXT_TO_IMAGE_DRAFT_KEY, type ImageCreationReuseDraft } from '@/lib/creation-reuse';
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
@@ -79,6 +80,7 @@ export function TextToImagePanel() {
const [results, setResults] = useState<string[]>([]);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const [inspirationOpen, setInspirationOpen] = useState(false);
const activeSubmissionSignaturesRef = useRef(new Set<string>());
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
const generating = activeTasks.length > 0;
@@ -485,6 +487,8 @@ export function TextToImagePanel() {
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel]);
return (
<>
<InspirationGalleryDialog mode="text2img" open={inspirationOpen} onOpenChange={setInspirationOpen} />
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
{/* Left: Settings (scrollable) */}
<div className="min-w-0 space-y-5 pb-8 pr-2">
@@ -513,14 +517,20 @@ export function TextToImagePanel() {
{/* Prompt */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label></Label>
{textModelOptions.length > 0 && (
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{optimizing ? '优化中...' : '优化提示词'}
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={() => setInspirationOpen(true)}>
<Sparkles className="h-3 w-3" />
</Button>
)}
{textModelOptions.length > 0 && (
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{optimizing ? '优化中...' : '优化提示词'}
</Button>
)}
</div>
</div>
<ExpandablePromptTextarea
title="创作描述"
@@ -600,7 +610,7 @@ export function TextToImagePanel() {
{/* Guidance Scale */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label></Label>
<span className="text-xs text-muted-foreground">{guidanceScale}</span>
</div>
@@ -637,7 +647,7 @@ export function TextToImagePanel() {
src={url}
alt={`生成结果 ${i + 1}`}
className="w-full aspect-square object-cover cursor-zoom-in"
onDoubleClick={() => setLightboxSrc(url)}
onClick={() => setLightboxSrc(url)}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<Button size="sm" variant="secondary" className="gap-1" onClick={() => setLightboxSrc(url)}><ImageIcon className="h-3.5 w-3.5" /></Button>
@@ -705,5 +715,6 @@ export function TextToImagePanel() {
}}
/>
</div>
</>
);
}

View File

@@ -33,6 +33,8 @@ import { CreationDetailDialog } from '@/components/creation-detail-dialog';
import { GenerationErrorPanel, createGenerationError, type GenerationErrorState } from '@/components/create/generation-error-panel';
import { ExpandablePromptTextarea } from '@/components/create/expandable-prompt-textarea';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
import { InspirationGalleryDialog } from '@/components/create/inspiration-gallery-dialog';
import { TEXT_TO_VIDEO_DRAFT_EVENT, TEXT_TO_VIDEO_DRAFT_KEY, type CreationReuseDraft } from '@/lib/creation-reuse';
export function TextToVideoPanel() {
const { user, accessToken } = useAuth();
@@ -52,6 +54,7 @@ export function TextToVideoPanel() {
const [results, setResults] = useState<string[]>([]);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const [inspirationOpen, setInspirationOpen] = useState(false);
const generating = activeTasks.length > 0;
const { records, add: addRecord } = useCreationHistory();
@@ -80,6 +83,33 @@ export function TextToVideoPanel() {
}
}, [modelOptions, selectedModel]);
const applyVideoDraft = useCallback((draft: unknown) => {
if (!draft || typeof draft !== 'object') return;
const data = draft as CreationReuseDraft;
if (typeof data.prompt === 'string') setPrompt(data.prompt);
if (typeof data.negativePrompt === 'string') setNegativePrompt(data.negativePrompt);
if (typeof data.model === 'string' && data.model.trim()) setSelectedModel(data.model.trim());
if (typeof data.aspectRatio === 'string' && data.aspectRatio.trim()) setAspectRatio(data.aspectRatio.trim());
if (typeof data.duration === 'string' && data.duration.trim()) setDuration(data.duration.trim());
if (typeof data.cameraMovement === 'string' && data.cameraMovement.trim()) setCameraMovement(data.cameraMovement.trim());
if (typeof data.style === 'string' && data.style.trim()) setStyle(data.style.trim());
}, []);
useEffect(() => {
try {
const raw = window.localStorage.getItem(TEXT_TO_VIDEO_DRAFT_KEY);
if (raw) applyVideoDraft(JSON.parse(raw));
} catch {
// Ignore malformed local draft data.
}
const handleDraft = (event: Event) => {
applyVideoDraft((event as CustomEvent).detail);
};
window.addEventListener(TEXT_TO_VIDEO_DRAFT_EVENT, handleDraft);
return () => window.removeEventListener(TEXT_TO_VIDEO_DRAFT_EVENT, handleDraft);
}, [applyVideoDraft]);
const textModelOptions = useMemo(() => [
...textKeys.map(k => ({ id: buildCustomModelId(k.id), label: `${k.modelName || k.provider} (自定义)`, config: { customApiKeyId: k.id, modelName: k.modelName } })),
...systemTextApis.map(api => ({ id: buildSystemModelId(api.id), label: `${api.name} (系统)`, config: { systemApiId: api.id, modelName: api.modelName } })),
@@ -251,6 +281,8 @@ export function TextToVideoPanel() {
}, [prompt, selectedModel, getCurrentModelLabel]);
return (
<>
<InspirationGalleryDialog mode="text2video" open={inspirationOpen} onOpenChange={setInspirationOpen} />
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
{/* Left: Settings */}
<div className="min-w-0 space-y-5 pb-8 pr-2">
@@ -277,14 +309,20 @@ export function TextToVideoPanel() {
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label></Label>
{textModelOptions.length > 0 && (
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{optimizing ? '优化中...' : '优化提示词'}
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={() => setInspirationOpen(true)}>
<Sparkles className="h-3 w-3" />
</Button>
)}
{textModelOptions.length > 0 && (
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{optimizing ? '优化中...' : '优化提示词'}
</Button>
)}
</div>
</div>
<ExpandablePromptTextarea
title="视频描述"
@@ -423,5 +461,6 @@ export function TextToVideoPanel() {
onClose={() => setSelectedHistoryRecord(null)}
/>
</div>
</>
);
}

View File

@@ -28,6 +28,7 @@ import { Download, Copy, FileSearch, ImageOff, Film, ImageIcon, Share2, CheckCir
import { toast } from 'sonner';
import { FullscreenPreview } from '@/components/fullscreen-preview';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
interface CreationDetailDialogProps {
record: CreationRecord | null;
@@ -139,6 +140,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
const [viewportSize, setViewportSize] = useState({ width: 1280, height: 900 });
const [deleting, setDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
useEffect(() => {
if (record) {
@@ -337,7 +339,8 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
src={referenceImage}
alt="参考图片"
className="h-full w-full cursor-zoom-in object-contain"
onDoubleClick={() => setFullscreenSrc(referenceImage)}
onClick={() => setFullscreenSrc(referenceImage)}
onContextMenu={(event) => openImageMenu(event, referenceImage)}
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
@@ -416,6 +419,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
open={!!fullscreenSrc}
onClose={() => setFullscreenSrc(null)}
/>
{ImageActionsContextMenu}
{deleteConfirmDialog}
</>
);
@@ -553,7 +557,8 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
setMediaAspectRatio(img.naturalWidth / img.naturalHeight);
}
}}
onDoubleClick={() => setFullscreenSrc(record.url)}
onClick={() => setFullscreenSrc(record.url)}
onContextMenu={(event) => openImageMenu(event, record.url)}
/>
) : (
<video
@@ -599,7 +604,8 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
src={url}
alt={`参考图 ${index + 1}`}
className="aspect-square w-full cursor-zoom-in object-cover"
onDoubleClick={() => setFullscreenSrc(url)}
onClick={() => setFullscreenSrc(url)}
onContextMenu={(event) => openImageMenu(event, url)}
/>
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-black/35 p-1 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
<button
@@ -766,6 +772,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
open={!!fullscreenSrc}
onClose={() => setFullscreenSrc(null)}
/>
{ImageActionsContextMenu}
{deleteConfirmDialog}
</>
);

View File

@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect, useRef, type MouseEvent } from 'react
import { createPortal } from 'react-dom';
import { X, ZoomIn, ZoomOut, ChevronLeft, ChevronRight } from 'lucide-react';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
interface FullscreenPreviewProps {
src: string;
@@ -27,6 +28,7 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [dragging, setDragging] = useState(false);
const overlayRef = useRef<HTMLDivElement | null>(null);
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
const dragRef = useRef({
pointerId: -1,
startX: 0,
@@ -246,7 +248,7 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
)}
<div className={`absolute bottom-4 left-4 z-10 rounded-full px-3 py-1.5 text-xs font-medium ${inverseControlClass}`}>
{Math.round(scale * 100)}% · · ·
{Math.round(scale * 100)}% · / · ·
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -264,6 +266,7 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
touchAction: 'none',
}}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => openImageMenu(event, currentSrc)}
onWheel={(event) => {
zoomFromWheel(event.nativeEvent);
}}
@@ -304,6 +307,7 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
}}
onPointerCancel={() => setDragging(false)}
/>
{ImageActionsContextMenu}
</div>
);
@@ -325,8 +329,8 @@ export function useFullscreenPreview() {
setPreviewOpen(false);
}, []);
const getDoubleClickProps = useCallback((src: string, alt?: string) => ({
onDoubleClick: (event: MouseEvent) => {
const getClickProps = useCallback((src: string, alt?: string) => ({
onClick: (event: MouseEvent) => {
event.stopPropagation();
openPreview(src, alt);
},
@@ -339,7 +343,8 @@ export function useFullscreenPreview() {
previewAlt,
openPreview,
closePreview,
getDoubleClickProps,
getDoubleClickProps: getClickProps,
getClickProps,
FullscreenPreviewComponent: previewOpen ? (
<FullscreenPreview
src={previewSrc}

View File

@@ -0,0 +1,180 @@
'use client';
import { useCallback, useEffect, useState, type MouseEvent as ReactMouseEvent } from 'react';
import { useRouter } from 'next/navigation';
import { Copy, Download, PencilLine, Share2 } from 'lucide-react';
import { downloadFile, copyTextToClipboard } from '@/lib/utils';
import { writeCreationReuseDraft } from '@/lib/creation-reuse';
import { toast } from 'sonner';
type MenuState = {
src: string;
x: number;
y: number;
} | null;
function getAbsoluteImageUrl(src: string): string {
const trimmed = src.trim();
if (!trimmed) return trimmed;
if (typeof window !== 'undefined' && trimmed.startsWith('/')) {
return `${window.location.origin}${trimmed}`;
}
return trimmed;
}
function getImageViewerUrl(src: string): string {
const absoluteUrl = getAbsoluteImageUrl(src);
if (typeof window === 'undefined') return absoluteUrl;
const viewerUrl = new URL('/image-viewer', window.location.origin);
viewerUrl.searchParams.set('url', absoluteUrl);
return viewerUrl.toString();
}
function getImageExtension(src: string): string {
const path = src.split('?')[0].split('#')[0];
const ext = path.split('.').pop()?.toLowerCase();
if (ext && ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return ext;
return 'png';
}
async function fetchOriginalImageBlob(src: string): Promise<Blob> {
const absoluteUrl = getAbsoluteImageUrl(src);
const response = await fetch(`/api/download?url=${encodeURIComponent(absoluteUrl)}&filename=image.${getImageExtension(src)}`, {
cache: 'no-store',
});
if (!response.ok) {
throw new Error('原图获取失败');
}
const blob = await response.blob();
if (!blob.type.startsWith('image/')) {
throw new Error('当前资源不是可复制的图片');
}
return blob;
}
async function copyOriginalImageToClipboard(src: string): Promise<void> {
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined' || !window.isSecureContext) {
throw new Error('当前浏览器环境不支持直接复制图片');
}
const blob = await fetchOriginalImageBlob(src);
const type = blob.type || 'image/png';
if (typeof ClipboardItem.supports === 'function' && !ClipboardItem.supports(type)) {
throw new Error('当前浏览器不支持复制该图片格式');
}
await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]);
}
function clampMenuPosition(x: number, y: number) {
if (typeof window === 'undefined') return { x, y };
return {
x: Math.min(x, window.innerWidth - 188),
y: Math.min(y, window.innerHeight - 184),
};
}
export function useImageActionsContextMenu() {
const router = useRouter();
const [menu, setMenu] = useState<MenuState>(null);
const closeImageMenu = useCallback(() => setMenu(null), []);
const openImageMenu = useCallback((event: ReactMouseEvent, src: string) => {
if (!src || src.startsWith('data:') || src.startsWith('[')) return;
event.preventDefault();
event.stopPropagation();
setMenu({ src, ...clampMenuPosition(event.clientX, event.clientY) });
}, []);
useEffect(() => {
if (!menu) return;
const close = () => setMenu(null);
window.addEventListener('click', close);
window.addEventListener('keydown', close);
window.addEventListener('scroll', close, true);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('keydown', close);
window.removeEventListener('scroll', close, true);
};
}, [menu]);
const copyImage = useCallback(async () => {
if (!menu) return;
try {
await copyOriginalImageToClipboard(menu.src);
toast.success('原图已复制到剪贴板');
} catch (error) {
toast.error(error instanceof Error ? error.message : '复制失败');
} finally {
closeImageMenu();
}
}, [closeImageMenu, menu]);
const downloadImage = useCallback(async () => {
if (!menu) return;
const result = await downloadFile(getAbsoluteImageUrl(menu.src), `miaojing-original-${Date.now()}.${getImageExtension(menu.src)}`);
if (result.ok) {
toast.success('原图下载已开始');
} else {
toast.error(result.error || '下载失败');
}
closeImageMenu();
}, [closeImageMenu, menu]);
const editImage = useCallback(() => {
if (!menu) return;
const originalUrl = getAbsoluteImageUrl(menu.src);
writeCreationReuseDraft('img2img', {
prompt: '',
referenceImage: originalUrl,
referenceImages: [originalUrl],
source: 'creation-detail',
updatedAt: Date.now(),
});
closeImageMenu();
router.push('/create?type=img2img');
toast.success('已带入图生图参考图');
}, [closeImageMenu, menu, router]);
const shareImage = useCallback(async () => {
if (!menu) return;
const url = getImageViewerUrl(menu.src);
const result = await copyTextToClipboard(url);
if (result === 'copied') {
toast.success('原图全屏链接已复制');
} else if (result === 'manual') {
toast.info('已选中链接,请按 Ctrl+C 复制');
} else {
toast.error('链接复制失败');
}
closeImageMenu();
}, [closeImageMenu, menu]);
const ImageActionsContextMenu = menu ? (
<div
className="fixed z-[2147483646] min-w-44 overflow-hidden rounded-xl border border-white/18 bg-black/82 p-1.5 text-sm text-white shadow-[0_18px_48px_rgba(0,0,0,0.36)] backdrop-blur-xl light:border-amber-900/18 light:bg-white/94 light:text-foreground light:shadow-[0_18px_48px_rgba(83,61,27,0.18)]"
style={{ left: menu.x, top: menu.y }}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
<button className="flex h-9 w-full items-center gap-2 rounded-lg px-3 text-left hover:bg-white/12 light:hover:bg-amber-900/8" onClick={copyImage}>
<Copy className="h-4 w-4" />
</button>
<button className="flex h-9 w-full items-center gap-2 rounded-lg px-3 text-left hover:bg-white/12 light:hover:bg-amber-900/8" onClick={downloadImage}>
<Download className="h-4 w-4" />
</button>
<button className="flex h-9 w-full items-center gap-2 rounded-lg px-3 text-left hover:bg-white/12 light:hover:bg-amber-900/8" onClick={editImage}>
<PencilLine className="h-4 w-4" />
</button>
<button className="flex h-9 w-full items-center gap-2 rounded-lg px-3 text-left hover:bg-white/12 light:hover:bg-amber-900/8" onClick={shareImage}>
<Share2 className="h-4 w-4" />
</button>
</div>
) : null;
return { openImageMenu, ImageActionsContextMenu };
}

View File

@@ -5,6 +5,7 @@ import { X, Download, ZoomIn, ZoomOut } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { downloadFile } from '@/lib/utils';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
interface LightboxProps {
/** Image URL to display */
@@ -28,6 +29,7 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
originX: 0,
originY: 0,
});
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
const setClampedZoom = useCallback((updater: number | ((current: number) => number)) => {
setZoom(current => {
@@ -104,7 +106,7 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
{/* Info bar */}
<div className="absolute bottom-4 left-4 z-10 rounded-full border border-white/20 bg-black/45 px-3 py-1.5 text-xs font-medium text-white/78 shadow-lg backdrop-blur-md light:border-amber-900/18 light:bg-white/52 light:text-foreground/70 light:shadow-[0_10px_32px_rgba(83,61,27,0.12)]" onClick={e => e.stopPropagation()}>
{zoom !== 1 && <span>{Math.round(zoom * 100)}% {' | '} </span>}
/ | ESC
/ | ESC |
</div>
{/* Image */}
@@ -139,6 +141,7 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
setClampedZoom(2);
}
}}
onContextMenu={(event) => openImageMenu(event, src)}
onPointerDown={e => {
e.stopPropagation();
if (zoom <= 1 || e.button !== 0) return;
@@ -169,11 +172,14 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
onPointerCancel={() => setDragging(false)}
/>
</div>
{ImageActionsContextMenu}
</div>
);
}
export function BareImagePreview({ src, alt, open, onClose }: LightboxProps) {
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
@@ -201,7 +207,9 @@ export function BareImagePreview({ src, alt, open, onClose }: LightboxProps) {
alt={alt || '参考图预览'}
className="max-h-[94vh] max-w-[94vw] object-contain"
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => openImageMenu(event, src)}
/>
{ImageActionsContextMenu}
</div>
);
}

View File

@@ -5,10 +5,16 @@ import type { ImageOutputFormat, ImageQuality } from '@/lib/model-config';
export const TEXT_TO_IMAGE_DRAFT_KEY = 'miaojing:text-to-image-draft';
export const IMAGE_TO_IMAGE_DRAFT_KEY = 'miaojing:image-to-image-draft';
export const TEXT_TO_VIDEO_DRAFT_KEY = 'miaojing:text-to-video-draft';
export const IMAGE_TO_VIDEO_DRAFT_KEY = 'miaojing:image-to-video-draft';
export const TEXT_TO_IMAGE_DRAFT_EVENT = 'miaojing:text-to-image-draft';
export const IMAGE_TO_IMAGE_DRAFT_EVENT = 'miaojing:image-to-image-draft';
export const TEXT_TO_VIDEO_DRAFT_EVENT = 'miaojing:text-to-video-draft';
export const IMAGE_TO_VIDEO_DRAFT_EVENT = 'miaojing:image-to-video-draft';
export type ImageCreationReuseDraft = {
export type CreationReuseTarget = 'text2img' | 'img2img' | 'text2video' | 'img2video';
export type CreationReuseDraft = {
prompt?: string;
negativePrompt?: string;
model?: string;
@@ -18,19 +24,35 @@ export type ImageCreationReuseDraft = {
outputFormat?: ImageOutputFormat;
imageQuality?: ImageQuality;
styleLabel?: string;
duration?: string;
cameraMovement?: string;
style?: string;
guidanceScale?: number;
strength?: number;
referenceImage?: string;
referenceImages?: string[];
source?: 'creation-detail' | 'reverse-prompt';
source?: 'creation-detail' | 'reverse-prompt' | 'gallery' | 'inspiration-gallery';
sourceRecordId?: string;
updatedAt?: number;
};
type ReuseTarget = 'text2img' | 'img2img';
export type ImageCreationReuseDraft = CreationReuseDraft;
type CreationReuseSource = {
id: string;
url: string;
prompt?: string | null;
negativePrompt?: string | null;
model?: string | null;
params?: Record<string, unknown>;
referenceImage?: string | null;
referenceImages?: string[];
thumbnailUrl?: string | null;
};
const TEXT_TO_IMAGE_ASPECT_RATIOS = new Set(['auto', '1:1', '16:9', '9:16', '4:3', '3:4']);
const IMAGE_TO_IMAGE_ASPECT_RATIOS = new Set(['auto', 'original', '1:1', '16:9', '9:16', '4:3', '3:4']);
const VIDEO_ASPECT_RATIOS = new Set(['1:1', '16:9', '9:16', '4:3', '3:4']);
const RESOLUTIONS = new Set(['auto', '1080P', '2K', '4K']);
const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']);
const IMAGE_QUALITIES = new Set(['auto', 'high', 'medium', 'low']);
@@ -53,11 +75,25 @@ function getNumber(params: Record<string, unknown>, keys: string[]): number | un
return undefined;
}
function normalizeAspectRatio(value: string | undefined, target: ReuseTarget): string | undefined {
function getStringArray(params: Record<string, unknown>, keys: string[]): string[] {
for (const key of keys) {
const value = params[key];
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
}
}
return [];
}
function normalizeAspectRatio(value: string | undefined, target: CreationReuseTarget): string | undefined {
if (!value) return undefined;
const allowed = target === 'img2img' ? IMAGE_TO_IMAGE_ASPECT_RATIOS : TEXT_TO_IMAGE_ASPECT_RATIOS;
const allowed = target === 'img2img'
? IMAGE_TO_IMAGE_ASPECT_RATIOS
: target === 'text2video' || target === 'img2video'
? VIDEO_ASPECT_RATIOS
: TEXT_TO_IMAGE_ASPECT_RATIOS;
if (allowed.has(value)) return value;
if (value === 'original' && target === 'text2img') return 'auto';
if (value === 'original' && target !== 'img2img') return target === 'text2img' ? 'auto' : undefined;
return undefined;
}
@@ -90,9 +126,38 @@ function normalizeReferenceUrl(url: string): string {
return trimmed;
}
export function buildImageCreationReuseDraft(record: CreationRecord, target: ReuseTarget): ImageCreationReuseDraft {
function getReferenceImages(record: CreationReuseSource, target: CreationReuseTarget, useOutputAsReference: boolean): string[] {
const params = record.params || {};
const draft: ImageCreationReuseDraft = {
const explicitReferences = [
...(typeof record.referenceImage === 'string' && record.referenceImage.trim() ? [record.referenceImage] : []),
...(Array.isArray(record.referenceImages) ? record.referenceImages : []),
...getStringArray(params, ['referenceImages']),
...getStringArray(params, ['images']),
...(getString(params, ['referenceImage', 'image']) ? [getString(params, ['referenceImage', 'image']) as string] : []),
];
const normalized = explicitReferences
.filter(url => url && !url.startsWith('data:') && !url.startsWith('['))
.map(normalizeReferenceUrl);
if (normalized.length > 0) return [...new Set(normalized)];
if (useOutputAsReference && target === 'img2img' && record.url && !record.url.startsWith('data:') && !record.url.startsWith('[')) {
return [normalizeReferenceUrl(record.url)];
}
if (useOutputAsReference && target === 'img2video' && record.thumbnailUrl && !record.thumbnailUrl.startsWith('data:') && !record.thumbnailUrl.startsWith('[')) {
return [normalizeReferenceUrl(record.thumbnailUrl)];
}
return [];
}
export function buildCreationReuseDraft(
record: CreationReuseSource,
target: CreationReuseTarget,
options: { source?: CreationReuseDraft['source']; useOutputAsReference?: boolean } = {},
): CreationReuseDraft {
const params = record.params || {};
const draft: CreationReuseDraft = {
prompt: record.prompt || '',
negativePrompt: record.negativePrompt || '',
model: record.model || getString(params, ['model']),
@@ -102,29 +167,49 @@ export function buildImageCreationReuseDraft(record: CreationRecord, target: Reu
outputFormat: normalizeOutputFormat(getString(params, ['outputFormat', 'format'])),
imageQuality: normalizeImageQuality(getString(params, ['imageQuality', 'quality'])),
styleLabel: getString(params, ['styleLabel']),
duration: getString(params, ['duration']),
cameraMovement: getString(params, ['cameraMovement']),
style: getString(params, ['style']),
guidanceScale: getNumber(params, ['guidanceScale']),
strength: getNumber(params, ['strength']),
source: 'creation-detail',
source: options.source || 'creation-detail',
sourceRecordId: record.id,
updatedAt: Date.now(),
};
if (target === 'img2img') {
const referenceImages = record.url && !record.url.startsWith('[')
? [normalizeReferenceUrl(record.url)]
: [];
if (target === 'img2img' || target === 'img2video') {
const referenceImages = getReferenceImages(record, target, options.useOutputAsReference !== false);
draft.referenceImage = referenceImages[0];
draft.referenceImages = referenceImages;
draft.strength = draft.strength ?? 0.5;
if (target === 'img2img') {
draft.strength = draft.strength ?? 0.5;
}
}
return draft;
}
export function writeImageCreationReuseDraft(target: ReuseTarget, draft: ImageCreationReuseDraft): void {
export function buildImageCreationReuseDraft(record: CreationRecord, target: 'text2img' | 'img2img'): ImageCreationReuseDraft {
return buildCreationReuseDraft(record, target, { source: 'creation-detail', useOutputAsReference: true });
}
function getDraftStorage(target: CreationReuseTarget): { key: string; eventName: string } {
switch (target) {
case 'img2img':
return { key: IMAGE_TO_IMAGE_DRAFT_KEY, eventName: IMAGE_TO_IMAGE_DRAFT_EVENT };
case 'text2video':
return { key: TEXT_TO_VIDEO_DRAFT_KEY, eventName: TEXT_TO_VIDEO_DRAFT_EVENT };
case 'img2video':
return { key: IMAGE_TO_VIDEO_DRAFT_KEY, eventName: IMAGE_TO_VIDEO_DRAFT_EVENT };
case 'text2img':
default:
return { key: TEXT_TO_IMAGE_DRAFT_KEY, eventName: TEXT_TO_IMAGE_DRAFT_EVENT };
}
}
export function writeCreationReuseDraft(target: CreationReuseTarget, draft: CreationReuseDraft): void {
if (typeof window === 'undefined') return;
const key = target === 'img2img' ? IMAGE_TO_IMAGE_DRAFT_KEY : TEXT_TO_IMAGE_DRAFT_KEY;
const eventName = target === 'img2img' ? IMAGE_TO_IMAGE_DRAFT_EVENT : TEXT_TO_IMAGE_DRAFT_EVENT;
const { key, eventName } = getDraftStorage(target);
try {
window.localStorage.setItem(key, JSON.stringify(draft));
} catch {
@@ -132,3 +217,7 @@ export function writeImageCreationReuseDraft(target: ReuseTarget, draft: ImageCr
}
window.dispatchEvent(new CustomEvent(eventName, { detail: draft }));
}
export function writeImageCreationReuseDraft(target: 'text2img' | 'img2img', draft: ImageCreationReuseDraft): void {
writeCreationReuseDraft(target, draft);
}