mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(files): add save-as-new vs overwrite choice for library file edits (#564)
Editing a file from the library used to silently supersede it: the worker auto-saved every result as a new version and the leaf-only listing hid the original, which read as a destructive overwrite. Tool pages now show a per-edit choice for library-sourced files. The default saves the result as an independent new file and keeps the original; picking overwrite keeps the old superseding-version behavior. The client sends a saveMode multipart field next to fileId, validated with a 400 on unknown values, and autoSaveToLibrary branches on it. Every hand-written route that honors fileId parses the field the same way as the factory. The review panel shows where an auto-saved result went instead of offering a second, duplicate save. Tools whose route or submitter ignores fileId keep the selector hidden via a shared unsupported-tools set, and the choice resets to the non-destructive default whenever a new file is staged. Closes #495
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS } from "@snapotter/shared";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
/**
|
||||
* Per-edit choice for library-sourced files (#495): save the processed result
|
||||
* to the file library as a new file (default, keeps the original) or
|
||||
* overwrite the original. Renders nothing when the choice would not be
|
||||
* honored: entry not linked to a library file, tool whose route or submitter
|
||||
* ignores the saveMode field, or a multi-file batch run (batches never send
|
||||
* fileId, so nothing is auto-saved).
|
||||
*/
|
||||
export function LibrarySaveModeSelector({ toolId }: { toolId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const currentEntry = useFileStore((s) => s.currentEntry);
|
||||
const entries = useFileStore((s) => s.entries);
|
||||
const librarySaveMode = useFileStore((s) => s.librarySaveMode);
|
||||
const setLibrarySaveMode = useFileStore((s) => s.setLibrarySaveMode);
|
||||
const processing = useFileStore((s) => s.processing);
|
||||
|
||||
if (!currentEntry?.serverFileId) return null;
|
||||
if (LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS.has(toolId)) return null;
|
||||
const isBatchRun = entries.length > 1 && !MULTI_FILE_TOOLS.has(toolId);
|
||||
if (isBatchRun) return null;
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-2 rounded-lg border border-border p-3">
|
||||
<legend className="px-1 text-xs font-medium text-muted-foreground">
|
||||
{t.toolPage.librarySaveTitle}
|
||||
</legend>
|
||||
<label className="flex items-start gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="library-save-mode"
|
||||
className="mt-1"
|
||||
checked={librarySaveMode === "new"}
|
||||
onChange={() => setLibrarySaveMode("new")}
|
||||
disabled={processing}
|
||||
/>
|
||||
<span>
|
||||
{t.toolPage.librarySaveAsNew}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t.toolPage.librarySaveAsNewHint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="library-save-mode"
|
||||
className="mt-1"
|
||||
checked={librarySaveMode === "overwrite"}
|
||||
onChange={() => setLibrarySaveMode("overwrite")}
|
||||
disabled={processing}
|
||||
/>
|
||||
<span>
|
||||
{t.toolPage.libraryOverwrite}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t.toolPage.libraryOverwriteHint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,8 @@ interface ReviewPanelProps {
|
||||
totalCount?: number;
|
||||
successCount?: number;
|
||||
failedCount?: number;
|
||||
/** Library id of the auto-saved result (#495); replaces the manual save link. */
|
||||
savedLibraryFileId?: string | null;
|
||||
}
|
||||
|
||||
export function ReviewPanel({
|
||||
@@ -62,6 +64,7 @@ export function ReviewPanel({
|
||||
totalCount,
|
||||
successCount,
|
||||
failedCount,
|
||||
savedLibraryFileId,
|
||||
}: ReviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -194,8 +197,20 @@ export function ReviewPanel({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Result already auto-saved to the library: show where it went
|
||||
instead of the manual save link (avoids duplicate saves). */}
|
||||
{!isDataOutput && savedLibraryFileId && (
|
||||
<div className="flex items-center justify-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t.toolPage.savedToFiles}
|
||||
<Link to="/files" className="underline underline-offset-2 hover:text-foreground">
|
||||
{t.toolPage.viewInFiles}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save to Files -- subtle text link */}
|
||||
{!isDataOutput && (
|
||||
{!isDataOutput && !savedLibraryFileId && (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -432,7 +432,9 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
|
||||
navigate("/", { state: { fromLibrary: true } });
|
||||
}
|
||||
|
||||
// Set serverFileId on each entry so tool processing creates new versions
|
||||
// Set serverFileId on each entry so tool processing auto-saves the result
|
||||
// to the library: an independent new file by default, or a superseding
|
||||
// version when the user picks overwrite (#495)
|
||||
setTimeout(() => {
|
||||
const store = useFileStore.getState();
|
||||
for (let i = 0; i < valid.length; i++) {
|
||||
|
||||
@@ -186,8 +186,12 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
|
||||
form.append("placements", JSON.stringify(placements));
|
||||
form.append("clientJobId", clientJobId);
|
||||
// Forward the library file id (when the PDF came from the library) so the
|
||||
// worker auto-saves the signed result as a new version.
|
||||
if (currentEntry?.serverFileId) form.append("fileId", currentEntry.serverFileId);
|
||||
// worker auto-saves the signed result, honoring the chosen save mode
|
||||
// (new file by default, overwrite on request).
|
||||
if (currentEntry?.serverFileId) {
|
||||
form.append("fileId", currentEntry.serverFileId);
|
||||
form.append("saveMode", useFileStore.getState().librarySaveMode);
|
||||
}
|
||||
pngs.forEach((png, i) => {
|
||||
form.append(`sig${i}`, new File([png], `sig${i}.png`, { type: "image/png" }));
|
||||
});
|
||||
|
||||
@@ -91,6 +91,10 @@ export function useToolProcessor(toolId: string) {
|
||||
const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const activeJobIdRef = useRef<string | null>(null);
|
||||
const asyncModeRef = useRef(false);
|
||||
// Save mode captured at run start (#495). Only "overwrite" re-anchors
|
||||
// serverFileId to the saved result, so "new" keeps deriving from the
|
||||
// original library file on re-runs.
|
||||
const saveModeRef = useRef<"new" | "overwrite">("new");
|
||||
|
||||
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
|
||||
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
|
||||
@@ -180,6 +184,9 @@ export function useToolProcessor(toolId: string) {
|
||||
const result = data.result as ProcessResult;
|
||||
setWarning(result.warning ?? null);
|
||||
setResultPayload(result as unknown as Record<string, unknown>);
|
||||
if (result.savedFileId) {
|
||||
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
|
||||
}
|
||||
const idx = useFileStore.getState().selectedIndex;
|
||||
useFileStore.getState().updateEntry(idx, {
|
||||
processedUrl: result.downloadUrl,
|
||||
@@ -188,7 +195,9 @@ export function useToolProcessor(toolId: string) {
|
||||
status: "completed",
|
||||
originalSize: result.originalSize,
|
||||
processedSize: result.processedSize,
|
||||
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
|
||||
...(result.savedFileId && saveModeRef.current === "overwrite"
|
||||
? { serverFileId: result.savedFileId }
|
||||
: {}),
|
||||
});
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
@@ -275,6 +284,7 @@ export function useToolProcessor(toolId: string) {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setResultPayload(null);
|
||||
useFileStore.getState().setLastSavedLibraryFileId(null);
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: null,
|
||||
processedPreviewUrl: null,
|
||||
@@ -353,6 +363,9 @@ export function useToolProcessor(toolId: string) {
|
||||
const result = data.result as ProcessResult;
|
||||
setWarning(result.warning ?? null);
|
||||
setResultPayload(result as unknown as Record<string, unknown>);
|
||||
if (result.savedFileId) {
|
||||
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
|
||||
}
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: result.downloadUrl,
|
||||
processedPreviewUrl: result.previewUrl ?? null,
|
||||
@@ -360,7 +373,9 @@ export function useToolProcessor(toolId: string) {
|
||||
status: "completed",
|
||||
originalSize: result.originalSize,
|
||||
processedSize: result.processedSize,
|
||||
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
|
||||
...(result.savedFileId && saveModeRef.current === "overwrite"
|
||||
? { serverFileId: result.savedFileId }
|
||||
: {}),
|
||||
});
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
@@ -421,8 +436,10 @@ export function useToolProcessor(toolId: string) {
|
||||
formData.append("clientJobId", clientJobId);
|
||||
|
||||
const capturedEntry = useFileStore.getState().entries[capturedIndex];
|
||||
saveModeRef.current = useFileStore.getState().librarySaveMode;
|
||||
if (capturedEntry?.serverFileId) {
|
||||
formData.append("fileId", capturedEntry.serverFileId);
|
||||
formData.append("saveMode", saveModeRef.current);
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
@@ -469,6 +486,9 @@ export function useToolProcessor(toolId: string) {
|
||||
const result: ProcessResult = JSON.parse(xhr.responseText);
|
||||
setWarning(result.warning ?? null);
|
||||
setResultPayload(result as unknown as Record<string, unknown>);
|
||||
if (result.savedFileId) {
|
||||
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
|
||||
}
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: result.downloadUrl,
|
||||
processedPreviewUrl: result.previewUrl ?? null,
|
||||
@@ -476,7 +496,9 @@ export function useToolProcessor(toolId: string) {
|
||||
status: "completed",
|
||||
originalSize: result.originalSize,
|
||||
processedSize: result.processedSize,
|
||||
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
|
||||
...(result.savedFileId && saveModeRef.current === "overwrite"
|
||||
? { serverFileId: result.savedFileId }
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
setError("Invalid response from server");
|
||||
@@ -580,6 +602,9 @@ export function useToolProcessor(toolId: string) {
|
||||
const { updateEntry, setBatchZip } = useFileStore.getState();
|
||||
|
||||
setError(null);
|
||||
// Batch runs never auto-save to the library (no fileId is sent), so a
|
||||
// previous single run's saved indicator must not survive into this one.
|
||||
useFileStore.getState().setLastSavedLibraryFileId(null);
|
||||
setProcessing(true);
|
||||
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { BottomSheet } from "@/components/common/bottom-sheet";
|
||||
import { Dropzone } from "@/components/common/dropzone";
|
||||
import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer";
|
||||
import { LibrarySaveModeSelector } from "@/components/common/library-save-mode-selector";
|
||||
import { ReviewPanel } from "@/components/common/review-panel";
|
||||
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
|
||||
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||
@@ -303,6 +304,7 @@ export function ToolPage() {
|
||||
navigateNext,
|
||||
navigatePrev,
|
||||
currentEntry,
|
||||
lastSavedLibraryFileId,
|
||||
} = useFileStore();
|
||||
const isMobile = useMobile();
|
||||
const hasMultiple = entries.length > 1;
|
||||
@@ -1115,6 +1117,8 @@ export function ToolPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LibrarySaveModeSelector toolId={tool?.id ?? ""} />
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -1161,6 +1165,7 @@ export function ToolPage() {
|
||||
totalCount={batchTotal}
|
||||
successCount={batchSuccess}
|
||||
failedCount={batchFailed}
|
||||
savedLibraryFileId={lastSavedLibraryFileId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ANALYTICS_EVENTS, detectModalityFromMime, type Modality } from "@snapotter/shared";
|
||||
import {
|
||||
ANALYTICS_EVENTS,
|
||||
detectModalityFromMime,
|
||||
type LibrarySaveMode,
|
||||
type Modality,
|
||||
} from "@snapotter/shared";
|
||||
import { create } from "zustand";
|
||||
import { fetchDecodedPreview, needsServerPreview } from "@/lib/image-preview";
|
||||
|
||||
@@ -118,6 +123,10 @@ interface FileState {
|
||||
error: string | null;
|
||||
activeJobId: string | null;
|
||||
cancelCurrentJob: (() => Promise<void>) | null;
|
||||
/** How library-sourced results are saved (#495): "new" keeps the original. */
|
||||
librarySaveMode: LibrarySaveMode;
|
||||
/** Library file id of the last run's auto-saved result, for the review UI. */
|
||||
lastSavedLibraryFileId: string | null;
|
||||
|
||||
// Derived from entries (selected entry fields)
|
||||
readonly files: File[];
|
||||
@@ -142,6 +151,8 @@ interface FileState {
|
||||
setProcessing: (v: boolean) => void;
|
||||
setError: (e: string | null) => void;
|
||||
setActiveJob: (id: string | null, cancelFn: (() => Promise<void>) | null) => void;
|
||||
setLibrarySaveMode: (mode: LibrarySaveMode) => void;
|
||||
setLastSavedLibraryFileId: (id: string | null) => void;
|
||||
setJobId: (id: string) => void;
|
||||
setProcessedUrl: (url: string | null, previewUrl?: string | null) => void;
|
||||
setSizes: (original: number, processed: number) => void;
|
||||
@@ -158,6 +169,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
error: null,
|
||||
activeJobId: null,
|
||||
cancelCurrentJob: null,
|
||||
librarySaveMode: "new",
|
||||
lastSavedLibraryFileId: null,
|
||||
|
||||
// Initial derived values (empty state)
|
||||
files: [],
|
||||
@@ -175,6 +188,9 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
entries,
|
||||
selectedIndex: 0,
|
||||
error: null,
|
||||
// A fresh file set is a fresh edit: the save-mode choice made for a
|
||||
// previous file must not carry over (#495 defaults to non-destructive).
|
||||
librarySaveMode: "new",
|
||||
files: deriveFiles(entries),
|
||||
...deriveSelected(entries, 0),
|
||||
});
|
||||
@@ -347,6 +363,10 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
set({ entries: updated, ...deriveSelected(updated, selectedIndex) });
|
||||
},
|
||||
|
||||
setLibrarySaveMode: (mode) => set({ librarySaveMode: mode }),
|
||||
|
||||
setLastSavedLibraryFileId: (id) => set({ lastSavedLibraryFileId: id }),
|
||||
|
||||
undoProcessing: () => {
|
||||
const { entries, selectedIndex } = get();
|
||||
for (const entry of entries) {
|
||||
@@ -370,6 +390,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
error: null,
|
||||
activeJobId: null,
|
||||
cancelCurrentJob: null,
|
||||
lastSavedLibraryFileId: null,
|
||||
files: deriveFiles(resetEntries),
|
||||
...deriveSelected(resetEntries, selectedIndex),
|
||||
});
|
||||
@@ -387,6 +408,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
error: null,
|
||||
activeJobId: null,
|
||||
cancelCurrentJob: null,
|
||||
librarySaveMode: "new",
|
||||
lastSavedLibraryFileId: null,
|
||||
files: [],
|
||||
...deriveSelected([], 0),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user