mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-25 07:15:36 +00:00
@@ -25,4 +25,4 @@ export const PhotoUploadConfirmModal: ModalComponent<PhotoUploadConfirmModalProp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
PhotoUploadConfirmModal.contentClassName = 'w-[min(520px,92vw)] p-6'
|
PhotoUploadConfirmModal.contentClassName = 'w-[min(520px,92vw)]'
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user