91eaa5d186
Git LFS stores pointer files whose SHA differs from the actual blob SHA, causing false-positive diffs on every upload. Switching to file size comparison resolves this for LFS-enabled repos. Also replaces the inline error message with a dedicated NoChangesModal when no differences are detected, offering cancel (reset) or modify (close modal) actions.
335 lines
8.9 KiB
TypeScript
335 lines
8.9 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef } from 'react'
|
|
import type { Destination } from '@/lib/constants'
|
|
import { DESTINATIONS } from '@/lib/constants'
|
|
import type { FolderEntry } from '@/lib/client-types'
|
|
import type { FileDiff } from '@/lib/types'
|
|
import { useSecret } from '@/hooks/useSecret'
|
|
import { useFolderEntries } from '@/hooks/useFolderEntries'
|
|
import SecretInput from './upload/SecretInput'
|
|
import DestinationPicker from './upload/DestinationPicker'
|
|
import FolderDropzone from './upload/FolderDropzone'
|
|
import FolderCard from './upload/FolderCard'
|
|
import ActionButtons from './upload/ActionButtons'
|
|
import OverwriteConfirmModal from './upload/OverwriteConfirmModal'
|
|
import NoChangesModal from './upload/NoChangesModal'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CheckResult {
|
|
exists: boolean
|
|
diffs: FileDiff[]
|
|
}
|
|
|
|
async function checkFolderDiffs(
|
|
folder: FolderEntry,
|
|
destination: string,
|
|
secret: string,
|
|
signal?: AbortSignal,
|
|
): Promise<CheckResult> {
|
|
try {
|
|
const params = new URLSearchParams({ folderName: folder.folderName, destination })
|
|
const res = await fetch(`/api/upload/check?${params}`, {
|
|
headers: { 'x-upload-secret': secret.trim() },
|
|
signal,
|
|
})
|
|
const data = await res.json()
|
|
if (!data.success || !data.exists) {
|
|
return { exists: false, diffs: [] }
|
|
}
|
|
|
|
const remoteFiles: { name: string; size: number }[] = data.files || []
|
|
const remoteMap = new Map(remoteFiles.map((f) => [f.name.toLowerCase(), f.size]))
|
|
|
|
// Build local file list with sizes
|
|
const localFiles: { name: string; size: number }[] = []
|
|
localFiles.push({
|
|
name: folder.modelFile.name,
|
|
size: folder.modelFile.size,
|
|
})
|
|
for (const tex of folder.textures) {
|
|
localFiles.push({
|
|
name: tex.name,
|
|
size: tex.file.size,
|
|
})
|
|
}
|
|
|
|
const diffs: FileDiff[] = []
|
|
const localNames = new Set<string>()
|
|
|
|
for (const local of localFiles) {
|
|
const key = local.name.toLowerCase()
|
|
localNames.add(key)
|
|
const remoteSize = remoteMap.get(key)
|
|
if (remoteSize === undefined) {
|
|
diffs.push({ name: local.name, status: 'new' })
|
|
} else if (remoteSize !== local.size) {
|
|
diffs.push({ name: local.name, status: 'changed' })
|
|
}
|
|
}
|
|
|
|
for (const [name] of remoteMap) {
|
|
if (!localNames.has(name)) {
|
|
diffs.push({ name, status: 'deleted' })
|
|
}
|
|
}
|
|
|
|
return { exists: true, diffs }
|
|
} catch {
|
|
return { exists: false, diffs: [] }
|
|
}
|
|
}
|
|
|
|
async function uploadFolder(
|
|
folder: FolderEntry,
|
|
secret: string,
|
|
destination: string,
|
|
onProgress: (pct: number) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<{ success: boolean; filename?: string; error?: string }> {
|
|
const formData = new FormData()
|
|
formData.append('folderName', folder.folderName)
|
|
formData.append('destination', destination)
|
|
|
|
formData.append('files', folder.modelFile)
|
|
formData.append('fileTypes', 'model')
|
|
formData.append('textureNames', '')
|
|
|
|
for (const tex of folder.textures) {
|
|
formData.append('files', tex.file)
|
|
formData.append('fileTypes', 'texture')
|
|
formData.append('textureNames', tex.name)
|
|
}
|
|
|
|
onProgress(10)
|
|
|
|
try {
|
|
const res = await fetch('/api/upload/git', {
|
|
method: 'POST',
|
|
headers: { 'x-upload-secret': secret.trim() },
|
|
body: formData,
|
|
signal,
|
|
})
|
|
|
|
onProgress(80)
|
|
const data = await res.json()
|
|
|
|
if (!data.success) {
|
|
return { success: false, error: data.error }
|
|
}
|
|
|
|
onProgress(100)
|
|
return { success: true, filename: folder.folderName }
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
return { success: false, error: 'Upload annule' }
|
|
}
|
|
return { success: false, error: 'Erreur reseau' }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UploadZone — orchestrator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function UploadZone() {
|
|
const {
|
|
secret,
|
|
secretError,
|
|
secretVisible,
|
|
isSecretEmpty,
|
|
setSecretError,
|
|
handleSecretChange,
|
|
toggleSecretVisible,
|
|
} = useSecret()
|
|
|
|
const {
|
|
entries,
|
|
setEntries,
|
|
updateEntry,
|
|
removeEntry,
|
|
resetEntries,
|
|
allDone,
|
|
hasErrors,
|
|
} = useFolderEntries()
|
|
|
|
const [isUploading, setIsUploading] = useState(false)
|
|
const [globalError, setGlobalError] = useState<string | null>(null)
|
|
const [destination, setDestination] = useState<Destination>(DESTINATIONS[0].value)
|
|
const [overwriteConfirm, setOverwriteConfirm] = useState<{
|
|
folderName: string
|
|
diffs: FileDiff[]
|
|
} | null>(null)
|
|
const [noChangesFolder, setNoChangesFolder] = useState<string | null>(null)
|
|
const abortRef = useRef<AbortController | null>(null)
|
|
|
|
// -- Handlers --
|
|
|
|
const handleFolderSelected = (entry: FolderEntry) => {
|
|
setGlobalError(null)
|
|
setEntries([entry])
|
|
}
|
|
|
|
const handleToggleViewer = (index: number) => {
|
|
const entry = entries[index]
|
|
if (entry?.modelUrl) {
|
|
updateEntry(index, { viewerOpen: !entry.viewerOpen })
|
|
}
|
|
}
|
|
|
|
const handleUpload = async () => {
|
|
if (!secret.trim()) {
|
|
setSecretError("La cle d'acces est requise")
|
|
return
|
|
}
|
|
if (entries.length === 0) return
|
|
|
|
setSecretError(null)
|
|
setGlobalError(null)
|
|
|
|
const folder = entries[0]
|
|
const check = await checkFolderDiffs(folder, destination, secret, abortRef.current?.signal)
|
|
if (check.exists) {
|
|
if (check.diffs.length === 0) {
|
|
setNoChangesFolder(folder.folderName)
|
|
return
|
|
}
|
|
setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs })
|
|
return
|
|
}
|
|
|
|
await proceedUpload()
|
|
}
|
|
|
|
const proceedUpload = async () => {
|
|
setOverwriteConfirm(null)
|
|
setIsUploading(true)
|
|
setGlobalError(null)
|
|
|
|
const controller = new AbortController()
|
|
abortRef.current = controller
|
|
|
|
for (let i = 0; i < entries.length; i++) {
|
|
if (entries[i].status === 'success') continue
|
|
if (controller.signal.aborted) break
|
|
|
|
updateEntry(i, { status: 'uploading', progress: 0, error: undefined })
|
|
|
|
const result = await uploadFolder(
|
|
entries[i],
|
|
secret,
|
|
destination,
|
|
(pct) => updateEntry(i, { progress: pct }),
|
|
controller.signal,
|
|
)
|
|
|
|
updateEntry(i, {
|
|
status: result.success ? 'success' : 'error',
|
|
progress: result.success ? 100 : 0,
|
|
error: result.success ? undefined : result.error,
|
|
filename: result.filename,
|
|
})
|
|
}
|
|
|
|
abortRef.current = null
|
|
setIsUploading(false)
|
|
}
|
|
|
|
const handleCancel = () => {
|
|
abortRef.current?.abort()
|
|
abortRef.current = null
|
|
setIsUploading(false)
|
|
}
|
|
|
|
const handleReset = () => {
|
|
resetEntries()
|
|
setGlobalError(null)
|
|
setIsUploading(false)
|
|
}
|
|
|
|
const hasPendingOrErrors = entries.some((f) => f.status === 'pending' || f.status === 'error')
|
|
|
|
// -- Render --
|
|
|
|
return (
|
|
<div className="w-full max-w-2xl space-y-4">
|
|
<SecretInput
|
|
secret={secret}
|
|
secretVisible={secretVisible}
|
|
secretError={secretError}
|
|
disabled={isUploading}
|
|
onChange={handleSecretChange}
|
|
onToggleVisible={toggleSecretVisible}
|
|
/>
|
|
|
|
<DestinationPicker
|
|
destination={destination}
|
|
disabled={isUploading}
|
|
onChange={setDestination}
|
|
/>
|
|
|
|
{entries.length === 0 && (
|
|
<FolderDropzone
|
|
isUploading={isUploading}
|
|
onFolderSelected={handleFolderSelected}
|
|
onError={setGlobalError}
|
|
/>
|
|
)}
|
|
|
|
{globalError && (
|
|
<p className="text-xs text-red-400 text-center">{globalError}</p>
|
|
)}
|
|
|
|
{entries.length > 0 && (
|
|
<div className="space-y-2">
|
|
{entries.map((entry, i) => (
|
|
<FolderCard
|
|
key={i}
|
|
entry={entry}
|
|
index={i}
|
|
onToggleViewer={handleToggleViewer}
|
|
onRemove={removeEntry}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<ActionButtons
|
|
isUploading={isUploading}
|
|
isSecretEmpty={isSecretEmpty}
|
|
hasPendingOrErrors={hasPendingOrErrors}
|
|
allDone={allDone}
|
|
hasErrors={hasErrors}
|
|
onUpload={handleUpload}
|
|
onCancel={handleCancel}
|
|
onReset={handleReset}
|
|
/>
|
|
|
|
{overwriteConfirm && (
|
|
<OverwriteConfirmModal
|
|
destination={destination}
|
|
folderName={overwriteConfirm.folderName}
|
|
diffs={overwriteConfirm.diffs}
|
|
onCancel={() => setOverwriteConfirm(null)}
|
|
onConfirm={proceedUpload}
|
|
/>
|
|
)}
|
|
|
|
{noChangesFolder && (
|
|
<NoChangesModal
|
|
destination={destination}
|
|
folderName={noChangesFolder}
|
|
onCancel={() => {
|
|
setNoChangesFolder(null)
|
|
handleReset()
|
|
}}
|
|
onModify={() => setNoChangesFolder(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|