fix: prevent duplicate uploads and group asset commits
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
findNextVersion,
|
findNextVersion,
|
||||||
} from '@/lib/nextcloud'
|
} from '@/lib/nextcloud'
|
||||||
|
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -56,6 +57,13 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!acquireUploadLock(folderName)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' },
|
||||||
|
{ status: 409 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
|
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
|
||||||
const vfFolderPath = `${basePath}/VF/${folderName}`
|
const vfFolderPath = `${basePath}/VF/${folderName}`
|
||||||
|
|
||||||
@@ -95,5 +103,7 @@ export async function POST(req: NextRequest) {
|
|||||||
{ success: false, error: `Drive echoue: ${message}` },
|
{ success: false, error: `Drive echoue: ${message}` },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
)
|
)
|
||||||
|
} finally {
|
||||||
|
releaseUploadLock(folderName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getRemoteFolder, pushAllToGitHub } from '@/lib/github'
|
|||||||
import { buildCommitMessage } from '@/lib/commit-message'
|
import { buildCommitMessage } from '@/lib/commit-message'
|
||||||
import { classifyFileChanges } from '@/lib/diff-files'
|
import { classifyFileChanges } from '@/lib/diff-files'
|
||||||
import { prepareGitAssets } from '@/lib/prepare-git-assets'
|
import { prepareGitAssets } from '@/lib/prepare-git-assets'
|
||||||
|
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -31,13 +32,21 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!acquireUploadLock(folderName)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' },
|
||||||
|
{ status: 409 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// --- Process files (compress model + textures for Git) ---
|
// --- Process files (compress model + textures for Git) ---
|
||||||
const {
|
const {
|
||||||
filesToPush,
|
filesToPush,
|
||||||
modelFilename,
|
modelFilename,
|
||||||
compressed,
|
compressed,
|
||||||
compressionError,
|
compressionError,
|
||||||
textureNames,
|
assetSummaries,
|
||||||
} = await prepareGitAssets({ folderName, parsedFiles })
|
} = await prepareGitAssets({ folderName, parsedFiles })
|
||||||
|
|
||||||
// --- Detect existing files and classify changes ---
|
// --- Detect existing files and classify changes ---
|
||||||
@@ -70,8 +79,12 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// --- Build commit message ---
|
// --- Build commit message ---
|
||||||
const commitMessage = buildCommitMessage(
|
const commitMessage = buildCommitMessage(
|
||||||
folderName, modelFilename, textureNames,
|
folderName,
|
||||||
compressed, isReplace, fileChanges, deletedFileNames,
|
modelFilename,
|
||||||
|
assetSummaries,
|
||||||
|
isReplace,
|
||||||
|
fileChanges,
|
||||||
|
deletedFileNames,
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Push all in one commit ---
|
// --- Push all in one commit ---
|
||||||
@@ -94,4 +107,7 @@ export async function POST(req: NextRequest) {
|
|||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
releaseUploadLock(folderName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<UploadZone />
|
<UploadZone />
|
||||||
|
|
||||||
<footer className="text-gray-500 text-xs text-center">
|
<footer className="mt-3 text-gray-500 text-xs text-center">
|
||||||
Modeles : <span className="font-mono text-gray-400">.glb</span>
|
Modeles : <span className="font-mono text-gray-400">.glb</span>
|
||||||
<span className="mx-2">·</span>
|
<span className="mx-2">·</span>
|
||||||
Textures : <span className="font-mono text-gray-400">.png · .jpg · .webp</span>
|
Textures : <span className="font-mono text-gray-400">.png · .jpg · .webp</span>
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export default function UploadZone() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
isUploading,
|
isUploading,
|
||||||
|
isChecking,
|
||||||
|
isResolvingDriveError,
|
||||||
globalError,
|
globalError,
|
||||||
setGlobalError,
|
setGlobalError,
|
||||||
overwriteConfirm,
|
overwriteConfirm,
|
||||||
@@ -43,6 +45,7 @@ export default function UploadZone() {
|
|||||||
setNoChangesFolder,
|
setNoChangesFolder,
|
||||||
driveError,
|
driveError,
|
||||||
handleUpload,
|
handleUpload,
|
||||||
|
handleOverwriteCancel,
|
||||||
handleDriveContinue,
|
handleDriveContinue,
|
||||||
handleDriveCancel,
|
handleDriveCancel,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
@@ -93,7 +96,7 @@ export default function UploadZone() {
|
|||||||
secret={secret}
|
secret={secret}
|
||||||
secretVisible={secretVisible}
|
secretVisible={secretVisible}
|
||||||
secretError={secretError}
|
secretError={secretError}
|
||||||
disabled={isUploading}
|
disabled={isUploading || isChecking}
|
||||||
onChange={handleSecretChange}
|
onChange={handleSecretChange}
|
||||||
onToggleVisible={toggleSecretVisible}
|
onToggleVisible={toggleSecretVisible}
|
||||||
/>
|
/>
|
||||||
@@ -101,7 +104,7 @@ export default function UploadZone() {
|
|||||||
{entries.length === 0 && (
|
{entries.length === 0 && (
|
||||||
<div>
|
<div>
|
||||||
<FolderDropzone
|
<FolderDropzone
|
||||||
isUploading={isUploading}
|
isUploading={isUploading || isChecking}
|
||||||
onFolderSelected={handleFolderSelected}
|
onFolderSelected={handleFolderSelected}
|
||||||
onError={setGlobalError}
|
onError={setGlobalError}
|
||||||
/>
|
/>
|
||||||
@@ -128,6 +131,7 @@ export default function UploadZone() {
|
|||||||
|
|
||||||
<ActionButtons
|
<ActionButtons
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
|
isChecking={isChecking}
|
||||||
isSecretEmpty={isSecretEmpty}
|
isSecretEmpty={isSecretEmpty}
|
||||||
hasPendingOrErrors={hasPendingOrErrors}
|
hasPendingOrErrors={hasPendingOrErrors}
|
||||||
allDone={allDone}
|
allDone={allDone}
|
||||||
@@ -141,8 +145,9 @@ export default function UploadZone() {
|
|||||||
<OverwriteConfirmModal
|
<OverwriteConfirmModal
|
||||||
folderName={overwriteConfirm.folderName}
|
folderName={overwriteConfirm.folderName}
|
||||||
diffs={overwriteConfirm.diffs}
|
diffs={overwriteConfirm.diffs}
|
||||||
onCancel={() => setOverwriteConfirm(null)}
|
onCancel={handleOverwriteCancel}
|
||||||
onConfirm={proceedUpload}
|
onConfirm={proceedUpload}
|
||||||
|
disabled={isUploading || isChecking || isResolvingDriveError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -162,6 +167,7 @@ export default function UploadZone() {
|
|||||||
error={driveError.error}
|
error={driveError.error}
|
||||||
onCancel={handleDriveCancel}
|
onCancel={handleDriveCancel}
|
||||||
onContinue={handleDriveContinue}
|
onContinue={handleDriveContinue}
|
||||||
|
disabled={isResolvingDriveError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface ModalActionsProps {
|
|||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
/** Tailwind classes for the confirm button (default: white bg) */
|
/** Tailwind classes for the confirm button (default: white bg) */
|
||||||
confirmClassName?: string
|
confirmClassName?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModalActions({
|
export function ModalActions({
|
||||||
@@ -43,20 +44,23 @@ export function ModalActions({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
confirmClassName = 'bg-white text-[#000000] hover:bg-gray-200',
|
confirmClassName = 'bg-white text-[#000000] hover:bg-gray-200',
|
||||||
|
disabled = false,
|
||||||
}: ModalActionsProps) {
|
}: ModalActionsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
|
disabled={disabled}
|
||||||
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
|
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
|
||||||
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150
|
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
hover:bg-black-600"
|
hover:bg-black-600"
|
||||||
>
|
>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className={`flex-1 font-medium text-sm py-2.5 px-4 rounded-xl transition-colors duration-150 ${confirmClassName}`}
|
disabled={disabled}
|
||||||
|
className={`flex-1 font-medium text-sm py-2.5 px-4 rounded-xl transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed ${confirmClassName}`}
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
interface ActionButtonsProps {
|
interface ActionButtonsProps {
|
||||||
isUploading: boolean
|
isUploading: boolean
|
||||||
|
isChecking: boolean
|
||||||
isSecretEmpty: boolean
|
isSecretEmpty: boolean
|
||||||
hasPendingOrErrors: boolean
|
hasPendingOrErrors: boolean
|
||||||
allDone: boolean
|
allDone: boolean
|
||||||
@@ -11,6 +12,7 @@ interface ActionButtonsProps {
|
|||||||
|
|
||||||
export default function ActionButtons({
|
export default function ActionButtons({
|
||||||
isUploading,
|
isUploading,
|
||||||
|
isChecking,
|
||||||
isSecretEmpty,
|
isSecretEmpty,
|
||||||
hasPendingOrErrors,
|
hasPendingOrErrors,
|
||||||
allDone,
|
allDone,
|
||||||
@@ -19,11 +21,12 @@ export default function ActionButtons({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onReset,
|
onReset,
|
||||||
}: ActionButtonsProps) {
|
}: ActionButtonsProps) {
|
||||||
const cantUpload = isSecretEmpty
|
const cantUpload = isSecretEmpty || isChecking
|
||||||
|
const isBusy = isUploading || isChecking
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{!isUploading && hasPendingOrErrors && (
|
{!isBusy && hasPendingOrErrors && (
|
||||||
<button
|
<button
|
||||||
onClick={onUpload}
|
onClick={onUpload}
|
||||||
disabled={cantUpload}
|
disabled={cantUpload}
|
||||||
@@ -38,18 +41,19 @@ export default function ActionButtons({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUploading && (
|
{isBusy && (
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
|
disabled={!isUploading}
|
||||||
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
|
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
|
||||||
py-2.5 px-6 rounded-xl border border-black-600 transition-colors duration-150
|
py-2.5 px-6 rounded-xl border border-black-600 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
hover:bg-black-600"
|
hover:bg-black-600"
|
||||||
>
|
>
|
||||||
Annuler
|
{isChecking ? 'Verification...' : 'Annuler'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(allDone || hasErrors) && !isUploading && (
|
{(allDone || hasErrors) && !isBusy && (
|
||||||
<button
|
<button
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
|
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ interface DriveErrorModalProps {
|
|||||||
error: string
|
error: string
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onContinue: () => void
|
onContinue: () => void
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DriveErrorModal({
|
export default function DriveErrorModal({
|
||||||
error,
|
error,
|
||||||
onCancel,
|
onCancel,
|
||||||
onContinue,
|
onContinue,
|
||||||
|
disabled = false,
|
||||||
}: DriveErrorModalProps) {
|
}: DriveErrorModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal ariaLabelledBy="drive-error-title">
|
<Modal ariaLabelledBy="drive-error-title">
|
||||||
@@ -41,6 +43,7 @@ export default function DriveErrorModal({
|
|||||||
confirmLabel="Envoyer sur Git seulement"
|
confirmLabel="Envoyer sur Git seulement"
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onContinue}
|
onConfirm={onContinue}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Drive/Git status sub-line for FolderCard
|
// Drive/Git status sub-line for FolderCard
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { SpinnerIcon, XIcon, InfoIcon } from '@/components/ui/icons'
|
import { SpinnerIcon, XIcon, WarningIcon } from '@/components/ui/icons'
|
||||||
import type { FolderEntry } from '@/lib/client-types'
|
import type { FolderEntry } from '@/lib/client-types'
|
||||||
|
|
||||||
interface DriveStatusLineProps {
|
interface DriveStatusLineProps {
|
||||||
@@ -35,7 +35,7 @@ export default function DriveStatusLine({ driveStatus, driveError }: DriveStatus
|
|||||||
)}
|
)}
|
||||||
{driveStatus === 'skipped' && (
|
{driveStatus === 'skipped' && (
|
||||||
<>
|
<>
|
||||||
<InfoIcon className="w-3 h-3 text-yellow-400" />
|
<WarningIcon className="w-3 h-3 text-yellow-400" />
|
||||||
<span className="text-xs text-yellow-400">Drive ignore</span>
|
<span className="text-xs text-yellow-400">Drive ignore</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface OverwriteConfirmModalProps {
|
|||||||
diffs: FileDiff[]
|
diffs: FileDiff[]
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OverwriteConfirmModal({
|
export default function OverwriteConfirmModal({
|
||||||
@@ -14,6 +15,7 @@ export default function OverwriteConfirmModal({
|
|||||||
diffs,
|
diffs,
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
disabled = false,
|
||||||
}: OverwriteConfirmModalProps) {
|
}: OverwriteConfirmModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal ariaLabelledBy="overwrite-title">
|
<Modal ariaLabelledBy="overwrite-title">
|
||||||
@@ -68,6 +70,7 @@ export default function OverwriteConfirmModal({
|
|||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
confirmClassName="bg-yellow-600 text-[#000000] hover:bg-yellow-500"
|
confirmClassName="bg-yellow-600 text-[#000000] hover:bg-yellow-500"
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export function useUploadOrchestrator({
|
|||||||
resetEntries,
|
resetEntries,
|
||||||
}: UseUploadOrchestratorParams) {
|
}: UseUploadOrchestratorParams) {
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [isChecking, setIsChecking] = useState(false)
|
||||||
|
const [isResolvingDriveError, setIsResolvingDriveError] = useState(false)
|
||||||
const [globalError, setGlobalError] = useState<string | null>(null)
|
const [globalError, setGlobalError] = useState<string | null>(null)
|
||||||
const [overwriteConfirm, setOverwriteConfirm] = useState<{
|
const [overwriteConfirm, setOverwriteConfirm] = useState<{
|
||||||
folderName: string
|
folderName: string
|
||||||
@@ -39,6 +41,7 @@ export function useUploadOrchestrator({
|
|||||||
|
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] })
|
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] })
|
||||||
|
const uploadActionRef = useRef(false)
|
||||||
|
|
||||||
// Refs for values used inside callbacks to avoid stale closures
|
// Refs for values used inside callbacks to avoid stale closures
|
||||||
const secretRef = useRef(secret)
|
const secretRef = useRef(secret)
|
||||||
@@ -67,13 +70,17 @@ export function useUploadOrchestrator({
|
|||||||
|
|
||||||
// ---- Main upload flow: Drive first, then Git ----
|
// ---- Main upload flow: Drive first, then Git ----
|
||||||
const proceedUpload = useCallback(async () => {
|
const proceedUpload = useCallback(async () => {
|
||||||
|
if (uploadActionRef.current) return
|
||||||
|
uploadActionRef.current = true
|
||||||
setOverwriteConfirm(null)
|
setOverwriteConfirm(null)
|
||||||
|
setIsChecking(false)
|
||||||
setIsUploading(true)
|
setIsUploading(true)
|
||||||
setGlobalError(null)
|
setGlobalError(null)
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
|
|
||||||
|
try {
|
||||||
const currentEntries = entriesRef.current
|
const currentEntries = entriesRef.current
|
||||||
|
|
||||||
for (let i = 0; i < currentEntries.length; i++) {
|
for (let i = 0; i < currentEntries.length; i++) {
|
||||||
@@ -110,20 +117,26 @@ export function useUploadOrchestrator({
|
|||||||
// ---- Step 2: Git upload ----
|
// ---- Step 2: Git upload ----
|
||||||
await pushGit(i, controller.signal)
|
await pushGit(i, controller.signal)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
|
uploadActionRef.current = false
|
||||||
|
}
|
||||||
}, [updateEntry, pushGit])
|
}, [updateEntry, pushGit])
|
||||||
|
|
||||||
// ---- Handlers ----
|
// ---- Handlers ----
|
||||||
|
|
||||||
const handleUpload = useCallback(async () => {
|
const handleUpload = useCallback(async () => {
|
||||||
|
if (uploadActionRef.current || isChecking || isUploading) return
|
||||||
|
|
||||||
if (!secretRef.current.trim()) {
|
if (!secretRef.current.trim()) {
|
||||||
setSecretError("La cle d'acces est requise")
|
setSecretError("La cle d'acces est requise")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (entriesRef.current.length === 0) return
|
if (entriesRef.current.length === 0) return
|
||||||
|
|
||||||
|
uploadActionRef.current = true
|
||||||
|
setIsChecking(true)
|
||||||
setSecretError(null)
|
setSecretError(null)
|
||||||
setGlobalError(null)
|
setGlobalError(null)
|
||||||
|
|
||||||
@@ -140,25 +153,35 @@ export function useUploadOrchestrator({
|
|||||||
if (check.exists) {
|
if (check.exists) {
|
||||||
if (check.diffs.length === 0) {
|
if (check.diffs.length === 0) {
|
||||||
setNoChangesFolder(folder.folderName)
|
setNoChangesFolder(folder.folderName)
|
||||||
|
uploadActionRef.current = false
|
||||||
|
setIsChecking(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs })
|
setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs })
|
||||||
|
uploadActionRef.current = false
|
||||||
|
setIsChecking(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
setGlobalError(message)
|
setGlobalError(message)
|
||||||
|
uploadActionRef.current = false
|
||||||
|
setIsChecking(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploadActionRef.current = false
|
||||||
await proceedUpload()
|
await proceedUpload()
|
||||||
}, [setSecretError, proceedUpload])
|
}, [setSecretError, proceedUpload, isChecking, isUploading])
|
||||||
|
|
||||||
const handleDriveContinue = useCallback(async () => {
|
const handleDriveContinue = useCallback(async () => {
|
||||||
if (!driveError) return
|
if (!driveError || uploadActionRef.current) return
|
||||||
|
uploadActionRef.current = true
|
||||||
|
setIsResolvingDriveError(true)
|
||||||
const idx = driveError.folderIndex
|
const idx = driveError.folderIndex
|
||||||
setDriveError(null)
|
setDriveError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
updateEntry(idx, { driveStatus: 'skipped' })
|
updateEntry(idx, { driveStatus: 'skipped' })
|
||||||
|
|
||||||
const signal = abortRef.current?.signal
|
const signal = abortRef.current?.signal
|
||||||
@@ -178,9 +201,12 @@ export function useUploadOrchestrator({
|
|||||||
|
|
||||||
await pushGit(i, abortRef.current?.signal)
|
await pushGit(i, abortRef.current?.signal)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
|
setIsResolvingDriveError(false)
|
||||||
|
uploadActionRef.current = false
|
||||||
|
}
|
||||||
}, [driveError, updateEntry, pushGit])
|
}, [driveError, updateEntry, pushGit])
|
||||||
|
|
||||||
const handleDriveCancel = useCallback(() => {
|
const handleDriveCancel = useCallback(() => {
|
||||||
@@ -197,25 +223,40 @@ export function useUploadOrchestrator({
|
|||||||
|
|
||||||
abortRef.current?.abort()
|
abortRef.current?.abort()
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
|
uploadActionRef.current = false
|
||||||
|
setIsChecking(false)
|
||||||
|
setIsResolvingDriveError(false)
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
}, [driveError, updateEntry])
|
}, [driveError, updateEntry])
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
|
if (isChecking) {
|
||||||
|
uploadActionRef.current = false
|
||||||
|
setIsChecking(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
abortRef.current?.abort()
|
abortRef.current?.abort()
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
|
uploadActionRef.current = false
|
||||||
|
setIsResolvingDriveError(false)
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
}, [])
|
}, [isChecking])
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
resetEntries()
|
resetEntries()
|
||||||
setGlobalError(null)
|
setGlobalError(null)
|
||||||
|
setIsChecking(false)
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
|
setIsResolvingDriveError(false)
|
||||||
setDriveError(null)
|
setDriveError(null)
|
||||||
checkResultRef.current = { exists: false, diffs: [] }
|
checkResultRef.current = { exists: false, diffs: [] }
|
||||||
|
uploadActionRef.current = false
|
||||||
}, [resetEntries])
|
}, [resetEntries])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isUploading,
|
isUploading,
|
||||||
|
isChecking,
|
||||||
|
isResolvingDriveError,
|
||||||
globalError,
|
globalError,
|
||||||
setGlobalError,
|
setGlobalError,
|
||||||
overwriteConfirm,
|
overwriteConfirm,
|
||||||
@@ -224,6 +265,11 @@ export function useUploadOrchestrator({
|
|||||||
setNoChangesFolder,
|
setNoChangesFolder,
|
||||||
driveError,
|
driveError,
|
||||||
handleUpload,
|
handleUpload,
|
||||||
|
handleOverwriteCancel: () => {
|
||||||
|
setOverwriteConfirm(null)
|
||||||
|
uploadActionRef.current = false
|
||||||
|
setIsChecking(false)
|
||||||
|
},
|
||||||
handleDriveContinue,
|
handleDriveContinue,
|
||||||
handleDriveCancel,
|
handleDriveCancel,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type AssetCategory = 'color' | 'roughness' | 'normal' | 'metalness' | 'assets'
|
||||||
|
|
||||||
|
export function classifyAssetCategory(filename: string): AssetCategory {
|
||||||
|
const name = filename.toLowerCase().replace(/\.[^.]+$/, '')
|
||||||
|
|
||||||
|
if (name.includes('base_color') || name.includes('_color') || name === 'color') {
|
||||||
|
return 'color'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes('roughness')) {
|
||||||
|
return 'roughness'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes('normal')) {
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes('metallic') || name.includes('metalness')) {
|
||||||
|
return 'metalness'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'assets'
|
||||||
|
}
|
||||||
+36
-15
@@ -1,4 +1,6 @@
|
|||||||
|
import type { AssetCategory } from './asset-classification'
|
||||||
import type { FileChange } from './types'
|
import type { FileChange } from './types'
|
||||||
|
import type { PreparedAssetSummary } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a formatted commit message based on the upload context.
|
* Build a formatted commit message based on the upload context.
|
||||||
@@ -12,8 +14,7 @@ import type { FileChange } from './types'
|
|||||||
export function buildCommitMessage(
|
export function buildCommitMessage(
|
||||||
folderName: string,
|
folderName: string,
|
||||||
modelFilename: string,
|
modelFilename: string,
|
||||||
textureNames: string[],
|
assetSummaries: PreparedAssetSummary[],
|
||||||
compressed: boolean,
|
|
||||||
isReplace: boolean,
|
isReplace: boolean,
|
||||||
fileChanges: Map<string, FileChange>,
|
fileChanges: Map<string, FileChange>,
|
||||||
deletedFileNames: string[],
|
deletedFileNames: string[],
|
||||||
@@ -25,36 +26,56 @@ export function buildCommitMessage(
|
|||||||
const lines: string[] = [title, '']
|
const lines: string[] = [title, '']
|
||||||
|
|
||||||
// Model section — show status for new, changed, or unchanged
|
// Model section — show status for new, changed, or unchanged
|
||||||
|
const modelSummary = assetSummaries.find((asset) => asset.kind === 'model')
|
||||||
const modelChange = fileChanges.get(modelFilename.toLowerCase())
|
const modelChange = fileChanges.get(modelFilename.toLowerCase())
|
||||||
if (modelChange === 'new') {
|
if (modelChange === 'new') {
|
||||||
lines.push('📦 Model')
|
lines.push('📦 Model')
|
||||||
lines.push(` ✅ ${modelFilename}${compressed ? ' (compressed)' : ''}`)
|
lines.push(` ✅ ${modelFilename}${modelSummary?.compressed ? ' (compressed)' : ''}`)
|
||||||
} else if (modelChange === 'changed') {
|
} else if (modelChange === 'changed') {
|
||||||
lines.push('📦 Model')
|
lines.push('📦 Model')
|
||||||
lines.push(` 🔄 ${modelFilename}${compressed ? ' (compressed)' : ''}`)
|
lines.push(` 🔄 ${modelFilename}${modelSummary?.compressed ? ' (compressed)' : ''}`)
|
||||||
} else if (modelChange === 'unchanged') {
|
} else if (modelChange === 'unchanged') {
|
||||||
lines.push('📦 Model')
|
lines.push('📦 Model')
|
||||||
lines.push(` ↔️ ${modelFilename} (inchange)`)
|
lines.push(` ↔️ ${modelFilename}${modelSummary?.compressed ? ' (compressed)' : ' (inchange)'}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const textureLines: string[] = []
|
const grouped = new Map<AssetCategory, string[]>()
|
||||||
|
|
||||||
for (const textureName of textureNames) {
|
for (const asset of assetSummaries) {
|
||||||
const change = fileChanges.get(textureName.toLowerCase())
|
if (asset.kind === 'model' || !asset.category) continue
|
||||||
|
|
||||||
|
const change = fileChanges.get(asset.filename.toLowerCase())
|
||||||
if (change === 'new') {
|
if (change === 'new') {
|
||||||
textureLines.push(` ✅ ${textureName}`)
|
const current = grouped.get(asset.category) || []
|
||||||
|
current.push(` ✅ ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`)
|
||||||
|
grouped.set(asset.category, current)
|
||||||
} else if (change === 'changed') {
|
} else if (change === 'changed') {
|
||||||
textureLines.push(` 🔄 ${textureName}`)
|
const current = grouped.get(asset.category) || []
|
||||||
|
current.push(` 🔄 ${asset.filename}${asset.compressed ? ' (compressed)' : ''}`)
|
||||||
|
grouped.set(asset.category, current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const name of deletedFileNames) {
|
const sectionTitles: Record<AssetCategory, string> = {
|
||||||
textureLines.push(` ❌ ${name} (supprime)`)
|
color: '🎨 Textures (color)',
|
||||||
|
roughness: '🪶 Textures (roughness)',
|
||||||
|
normal: '🧭 Textures (normal)',
|
||||||
|
metalness: '🔩 Textures (metalness)',
|
||||||
|
assets: '🧩 Assets',
|
||||||
}
|
}
|
||||||
|
|
||||||
if (textureLines.length > 0) {
|
for (const category of ['color', 'roughness', 'normal', 'metalness', 'assets'] as const) {
|
||||||
lines.push('🎨 Textures')
|
const entries = grouped.get(category)
|
||||||
lines.push(...textureLines)
|
if (!entries || entries.length === 0) continue
|
||||||
|
lines.push('')
|
||||||
|
lines.push(sectionTitles[category])
|
||||||
|
lines.push(...entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedFileNames.length > 0) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('🗑 Deleted')
|
||||||
|
lines.push(...deletedFileNames.map((name) => ` ❌ ${name}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises'
|
|||||||
import { TMP_DIR } from '@/lib/constants'
|
import { TMP_DIR } from '@/lib/constants'
|
||||||
import { compressWithBlender } from '@/lib/blender'
|
import { compressWithBlender } from '@/lib/blender'
|
||||||
import { compressTextureBuffer } from '@/lib/texture-compression'
|
import { compressTextureBuffer } from '@/lib/texture-compression'
|
||||||
import type { ParsedFile } from '@/lib/types'
|
import { classifyAssetCategory } from '@/lib/asset-classification'
|
||||||
|
import type { ParsedFile, PreparedAssetSummary } from '@/lib/types'
|
||||||
|
|
||||||
interface PushFile {
|
interface PushFile {
|
||||||
path: string
|
path: string
|
||||||
@@ -19,7 +20,7 @@ interface PrepareGitAssetsParams {
|
|||||||
interface PrepareGitAssetsResult {
|
interface PrepareGitAssetsResult {
|
||||||
filesToPush: PushFile[]
|
filesToPush: PushFile[]
|
||||||
modelFilename: string
|
modelFilename: string
|
||||||
textureNames: string[]
|
assetSummaries: PreparedAssetSummary[]
|
||||||
compressed: boolean
|
compressed: boolean
|
||||||
compressionError?: string
|
compressionError?: string
|
||||||
}
|
}
|
||||||
@@ -29,7 +30,7 @@ export async function prepareGitAssets({
|
|||||||
parsedFiles,
|
parsedFiles,
|
||||||
}: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> {
|
}: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> {
|
||||||
const filesToPush: PushFile[] = []
|
const filesToPush: PushFile[] = []
|
||||||
const textureNames: string[] = []
|
const assetSummaries: PreparedAssetSummary[] = []
|
||||||
let modelFilename = ''
|
let modelFilename = ''
|
||||||
let compressed = false
|
let compressed = false
|
||||||
let compressionError: string | undefined
|
let compressionError: string | undefined
|
||||||
@@ -39,6 +40,7 @@ export async function prepareGitAssets({
|
|||||||
|
|
||||||
if (pf.isModel) {
|
if (pf.isModel) {
|
||||||
modelFilename = pf.filename
|
modelFilename = pf.filename
|
||||||
|
let modelCompressed = false
|
||||||
|
|
||||||
const tmpFolder = join(TMP_DIR, folderName)
|
const tmpFolder = join(TMP_DIR, folderName)
|
||||||
await mkdir(tmpFolder, { recursive: true })
|
await mkdir(tmpFolder, { recursive: true })
|
||||||
@@ -54,6 +56,7 @@ export async function prepareGitAssets({
|
|||||||
if (result.success && existsSync(compressedPath)) {
|
if (result.success && existsSync(compressedPath)) {
|
||||||
content = await readFile(compressedPath)
|
content = await readFile(compressedPath)
|
||||||
compressed = true
|
compressed = true
|
||||||
|
modelCompressed = true
|
||||||
await unlink(compressedPath).catch(() => {})
|
await unlink(compressedPath).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
compressionError = result.error
|
compressionError = result.error
|
||||||
@@ -62,8 +65,14 @@ export async function prepareGitAssets({
|
|||||||
await unlink(tmpFilePath).catch(() => {})
|
await unlink(tmpFilePath).catch(() => {})
|
||||||
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
|
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assetSummaries.push({
|
||||||
|
filename: pf.filename,
|
||||||
|
kind: 'model',
|
||||||
|
compressed: modelCompressed,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
textureNames.push(pf.filename)
|
const category = classifyAssetCategory(pf.filename)
|
||||||
|
|
||||||
const textureResult = await compressTextureBuffer(pf.filename, pf.buffer)
|
const textureResult = await compressTextureBuffer(pf.filename, pf.buffer)
|
||||||
content = textureResult.buffer
|
content = textureResult.buffer
|
||||||
@@ -71,6 +80,13 @@ export async function prepareGitAssets({
|
|||||||
if (textureResult.error && !compressionError) {
|
if (textureResult.error && !compressionError) {
|
||||||
compressionError = textureResult.error
|
compressionError = textureResult.error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assetSummaries.push({
|
||||||
|
filename: pf.filename,
|
||||||
|
kind: category === 'assets' ? 'asset' : 'texture',
|
||||||
|
category,
|
||||||
|
compressed: textureResult.compressed,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
filesToPush.push({
|
filesToPush.push({
|
||||||
@@ -82,7 +98,7 @@ export async function prepareGitAssets({
|
|||||||
return {
|
return {
|
||||||
filesToPush,
|
filesToPush,
|
||||||
modelFilename,
|
modelFilename,
|
||||||
textureNames,
|
assetSummaries,
|
||||||
compressed,
|
compressed,
|
||||||
compressionError,
|
compressionError,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// Shared types
|
// Shared types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { AssetCategory } from './asset-classification'
|
||||||
|
|
||||||
export interface ParsedFile {
|
export interface ParsedFile {
|
||||||
filename: string
|
filename: string
|
||||||
buffer: Buffer
|
buffer: Buffer
|
||||||
@@ -19,3 +21,10 @@ export interface RemoteFile {
|
|||||||
name: string
|
name: string
|
||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreparedAssetSummary {
|
||||||
|
filename: string
|
||||||
|
kind: 'model' | 'texture' | 'asset'
|
||||||
|
category?: AssetCategory
|
||||||
|
compressed: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const activeUploads = new Set<string>()
|
||||||
|
|
||||||
|
function buildKey(folderName: string) {
|
||||||
|
return folderName.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acquireUploadLock(folderName: string): boolean {
|
||||||
|
const key = buildKey(folderName)
|
||||||
|
if (activeUploads.has(key)) return false
|
||||||
|
activeUploads.add(key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseUploadLock(folderName: string) {
|
||||||
|
activeUploads.delete(buildKey(folderName))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user