feat: upload modal

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-16 02:09:36 +08:00
parent 2f1b69f0bd
commit 1616f866e5
6 changed files with 87 additions and 34 deletions

View File

@@ -25,4 +25,4 @@ export const PhotoUploadConfirmModal: ModalComponent<PhotoUploadConfirmModalProp
) )
} }
PhotoUploadConfirmModal.contentClassName = 'w-[min(520px,92vw)] p-6' PhotoUploadConfirmModal.contentClassName = 'w-[min(520px,92vw)]'

View File

@@ -3,7 +3,6 @@ import { clsxm } from '@afilmory/utils'
import type { KeyboardEvent } from 'react' import type { KeyboardEvent } from 'react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
type AutoSelectOption = { type AutoSelectOption = {
label: string label: string
@@ -50,7 +49,11 @@ export function AutoSelect({ options, value, onChange, placeholder, disabled }:
} }
if (event.key === 'Backspace' && !query && value.length > 0) { if (event.key === 'Backspace' && !query && value.length > 0) {
event.preventDefault() event.preventDefault()
handleRemove(value.at(-1)) const last = value.at(-1)
if (!last) {
return
}
handleRemove(last)
} }
} }
@@ -58,9 +61,9 @@ export function AutoSelect({ options, value, onChange, placeholder, disabled }:
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<LinearBorderPanel <div
className={clsxm( className={clsxm(
'bg-background px-3 py-2 transition-opacity duration-200', 'bg-background px-3 rounded border-[0.5px] py-2 transition-opacity duration-200',
disabled && 'pointer-events-none opacity-60', disabled && 'pointer-events-none opacity-60',
)} )}
> >
@@ -70,13 +73,13 @@ export function AutoSelect({ options, value, onChange, placeholder, disabled }:
key={tag} key={tag}
type="button" type="button"
onClick={() => handleRemove(tag)} onClick={() => handleRemove(tag)}
className="bg-accent/10 text-accent hover:bg-accent/20 flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px]" className="bg-accent/10 text-accent hover:bg-accent/20 flex items-center gap-1 rounded px-2 py-0.5 text-[11px]"
> >
{tag} {tag}
<span className="text-[10px] opacity-80">×</span> <span className="text-sm opacity-80">×</span>
</button> </button>
))} ))}
<div className="min-w-[160px] flex-1"> <div className="min-w-40 flex-1">
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
@@ -87,10 +90,10 @@ export function AutoSelect({ options, value, onChange, placeholder, disabled }:
/> />
</div> </div>
</div> </div>
</LinearBorderPanel> </div>
{(filteredOptions.length > 0 || showCreateOption) && !disabled ? ( {(filteredOptions.length > 0 || showCreateOption) && !disabled ? (
<LinearBorderPanel className="bg-background-tertiary/70 px-3 py-2"> <div className="bg-background-tertiary/70 rounded px-3 py-2">
<p className="text-text-tertiary mb-1 text-[11px]"></p> <p className="text-text-tertiary mb-1 text-[11px]"></p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{filteredOptions.map((option) => ( {filteredOptions.map((option) => (
@@ -117,7 +120,7 @@ export function AutoSelect({ options, value, onChange, placeholder, disabled }:
</Button> </Button>
) : null} ) : null}
</div> </div>
</LinearBorderPanel> </div>
) : null} ) : null}
</div> </div>
) )

View File

@@ -1,8 +1,8 @@
import { Button, ScrollArea } from '@afilmory/ui'
import { Spring } from '@afilmory/utils' import { Spring } from '@afilmory/utils'
import { X } from 'lucide-react'
import { m } from 'motion/react' import { m } from 'motion/react'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { FILE_STATUS_CLASS, FILE_STATUS_LABEL } from './constants' import { FILE_STATUS_CLASS, FILE_STATUS_LABEL } from './constants'
import type { FileProgressEntry } from './types' import type { FileProgressEntry } from './types'
import { formatBytes } from './utils' import { formatBytes } from './utils'
@@ -10,9 +10,10 @@ import { formatBytes } from './utils'
type UploadFileListProps = { type UploadFileListProps = {
entries: FileProgressEntry[] entries: FileProgressEntry[]
overallProgress: number overallProgress: number
onRemoveEntry?: (entry: FileProgressEntry) => void
} }
export function UploadFileList({ entries, overallProgress }: UploadFileListProps) { export function UploadFileList({ entries, overallProgress, onRemoveEntry }: UploadFileListProps) {
return ( return (
<div> <div>
<div className="flex items-center justify-between text-xs text-text-tertiary"> <div className="flex items-center justify-between text-xs text-text-tertiary">
@@ -27,7 +28,8 @@ export function UploadFileList({ entries, overallProgress }: UploadFileListProps
transition={Spring.presets.smooth} transition={Spring.presets.smooth}
/> />
</div> </div>
<LinearBorderPanel className="bg-background/60 mt-4 max-h-60 overflow-auto">
<ScrollArea rootClassName="h-60 mt-4 -mx-4" viewportClassName="px-4">
<m.ul <m.ul
initial={{ opacity: 0, y: 6 }} initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -37,7 +39,7 @@ export function UploadFileList({ entries, overallProgress }: UploadFileListProps
{entries.map((entry) => ( {entries.map((entry) => (
<li <li
key={`${entry.name}-${entry.index}`} key={`${entry.name}-${entry.index}`}
className="text-text-secondary flex flex-col gap-2 px-4 py-2 text-sm" className="text-text-secondary flex flex-col gap-2 px-2 py-2 text-sm"
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
@@ -46,9 +48,24 @@ export function UploadFileList({ entries, overallProgress }: UploadFileListProps
</span> </span>
<p className="text-text-tertiary text-[11px]">{formatBytes(entry.size)}</p> <p className="text-text-tertiary text-[11px]">{formatBytes(entry.size)}</p>
</div> </div>
<span className={`${FILE_STATUS_CLASS[entry.status]} text-[11px] font-medium`}> <div className="flex items-center gap-1.5">
{FILE_STATUS_LABEL[entry.status]} <span className={`${FILE_STATUS_CLASS[entry.status]} text-[11px] font-medium`}>
</span> {FILE_STATUS_LABEL[entry.status]}
</span>
{onRemoveEntry ? (
<Button
type="button"
size="xs"
variant="ghost"
className="text-text-tertiary hover:text-rose-300"
aria-label="删除文件"
disabled={!(entry.status === 'pending' || entry.status === 'error')}
onClick={() => (entry.status === 'pending' || entry.status === 'error') && onRemoveEntry(entry)}
>
<X className="h-3 w-3" strokeWidth={2} />
</Button>
) : null}
</div>
</div> </div>
<div className="bg-fill/20 h-1.5 rounded-full"> <div className="bg-fill/20 h-1.5 rounded-full">
<div <div
@@ -67,7 +84,7 @@ export function UploadFileList({ entries, overallProgress }: UploadFileListProps
</li> </li>
))} ))}
</m.ul> </m.ul>
</LinearBorderPanel> </ScrollArea>
</div> </div>
) )
} }

View File

@@ -30,6 +30,7 @@ export function ReviewStep() {
const beginUpload = usePhotoUploadStore((state) => state.beginUpload) const beginUpload = usePhotoUploadStore((state) => state.beginUpload)
const closeModal = usePhotoUploadStore((state) => state.closeModal) const closeModal = usePhotoUploadStore((state) => state.closeModal)
const setSelectedTags = usePhotoUploadStore((state) => state.setSelectedTags) const setSelectedTags = usePhotoUploadStore((state) => state.setSelectedTags)
const removeEntry = usePhotoUploadStore((state) => state.removeEntry)
const tagOptions = useMemo( const tagOptions = useMemo(
() => availableTags.map((tag) => ({ label: tag, value: tag.toLowerCase() })), () => availableTags.map((tag) => ({ label: tag, value: tag.toLowerCase() })),
@@ -42,7 +43,7 @@ export function ReviewStep() {
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-text text-lg font-semibold"></h2> <h2 className="text-text text-lg font-semibold"></h2>
<p className="text-text-tertiary text-sm"> <p className="text-text-tertiary text-sm">
{filesCount} {formatBytes(totalSize)} {filesCount} {formatBytes(totalSize)}
</p> </p>
</div> </div>
@@ -63,10 +64,10 @@ export function ReviewStep() {
options={tagOptions} options={tagOptions}
value={selectedTags} value={selectedTags}
onChange={setSelectedTags} onChange={setSelectedTags}
placeholder="输入后按 Enter 添加,或从建议中选择" placeholder="标签:输入后按 Enter 添加,或从建议中选择"
/> />
<UploadFileList entries={uploadEntries} overallProgress={progress} /> <UploadFileList entries={uploadEntries} overallProgress={progress} onRemoveEntry={removeEntry} />
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button <Button

View File

@@ -32,6 +32,7 @@ type PhotoUploadStoreState = {
uploadError: string | null uploadError: string | null
processingError: string | null processingError: string | null
processingState: ProcessingState | null processingState: ProcessingState | null
removeEntry: (entry: FileProgressEntry) => void
beginUpload: () => Promise<void> beginUpload: () => Promise<void>
abortCurrent: () => void abortCurrent: () => void
reset: () => void reset: () => void
@@ -54,10 +55,10 @@ const PhotoUploadStoreContext = createContext<PhotoUploadStore | null>(null)
const computeUploadedBytes = (entries: FileProgressEntry[]) => calculateUploadedBytes(entries) const computeUploadedBytes = (entries: FileProgressEntry[]) => calculateUploadedBytes(entries)
export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUploadStore { export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUploadStore {
const { files, availableTags, onUpload, onClose } = params const { files: initialFiles, availableTags, onUpload, onClose } = params
const initialEntries = createFileEntries(files) const initialEntries = createFileEntries(initialFiles)
const totalSize = calculateTotalSize(files) const totalSize = calculateTotalSize(initialFiles)
const { unmatched: unmatchedMovFiles, hasMov } = collectUnmatchedMovFiles(files) const { unmatched: unmatchedMovFiles, hasMov } = collectUnmatchedMovFiles(initialFiles)
let uploadAbortController: AbortController | null = null let uploadAbortController: AbortController | null = null
let processingAbortController: AbortController | null = null let processingAbortController: AbortController | null = null
@@ -247,7 +248,7 @@ export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUpl
} }
return { return {
files, files: initialFiles,
totalSize, totalSize,
uploadedBytes: 0, uploadedBytes: 0,
availableTags, availableTags,
@@ -259,8 +260,36 @@ export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUpl
uploadError: null, uploadError: null,
processingError: null, processingError: null,
processingState: null, processingState: null,
removeEntry: (entry) => {
set((state) => {
if (state.phase !== 'review') {
return {}
}
const nextFiles = state.files.filter((_, index) => index !== entry.index)
if (nextFiles.length === state.files.length) {
return {}
}
const nextEntries = createFileEntries(nextFiles)
const nextTotalSize = calculateTotalSize(nextFiles)
const { unmatched, hasMov } = collectUnmatchedMovFiles(nextFiles)
return {
files: nextFiles,
totalSize: nextTotalSize,
unmatchedMovFiles: unmatched,
hasMovFile: hasMov,
uploadEntries: nextEntries,
uploadedBytes: computeUploadedBytes(nextEntries),
}
})
},
beginUpload: async () => { beginUpload: async () => {
if (get().unmatchedMovFiles.length > 0 || get().phase === 'uploading' || get().phase === 'processing') { const state = get()
if (state.unmatchedMovFiles.length > 0 || state.phase === 'uploading' || state.phase === 'processing') {
return
}
if (state.files.length === 0) {
return return
} }
@@ -283,7 +312,7 @@ export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUpl
try { try {
const directory = deriveDirectoryFromTags(get().selectedTags) const directory = deriveDirectoryFromTags(get().selectedTags)
const fileList = createFileList(files) const fileList = createFileList(get().files)
await onUpload(fileList, { await onUpload(fileList, {
signal: controller.signal, signal: controller.signal,
directory: directory ?? undefined, directory: directory ?? undefined,
@@ -304,7 +333,8 @@ export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUpl
const isAbort = (error as DOMException)?.name === 'AbortError' const isAbort = (error as DOMException)?.name === 'AbortError'
if (isAbort) { if (isAbort) {
set({ phase: 'review' }) set({ phase: 'review' })
updateEntries(() => createFileEntries(files)) const currentFiles = get().files
updateEntries(() => createFileEntries(currentFiles))
} else { } else {
const message = getErrorMessage(error, '上传失败,请稍后再试。') const message = getErrorMessage(error, '上传失败,请稍后再试。')
set({ set({
@@ -328,7 +358,8 @@ export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUpl
uploadAbortController?.abort() uploadAbortController?.abort()
uploadAbortController = null uploadAbortController = null
set({ phase: 'review' }) set({ phase: 'review' })
updateEntries(() => createFileEntries(files)) const currentFiles = get().files
updateEntries(() => createFileEntries(currentFiles))
return return
} }
if (phase === 'processing') { if (phase === 'processing') {
@@ -358,7 +389,8 @@ export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUpl
processingError: null, processingError: null,
processingState: null, processingState: null,
}) })
updateEntries(() => createFileEntries(files)) const currentFiles = get().files
updateEntries(() => createFileEntries(currentFiles))
}, },
closeModal: () => { closeModal: () => {
get().cleanup() get().cleanup()

View File

@@ -156,7 +156,7 @@ function DialogContent({
}} }}
transition={transition} transition={transition}
className={clsxm( className={clsxm(
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background px-3 pt-4 pb-3 rounded-lg', 'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background px-3 pt-4 pb-3 rounded-lg shape-squircle',
className, className,
)} )}
{...props} {...props}