diff --git a/.changeset/answer-all-duplicate-prompts.md b/.changeset/answer-all-duplicate-prompts.md new file mode 100644 index 0000000..b49c666 --- /dev/null +++ b/.changeset/answer-all-duplicate-prompts.md @@ -0,0 +1,26 @@ +--- +'frontend': patch +--- + +Answer every duplicate prompt at once instead of one file at a time + +Dropping ten files onto ten that already exist asked ten identical questions, +one after another, each needing its own click. Nothing said how many were left, +so there was no way to tell whether answering meant one more click or nine. + +The dialog now offers to let the answer stand for every collision left in the +drop, so ten files take one click. It is honoured by the loop that raises the +prompts, in use-upload-dispatch, by not asking again. That loop awaits each +answer before the next question exists, so there is never a queue of prompts to +answer in bulk - which is the shape this looked like it had from the store. + +The dialog also shows how many are left, which is most of what made repeating +the same answer feel endless. + +Cancel used to close the dialog and resolve nothing, leaving the loop awaiting +an answer that never came: every file behind the cancelled one was never asked +about and never uploaded, and the drop stalled there in silence. Cancel is an +answer now. With more than one collision it reads "Skip this one", and a "Cancel +all" beside it abandons the rest of the drop. A file left alone this way gets a +notice saying so rather than the "could not find a free name" one, which was +about a different thing entirely. diff --git a/.changeset/duplicate-policy-setting.md b/.changeset/duplicate-policy-setting.md new file mode 100644 index 0000000..edc4590 --- /dev/null +++ b/.changeset/duplicate-policy-setting.md @@ -0,0 +1,20 @@ +--- +'frontend': patch +--- + +Let the answer to "this file already exists" be decided once, in settings + +The prompt can now be answered in advance. General settings has a "When a file +already exists" choice: ask, keep both, or replace. It defaults to ask, which is +the behaviour there has always been, because deciding to overwrite by default is +the user's call rather than something to inherit from an install. + +It is the same mechanism the prompt's own "do the same for the rest" uses. A +policy simply seeds that standing answer before the first question is asked, so +a drop of ten colliding files finishes without a single prompt. + +Replacing without being asked leaves a notice on the transfers card saying how +many files were overwritten, and the setting says so beside the choice. A file +that was there is gone, and a setting chosen weeks earlier is not something +anyone remembers at the moment it acts. Keeping both takes nothing away and the +new name is already on the card, so it passes without one. diff --git a/frontend/src/features/dashboard/components/layout/sidebar/sidebar-create-button.tsx b/frontend/src/features/dashboard/components/layout/sidebar/sidebar-create-button.tsx index 230f707..7addf2f 100644 --- a/frontend/src/features/dashboard/components/layout/sidebar/sidebar-create-button.tsx +++ b/frontend/src/features/dashboard/components/layout/sidebar/sidebar-create-button.tsx @@ -99,13 +99,17 @@ export const SidebarCreateButton: React.FC = ({ onClic {duplicateDialog.isOpen && ( {})} - onKeepBoth={duplicateDialog.onKeepBoth || (() => {})} + // One folder, so there is never a rest of the drop to apply an answer + // to and the bulk controls stay hidden. Both handlers close the dialog + // themselves once the folder is created. + onResolve={(choice) => + choice === 'replace' ? duplicateDialog.onReplace?.() : duplicateDialog.onKeepBoth?.() + } + onCancel={hideDuplicateDialog} /> )} diff --git a/frontend/src/features/settings/components/general-settings-panel.tsx b/frontend/src/features/settings/components/general-settings-panel.tsx index a39c63e..b4c4801 100644 --- a/frontend/src/features/settings/components/general-settings-panel.tsx +++ b/frontend/src/features/settings/components/general-settings-panel.tsx @@ -4,7 +4,7 @@ import { GeneralSettings } from '../types'; import { START_PAGE_OPTIONS, BULK_SHARE_DURATION_OPTIONS, isValidDuration } from '../constants'; import { RadioGroup } from './radio-group'; import { CustomDropdown } from '@/shared/components/ui/custom-dropdown'; -import { UploadModeSettings } from '@/features/upload'; +import { DuplicatePolicySettings, UploadModeSettings } from '@/features/upload'; interface GeneralSettingsPanelProps { settings: GeneralSettings; @@ -61,6 +61,19 @@ export function GeneralSettingsPanel({ settings, onUpdate }: GeneralSettingsPane +
+
+

When a file already exists

+

+ What to do when something you upload has the same name as a file already there. The + prompt can still answer for a whole drop at once. +

+
+
+ +
+
+

Bulk file share duration

diff --git a/frontend/src/features/upload/components/duplicate-dialog.tsx b/frontend/src/features/upload/components/duplicate-dialog.tsx index ecbb9e6..bb933c9 100644 --- a/frontend/src/features/upload/components/duplicate-dialog.tsx +++ b/frontend/src/features/upload/components/duplicate-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { File, Folder, AlertTriangle } from 'lucide-react'; import { Dialog, @@ -18,32 +18,52 @@ export interface DuplicateItem { interface DuplicateDialogProps { isOpen: boolean; - onClose: () => void; - onReplace: () => void; - onKeepBoth: () => void; duplicateItem: DuplicateItem | null; + /** + * How many collisions are left in this drop, this one included. Only one is + * ever asked at a time, so this is what the rest of the drop looks like + * rather than a count of open dialogs. + */ + pendingCount?: number; + /** `applyToAll` asks for this answer to stand for every collision left. */ + onResolve: (choice: 'replace' | 'keepBoth', applyToAll: boolean) => void; + onCancel: (applyToAll: boolean) => void; } export function DuplicateDialog({ isOpen, - onClose, - onReplace, - onKeepBoth, duplicateItem, + pendingCount = 1, + onResolve, + onCancel, }: DuplicateDialogProps) { const [selectedAction, setSelectedAction] = useState<'replace' | 'keep-both'>('keep-both'); + const [applyToAll, setApplyToAll] = useState(false); + + /** Only worth offering when there is something else to apply the answer to. */ + const hasQueue = pendingCount > 1; + const others = pendingCount - 1; + + /** + * Cleared by hand, because the dialog is never unmounted between prompts: + * answering one reveals the next while it stays open. Only on the way back + * open, which happens between drops rather than between files, so ticking the + * box for one batch cannot carry into the next. + */ + useEffect(() => { + if (isOpen) setApplyToAll(false); + }, [isOpen]); const handleUpload = () => { - if (selectedAction === 'keep-both') { - onKeepBoth(); - } else { - onReplace(); - } - onClose(); + onResolve(selectedAction === 'keep-both' ? 'keepBoth' : 'replace', applyToAll); }; const handleCancel = () => { - onClose(); + onCancel(false); + }; + + const handleCancelAll = () => { + onCancel(true); }; if (!duplicateItem) return null; @@ -68,6 +88,17 @@ export function DuplicateDialog({ {duplicateItem.type === 'file' ? 'File already exists' : 'Folder already exists'} + {/* Without this there was no way to tell whether answering meant + one more click or nine, which is most of what made repeating + the same answer feel endless. */} + {hasQueue && ( + + {pendingCount} left + + )}
A {duplicateItem.type} named "{duplicateItem.name}" already exists in this location. @@ -168,6 +199,27 @@ export function DuplicateDialog({
+ + {hasQueue && ( + + )}
- Cancel + {/* "Cancel" was only ever skipping this one file, which was not + obvious while nine more waited behind it. */} + {hasQueue ? 'Skip this one' : 'Cancel'} + {hasQueue && ( + + )}