3adcf9d30e
Models are always re-pushed server-side since Draco compression changes the file size, making client/remote size comparison unreliable. Textures are still compared by size (not compressed, reliable). Client-side diff now only flags models as 'new' if absent from remote, and never as 'changed' (server handles the actual push decision).
333 lines
9.1 KiB
TypeScript
333 lines
9.1 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]))
|
|
|
|
const diffs: FileDiff[] = []
|
|
const localNames = new Set<string>()
|
|
|
|
// Model: skip size comparison (compression changes the size).
|
|
// We only check if it exists on remote or not.
|
|
const modelKey = folder.modelFile.name.toLowerCase()
|
|
localNames.add(modelKey)
|
|
if (!remoteMap.has(modelKey)) {
|
|
diffs.push({ name: folder.modelFile.name, status: 'new' })
|
|
}
|
|
// If model exists on remote → don't add to diffs (we can't know if it changed)
|
|
|
|
// Textures: compare by size (not compressed, so size is reliable)
|
|
for (const tex of folder.textures) {
|
|
const key = tex.name.toLowerCase()
|
|
localNames.add(key)
|
|
const remoteSize = remoteMap.get(key)
|
|
if (remoteSize === undefined) {
|
|
diffs.push({ name: tex.name, status: 'new' })
|
|
} else if (remoteSize !== tex.file.size) {
|
|
diffs.push({ name: tex.name, status: 'changed' })
|
|
}
|
|
}
|
|
|
|
// Files on remote but not in local → deleted
|
|
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>
|
|
)
|
|
}
|