Add creation detail reuse actions
This commit is contained in:
@@ -93,7 +93,7 @@ Important generation helpers:
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | None | Latest 300 completed user works as `records`. |
|
||||
| POST | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Single record or `{ records: [...] }` | Inserts/deduplicates completed works into `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. |
|
||||
| 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`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works and total. |
|
||||
| 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 media into gallery folders and inserts public completed work. |
|
||||
|
||||
@@ -49,6 +49,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 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. |
|
||||
| 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. |
|
||||
@@ -69,6 +70,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| History missing after generation or login/account switch | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, create panel component | History POST, `works` insert, URL not data URL except reverse prompt placeholder, and `miaojing_auth_updated` triggers a fresh server fetch. |
|
||||
| Detail delete removes only local history or record reappears after refresh | `src/components/creation-detail-dialog.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/components/profile/creation-history-tab.tsx` | Logged-in deletion should call `DELETE /api/creation-history?id=...` first, then refresh local history from the server. Check bearer token availability and route ownership filter (`id` + `user_id`). |
|
||||
| Published work not in gallery | `src/lib/creation-history-store.ts`, `src/app/api/gallery/publish/route.ts`, `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | `is_public = true`, `status = completed`, media copied to gallery folder, filters. |
|
||||
| Imported gallery images do not render after production data import | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, DB `works.result_url` | New exports should include `_media`; import should persist media to local storage. If using an older export without `_media`, DB rows alone cannot recreate missing `/api/local-storage/*` files. |
|
||||
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
|
||||
|
||||
@@ -49,8 +49,8 @@ Use this document to jump directly to code before broad searching.
|
||||
| Feature | Primary Files | Server/API Files |
|
||||
| --- | --- | --- |
|
||||
| Tab container | `src/app/create/page.tsx` | N/A |
|
||||
| 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. |
|
||||
| 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`. |
|
||||
| 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`. |
|
||||
| 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` |
|
||||
@@ -58,7 +58,8 @@ Use this document to jump directly to code before broad searching.
|
||||
| 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. |
|
||||
| Lightbox/fullscreen | `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. 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` | 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 record. 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
|
||||
|
||||
@@ -104,7 +105,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Public gallery page | `src/app/gallery/page.tsx` | Lists public works, search/sort/filter, preview/download. The search box is custom styled in-page to match the glass UI; gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
## Canvas
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { TextToImagePanel } from '@/components/create/text-to-image';
|
||||
@@ -10,19 +10,23 @@ import { ImageToVideoPanel } from '@/components/create/image-to-video';
|
||||
import ReversePromptPanel from '@/components/create/reverse-prompt-panel';
|
||||
import { Brush, ImagePlus, Video, Film, Loader2, FileSearch } from 'lucide-react';
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
text2img: 'text2img',
|
||||
img2img: 'img2img',
|
||||
text2video: 'text2video',
|
||||
img2video: 'img2video',
|
||||
reversePrompt: 'reversePrompt',
|
||||
};
|
||||
|
||||
function CreateContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const typeParam = searchParams.get('type') || 'text2img';
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
text2img: 'text2img',
|
||||
img2img: 'img2img',
|
||||
text2video: 'text2video',
|
||||
img2video: 'img2video',
|
||||
reversePrompt: 'reversePrompt',
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(TYPE_MAP[typeParam] || 'text2img');
|
||||
|
||||
const [activeTab, setActiveTab] = useState(typeMap[typeParam] || 'text2img');
|
||||
useEffect(() => {
|
||||
setActiveTab(TYPE_MAP[typeParam] || 'text2img');
|
||||
}, [typeParam]);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import { useCustomApiKeys } from '@/lib/custom-api-store';
|
||||
import { useManagedSystemApis } from '@/lib/managed-model-store';
|
||||
import {
|
||||
@@ -48,8 +47,8 @@ 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 { IMAGE_TO_IMAGE_DRAFT_EVENT, IMAGE_TO_IMAGE_DRAFT_KEY, type ImageCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
|
||||
const IMAGE_TO_IMAGE_DRAFT_KEY = 'miaojing:image-to-image-draft';
|
||||
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
|
||||
|
||||
function parseStreamUnsupportedSyncMessage(error: unknown): string | null {
|
||||
@@ -95,7 +94,7 @@ export function ImageToImagePanel() {
|
||||
const generating = activeTasks.length > 0;
|
||||
|
||||
// History
|
||||
const { records, add: addRecord } = useCreationHistory();
|
||||
const { records, add: addRecord, remove: removeRecord } = useCreationHistory();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const imageHistory = records.filter(r => getCreationMode(r) === 'img2img');
|
||||
|
||||
@@ -111,16 +110,17 @@ export function ImageToImagePanel() {
|
||||
|
||||
const applyImageToImageDraft = useCallback((draft: unknown) => {
|
||||
if (!draft || typeof draft !== 'object') return;
|
||||
const data = draft as {
|
||||
prompt?: unknown;
|
||||
negativePrompt?: unknown;
|
||||
referenceImage?: unknown;
|
||||
referenceImages?: unknown;
|
||||
strength?: unknown;
|
||||
};
|
||||
const data = draft as ImageCreationReuseDraft;
|
||||
|
||||
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.resolution === 'string' && data.resolution.trim()) setResolution(data.resolution.trim());
|
||||
if (typeof data.count === 'string' && data.count.trim()) setCount(data.count.trim());
|
||||
if (data.outputFormat) setOutputFormat(data.outputFormat);
|
||||
if (data.imageQuality) setImageQuality(data.imageQuality);
|
||||
if (typeof data.styleLabel === 'string') setSelectedStyleLabel(data.styleLabel);
|
||||
if (typeof data.strength === 'number' && Number.isFinite(data.strength)) {
|
||||
setStrength(Math.min(1, Math.max(0, data.strength)));
|
||||
}
|
||||
@@ -131,14 +131,18 @@ export function ImageToImagePanel() {
|
||||
? [data.referenceImage]
|
||||
: [];
|
||||
const references = rawReferences.filter((item): item is string => (
|
||||
typeof item === 'string' && (item.startsWith('data:image/') || /^https?:\/\/\S+/i.test(item))
|
||||
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}`,
|
||||
name: index === 0 ? '复用参考图' : `复用参考图 ${index + 1}`,
|
||||
})));
|
||||
}
|
||||
}, []);
|
||||
@@ -154,8 +158,8 @@ export function ImageToImagePanel() {
|
||||
const handleDraft = (event: Event) => {
|
||||
applyImageToImageDraft((event as CustomEvent).detail);
|
||||
};
|
||||
window.addEventListener('miaojing:image-to-image-draft', handleDraft);
|
||||
return () => window.removeEventListener('miaojing:image-to-image-draft', handleDraft);
|
||||
window.addEventListener(IMAGE_TO_IMAGE_DRAFT_EVENT, handleDraft);
|
||||
return () => window.removeEventListener(IMAGE_TO_IMAGE_DRAFT_EVENT, handleDraft);
|
||||
}, [applyImageToImageDraft]);
|
||||
|
||||
// System APIs
|
||||
@@ -817,11 +821,15 @@ export function ImageToImagePanel() {
|
||||
<BareImagePreview src={referencePreviewSrc || ''} open={!!referencePreviewSrc} onClose={() => setReferencePreviewSrc(null)} />
|
||||
|
||||
{/* History Detail Dialog */}
|
||||
<CreationDetailDialog
|
||||
record={selectedHistoryRecord}
|
||||
open={!!selectedHistoryRecord}
|
||||
onClose={() => setSelectedHistoryRecord(null)}
|
||||
/>
|
||||
<CreationDetailDialog
|
||||
record={selectedHistoryRecord}
|
||||
open={!!selectedHistoryRecord}
|
||||
onClose={() => setSelectedHistoryRecord(null)}
|
||||
onDelete={async (deletedRecord) => {
|
||||
await removeRecord(deletedRecord.id);
|
||||
setSelectedHistoryRecord(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { GenerationLoadingPanel } from '@/components/create/generation-loading-p
|
||||
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||
import { copyTextToClipboard } from '@/lib/utils';
|
||||
import { compressImageFileForUpload } from '@/lib/browser-image-compression';
|
||||
import { IMAGE_TO_IMAGE_DRAFT_EVENT, IMAGE_TO_IMAGE_DRAFT_KEY, TEXT_TO_IMAGE_DRAFT_EVENT, TEXT_TO_IMAGE_DRAFT_KEY } from '@/lib/creation-reuse';
|
||||
|
||||
type ReversePromptResult = {
|
||||
generalPrompt: string;
|
||||
@@ -47,9 +48,6 @@ const sectionLabels: Array<[keyof NonNullable<ReversePromptResult['structuredSec
|
||||
['character', '人物细节'],
|
||||
];
|
||||
|
||||
const TEXT_TO_IMAGE_DRAFT_KEY = 'miaojing:text-to-image-draft';
|
||||
const IMAGE_TO_IMAGE_DRAFT_KEY = 'miaojing:image-to-image-draft';
|
||||
|
||||
async function copyText(value: string) {
|
||||
const text = value.trim();
|
||||
if (!text) return;
|
||||
@@ -257,7 +255,7 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
|
||||
} catch {
|
||||
// The CustomEvent still transfers the draft for mounted tabs if localStorage quota is exceeded.
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('miaojing:image-to-image-draft', { detail: draft }));
|
||||
window.dispatchEvent(new CustomEvent(IMAGE_TO_IMAGE_DRAFT_EVENT, { detail: draft }));
|
||||
onUseForImageToImage?.();
|
||||
toast.success('已填入图生图');
|
||||
return;
|
||||
@@ -269,7 +267,7 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
window.localStorage.setItem(TEXT_TO_IMAGE_DRAFT_KEY, JSON.stringify(draft));
|
||||
window.dispatchEvent(new CustomEvent('miaojing:text-to-image-draft', { detail: draft }));
|
||||
window.dispatchEvent(new CustomEvent(TEXT_TO_IMAGE_DRAFT_EVENT, { detail: draft }));
|
||||
onUseForTextToImage?.();
|
||||
toast.success('已填入文生图');
|
||||
}, [fullPrompt, onUseForImageToImage, onUseForTextToImage, promptMode, result, reverseImage]);
|
||||
@@ -291,6 +289,7 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleUpload} />
|
||||
{reverseImage ? (
|
||||
<div className="relative flex max-h-[210px] w-full items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={reverseImage} alt="待转换图片" className="max-h-[210px] max-w-full rounded-xl object-contain shadow-lg" />
|
||||
<button
|
||||
className="absolute right-2 top-2 flex h-9 w-9 items-center justify-center rounded-full border bg-background/90 shadow backdrop-blur"
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import { useCustomApiKeys } from '@/lib/custom-api-store';
|
||||
import { useManagedSystemApis } from '@/lib/managed-model-store';
|
||||
@@ -46,8 +45,8 @@ 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 { TEXT_TO_IMAGE_DRAFT_EVENT, TEXT_TO_IMAGE_DRAFT_KEY, type ImageCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
|
||||
const TEXT_TO_IMAGE_DRAFT_KEY = 'miaojing:text-to-image-draft';
|
||||
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
|
||||
|
||||
function parseStreamUnsupportedSyncMessage(error: unknown): string | null {
|
||||
@@ -85,7 +84,7 @@ export function TextToImagePanel() {
|
||||
const generating = activeTasks.length > 0;
|
||||
|
||||
// History state
|
||||
const { records, add: addRecord } = useCreationHistory();
|
||||
const { records, add: addRecord, remove: removeRecord } = useCreationHistory();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const imageHistory = records.filter(r => getCreationMode(r) === 'text2img');
|
||||
|
||||
@@ -98,9 +97,19 @@ export function TextToImagePanel() {
|
||||
|
||||
const applyPromptDraft = useCallback((draft: unknown) => {
|
||||
if (!draft || typeof draft !== 'object') return;
|
||||
const data = draft as { prompt?: unknown; negativePrompt?: unknown };
|
||||
const data = draft as ImageCreationReuseDraft;
|
||||
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.resolution === 'string' && data.resolution.trim()) setResolution(data.resolution.trim());
|
||||
if (typeof data.count === 'string' && data.count.trim()) setCount(data.count.trim());
|
||||
if (data.outputFormat) setOutputFormat(data.outputFormat);
|
||||
if (data.imageQuality) setImageQuality(data.imageQuality);
|
||||
if (typeof data.styleLabel === 'string') setSelectedStyleLabel(data.styleLabel);
|
||||
if (typeof data.guidanceScale === 'number' && Number.isFinite(data.guidanceScale)) {
|
||||
setGuidanceScale(Math.min(20, Math.max(1, data.guidanceScale)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -114,8 +123,8 @@ export function TextToImagePanel() {
|
||||
const handleDraft = (event: Event) => {
|
||||
applyPromptDraft((event as CustomEvent).detail);
|
||||
};
|
||||
window.addEventListener('miaojing:text-to-image-draft', handleDraft);
|
||||
return () => window.removeEventListener('miaojing:text-to-image-draft', handleDraft);
|
||||
window.addEventListener(TEXT_TO_IMAGE_DRAFT_EVENT, handleDraft);
|
||||
return () => window.removeEventListener(TEXT_TO_IMAGE_DRAFT_EVENT, handleDraft);
|
||||
}, [applyPromptDraft]);
|
||||
|
||||
// System APIs
|
||||
@@ -690,6 +699,10 @@ export function TextToImagePanel() {
|
||||
record={selectedHistoryRecord}
|
||||
open={!!selectedHistoryRecord}
|
||||
onClose={() => setSelectedHistoryRecord(null)}
|
||||
onDelete={async (deletedRecord) => {
|
||||
await removeRecord(deletedRecord.id);
|
||||
setSelectedHistoryRecord(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { type CreationRecord, isPlaceholder, shareToGallery, isUrlPublished } from '@/lib/creation-history-store';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { type CreationRecord, deleteCreationRecord, isPlaceholder, shareToGallery, isUrlPublished } from '@/lib/creation-history-store';
|
||||
import { buildImageCreationReuseDraft, writeImageCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
import { copyTextToClipboard, downloadFile } from '@/lib/utils';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import {
|
||||
@@ -12,7 +14,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download, Copy, FileSearch, ImageOff, Film, ImageIcon, Share2, CheckCircle2, Maximize2 } from 'lucide-react';
|
||||
import { Download, Copy, FileSearch, ImageOff, Film, ImageIcon, Share2, CheckCircle2, Maximize2, RotateCcw, PencilLine, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { FullscreenPreview } from '@/components/fullscreen-preview';
|
||||
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
|
||||
@@ -22,6 +24,7 @@ interface CreationDetailDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPublishChange?: () => void;
|
||||
onDelete?: (record: CreationRecord) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function parseAspectRatio(value: unknown): number | null {
|
||||
@@ -117,12 +120,14 @@ function getRecordReferenceImages(record: CreationRecord): string[] {
|
||||
return [...new Set([...single, ...fromArray, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
|
||||
}
|
||||
|
||||
export function CreationDetailDialog({ record, open, onClose, onPublishChange }: CreationDetailDialogProps) {
|
||||
export function CreationDetailDialog({ record, open, onClose, onPublishChange, onDelete }: CreationDetailDialogProps) {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [fullscreenSrc, setFullscreenSrc] = useState<string | null>(null);
|
||||
const [mediaAspectRatio, setMediaAspectRatio] = useState<number | null>(null);
|
||||
const [viewportSize, setViewportSize] = useState({ width: 1280, height: 900 });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (record) {
|
||||
@@ -228,6 +233,48 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
|
||||
}
|
||||
};
|
||||
|
||||
const handleReuseConfig = () => {
|
||||
if (record.type !== 'image') {
|
||||
toast.info('当前仅支持将图片创作配置复用到文生图');
|
||||
return;
|
||||
}
|
||||
const draft = buildImageCreationReuseDraft(record, 'text2img');
|
||||
writeImageCreationReuseDraft('text2img', draft);
|
||||
onClose();
|
||||
router.push(`/create?type=text2img&reuse=${encodeURIComponent(record.id)}`);
|
||||
toast.success('已填入文生图');
|
||||
};
|
||||
|
||||
const handleEditOutput = () => {
|
||||
if (record.type !== 'image' || isPlaceholder(record.url)) {
|
||||
toast.info('当前作品没有可用图片,无法作为图生图参考图');
|
||||
return;
|
||||
}
|
||||
const draft = buildImageCreationReuseDraft(record, 'img2img');
|
||||
writeImageCreationReuseDraft('img2img', draft);
|
||||
onClose();
|
||||
router.push(`/create?type=img2img&reuse=${encodeURIComponent(record.id)}`);
|
||||
toast.success('已填入图生图');
|
||||
};
|
||||
|
||||
const handleDeleteRecord = async () => {
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
if (onDelete) {
|
||||
await onDelete(record);
|
||||
} else {
|
||||
await deleteCreationRecord(record.id);
|
||||
}
|
||||
onClose();
|
||||
toast.success('记录已删除');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败,请重试');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isReversePromptRecord) {
|
||||
const displayCreatedAt = new Date(record.createdAt).toLocaleString('zh-CN');
|
||||
const referenceImage = record.referenceImage && !isPlaceholder(record.referenceImage) ? record.referenceImage : null;
|
||||
@@ -310,6 +357,15 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
复制提示词
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-10 min-w-[102px] gap-1.5 px-3 text-sm font-semibold"
|
||||
onClick={handleDeleteRecord}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{deleting ? '删除中' : '删除记录'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -606,7 +662,34 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
|
||||
<span>分辨率:{displayResolution}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-nowrap justify-end gap-2">
|
||||
<div className="flex max-w-[500px] shrink-0 flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 min-w-[102px] gap-1.5 px-3 text-sm font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-300"
|
||||
onClick={handleReuseConfig}
|
||||
disabled={record.type !== 'image'}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
复用配置
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-10 min-w-[102px] gap-1.5 px-3 text-sm font-semibold text-emerald-600 hover:text-emerald-700 dark:text-emerald-300"
|
||||
onClick={handleEditOutput}
|
||||
disabled={record.type !== 'image' || isPlaceholderUrl}
|
||||
>
|
||||
<PencilLine className="h-3.5 w-3.5" />
|
||||
编辑输出
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-10 min-w-[102px] gap-1.5 px-3 text-sm font-semibold"
|
||||
onClick={handleDeleteRecord}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{deleting ? '删除中' : '删除记录'}
|
||||
</Button>
|
||||
<Button className="h-10 min-w-[102px] gap-1.5 px-3 text-sm font-semibold" onClick={handleDownload} disabled={isPlaceholderUrl}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
下载{record.type === 'image' ? '图片' : '视频'}
|
||||
|
||||
@@ -4,10 +4,9 @@ import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useCreationHistory, type CreationRecord, isPlaceholder } from '@/lib/creation-history-store';
|
||||
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||
import { ExternalLink, FileSearch, Film, Image, ImageOff, Trash2 } from 'lucide-react';
|
||||
import { FileSearch, Film, Image as ImageIcon, ImageOff } from 'lucide-react';
|
||||
export default function CreationHistoryTab() {
|
||||
const { records, remove, clear } = useCreationHistory();
|
||||
const [filter, setFilter] = useState<'all' | 'image' | 'video' | 'reverse-prompt'>('all');
|
||||
@@ -20,7 +19,7 @@ export default function CreationHistoryTab() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><Image className="h-5 w-5" />创作历史</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />创作历史</CardTitle>
|
||||
<CardDescription>点击记录查看详情、提示词和参考图</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -49,7 +48,7 @@ export default function CreationHistoryTab() {
|
||||
<CardContent>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Image className="h-12 w-12 mx-auto mb-3 opacity-20" />
|
||||
<ImageIcon className="h-12 w-12 mx-auto mb-3 opacity-20" />
|
||||
<p>还没有创作记录,去创作中心开始创作吧</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -120,6 +119,10 @@ export default function CreationHistoryTab() {
|
||||
record={selectedRecord}
|
||||
open={!!selectedRecord}
|
||||
onClose={() => setSelectedRecord(null)}
|
||||
onDelete={async (deletedRecord) => {
|
||||
await remove(deletedRecord.id);
|
||||
setSelectedRecord(null);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -164,10 +164,14 @@ async function persistServerRecords(records: CreationRecord[] | CreationRecord):
|
||||
async function deleteServerRecord(id?: string): Promise<CreationRecord[] | null> {
|
||||
const token = getAuthToken();
|
||||
if (!token) return null;
|
||||
await fetch(id ? `/api/creation-history?id=${encodeURIComponent(id)}` : '/api/creation-history', {
|
||||
const res = await fetch(id ? `/api/creation-history?id=${encodeURIComponent(id)}` : '/api/creation-history', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(typeof data.error === 'string' ? data.error : '删除服务器记录失败');
|
||||
}
|
||||
return fetchServerRecords();
|
||||
}
|
||||
|
||||
@@ -217,17 +221,22 @@ export function getCreationRecords(): CreationRecord[] {
|
||||
return loadRecords();
|
||||
}
|
||||
|
||||
export function deleteCreationRecord(id: string): void {
|
||||
export async function deleteCreationRecord(id: string): Promise<void> {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
const serverRecords = await deleteServerRecord(id);
|
||||
if (serverRecords) saveRecords(serverRecords);
|
||||
return;
|
||||
}
|
||||
const records = loadRecords().filter(r => r.id !== id);
|
||||
saveRecords(records);
|
||||
void deleteServerRecord(id).then(serverRecords => {
|
||||
if (serverRecords) saveRecords(serverRecords, false);
|
||||
}).catch(() => { /* local fallback */ });
|
||||
}
|
||||
|
||||
export function clearCreationRecords(): void {
|
||||
saveRecords([]);
|
||||
void deleteServerRecord().catch(() => { /* local fallback */ });
|
||||
void deleteServerRecord().then(serverRecords => {
|
||||
if (serverRecords) saveRecords(serverRecords, false);
|
||||
}).catch(() => { /* local fallback */ });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,8 +279,8 @@ export function useCreationHistory() {
|
||||
return newRecord;
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
deleteCreationRecord(id);
|
||||
const remove = useCallback(async (id: string) => {
|
||||
await deleteCreationRecord(id);
|
||||
setRecords(loadRecords());
|
||||
}, []);
|
||||
|
||||
@@ -431,7 +440,7 @@ export async function shareToGallery(options: {
|
||||
creditsCost: options.creditsCost,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
await res.json().catch(() => ({}));
|
||||
} catch {
|
||||
// Non-critical — localStorage version is already saved
|
||||
}
|
||||
@@ -565,7 +574,7 @@ export async function syncPublishedToSupabase(): Promise<number> {
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
await res.json().catch(() => ({}));
|
||||
synced++;
|
||||
} else {
|
||||
console.warn('[gallery sync] Failed to publish:', url.slice(0, 60), await res.text().catch(() => ''));
|
||||
|
||||
134
src/lib/creation-reuse.ts
Normal file
134
src/lib/creation-reuse.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import type { CreationRecord } from '@/lib/creation-history-store';
|
||||
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_IMAGE_DRAFT_EVENT = 'miaojing:text-to-image-draft';
|
||||
export const IMAGE_TO_IMAGE_DRAFT_EVENT = 'miaojing:image-to-image-draft';
|
||||
|
||||
export type ImageCreationReuseDraft = {
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
resolution?: string;
|
||||
count?: string;
|
||||
outputFormat?: ImageOutputFormat;
|
||||
imageQuality?: ImageQuality;
|
||||
styleLabel?: string;
|
||||
guidanceScale?: number;
|
||||
strength?: number;
|
||||
referenceImage?: string;
|
||||
referenceImages?: string[];
|
||||
source?: 'creation-detail' | 'reverse-prompt';
|
||||
sourceRecordId?: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
type ReuseTarget = 'text2img' | 'img2img';
|
||||
|
||||
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 RESOLUTIONS = new Set(['auto', '1080P', '2K', '4K']);
|
||||
const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']);
|
||||
const IMAGE_QUALITIES = new Set(['auto', 'high', 'medium', 'low']);
|
||||
|
||||
function getString(params: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = params[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNumber(params: Record<string, unknown>, keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = params[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeAspectRatio(value: string | undefined, target: ReuseTarget): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const allowed = target === 'img2img' ? IMAGE_TO_IMAGE_ASPECT_RATIOS : TEXT_TO_IMAGE_ASPECT_RATIOS;
|
||||
if (allowed.has(value)) return value;
|
||||
if (value === 'original' && target === 'text2img') return 'auto';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeResolution(value: string | undefined): string | undefined {
|
||||
return value && RESOLUTIONS.has(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeCount(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value === 'auto') return value;
|
||||
const count = Number(value);
|
||||
if (!Number.isFinite(count)) return undefined;
|
||||
return String(Math.min(10, Math.max(1, Math.floor(count))));
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(value: string | undefined): ImageOutputFormat | undefined {
|
||||
return value && OUTPUT_FORMATS.has(value) ? value as ImageOutputFormat : undefined;
|
||||
}
|
||||
|
||||
function normalizeImageQuality(value: string | undefined): ImageQuality | undefined {
|
||||
return value && IMAGE_QUALITIES.has(value) ? value as ImageQuality : undefined;
|
||||
}
|
||||
|
||||
function normalizeReferenceUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (typeof window !== 'undefined' && trimmed.startsWith('/')) {
|
||||
return `${window.location.origin}${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function buildImageCreationReuseDraft(record: CreationRecord, target: ReuseTarget): ImageCreationReuseDraft {
|
||||
const params = record.params || {};
|
||||
const draft: ImageCreationReuseDraft = {
|
||||
prompt: record.prompt || '',
|
||||
negativePrompt: record.negativePrompt || '',
|
||||
model: record.model || getString(params, ['model']),
|
||||
aspectRatio: normalizeAspectRatio(getString(params, ['aspectRatio', 'aspect_ratio', 'ratio', 'imageRatio']), target),
|
||||
resolution: normalizeResolution(getString(params, ['resolution'])),
|
||||
count: normalizeCount(getString(params, ['count', 'batchCount'])),
|
||||
outputFormat: normalizeOutputFormat(getString(params, ['outputFormat', 'format'])),
|
||||
imageQuality: normalizeImageQuality(getString(params, ['imageQuality', 'quality'])),
|
||||
styleLabel: getString(params, ['styleLabel']),
|
||||
guidanceScale: getNumber(params, ['guidanceScale']),
|
||||
strength: getNumber(params, ['strength']),
|
||||
source: 'creation-detail',
|
||||
sourceRecordId: record.id,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (target === 'img2img') {
|
||||
const referenceImages = record.url && !record.url.startsWith('[')
|
||||
? [normalizeReferenceUrl(record.url)]
|
||||
: [];
|
||||
draft.referenceImage = referenceImages[0];
|
||||
draft.referenceImages = referenceImages;
|
||||
draft.strength = draft.strength ?? 0.5;
|
||||
}
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function writeImageCreationReuseDraft(target: ReuseTarget, draft: ImageCreationReuseDraft): 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;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(draft));
|
||||
} catch {
|
||||
// Event delivery still updates already-mounted create panels if storage is full.
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(eventName, { detail: draft }));
|
||||
}
|
||||
Reference in New Issue
Block a user