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 { useMemo, useState } from 'react'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
type AutoSelectOption = {
label: string
@@ -50,7 +49,11 @@ export function AutoSelect({ options, value, onChange, placeholder, disabled }:
}
if (event.key === 'Backspace' && !query && value.length > 0) {
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 (
<div className="space-y-3">
<LinearBorderPanel
<div
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',
)}
>
@@ -70,13 +73,13 @@ export function AutoSelect({ options, value, onChange, placeholder, disabled }:
key={tag}
type="button"
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}
<span className="text-[10px] opacity-80">×</span>
<span className="text-sm opacity-80">×</span>
</button>
))}
<div className="min-w-[160px] flex-1">
<div className="min-w-40 flex-1">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -87,10 +90,10 @@ export function AutoSelect({ options, value, onChange, placeholder, disabled }:
/>
</div>
</div>
</LinearBorderPanel>
</div>
{(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>
<div className="flex flex-wrap gap-1">
{filteredOptions.map((option) => (
@@ -117,7 +120,7 @@ export function AutoSelect({ options, value, onChange, placeholder, disabled }:
</Button>
) : null}
</div>
</LinearBorderPanel>
</div>
) : null}
</div>
)

View File

@@ -1,8 +1,8 @@
import { Button, ScrollArea } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { X } from 'lucide-react'
import { m } from 'motion/react'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { FILE_STATUS_CLASS, FILE_STATUS_LABEL } from './constants'
import type { FileProgressEntry } from './types'
import { formatBytes } from './utils'
@@ -10,9 +10,10 @@ import { formatBytes } from './utils'
type UploadFileListProps = {
entries: FileProgressEntry[]
overallProgress: number
onRemoveEntry?: (entry: FileProgressEntry) => void
}
export function UploadFileList({ entries, overallProgress }: UploadFileListProps) {
export function UploadFileList({ entries, overallProgress, onRemoveEntry }: UploadFileListProps) {
return (
<div>
<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}
/>
</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
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
@@ -37,7 +39,7 @@ export function UploadFileList({ entries, overallProgress }: UploadFileListProps
{entries.map((entry) => (
<li
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="min-w-0">
@@ -46,9 +48,24 @@ export function UploadFileList({ entries, overallProgress }: UploadFileListProps
</span>
<p className="text-text-tertiary text-[11px]">{formatBytes(entry.size)}</p>
</div>
<span className={`${FILE_STATUS_CLASS[entry.status]} text-[11px] font-medium`}>
{FILE_STATUS_LABEL[entry.status]}
</span>
<div className="flex items-center gap-1.5">
<span className={`${FILE_STATUS_CLASS[entry.status]} text-[11px] font-medium`}>
{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 className="bg-fill/20 h-1.5 rounded-full">
<div
@@ -67,7 +84,7 @@ export function UploadFileList({ entries, overallProgress }: UploadFileListProps
</li>
))}
</m.ul>
</LinearBorderPanel>
</ScrollArea>
</div>
)
}

View File

@@ -30,6 +30,7 @@ export function ReviewStep() {
const beginUpload = usePhotoUploadStore((state) => state.beginUpload)
const closeModal = usePhotoUploadStore((state) => state.closeModal)
const setSelectedTags = usePhotoUploadStore((state) => state.setSelectedTags)
const removeEntry = usePhotoUploadStore((state) => state.removeEntry)
const tagOptions = useMemo(
() => availableTags.map((tag) => ({ label: tag, value: tag.toLowerCase() })),
@@ -42,7 +43,7 @@ export function ReviewStep() {
<div className="space-y-2">
<h2 className="text-text text-lg font-semibold"></h2>
<p className="text-text-tertiary text-sm">
{filesCount} {formatBytes(totalSize)}
{filesCount} {formatBytes(totalSize)}
</p>
</div>
@@ -63,10 +64,10 @@ export function ReviewStep() {
options={tagOptions}
value={selectedTags}
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">
<Button

View File

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

View File

@@ -156,7 +156,7 @@ function DialogContent({
}}
transition={transition}
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,
)}
{...props}