fix: harden upload resilience and contracts
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Component, useEffect, useState } from 'react'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { ModelStats } from './SceneViewer'
|
||||
|
||||
interface ModelViewerProps {
|
||||
@@ -10,10 +11,51 @@ interface ModelViewerProps {
|
||||
size: string
|
||||
}
|
||||
|
||||
function getPreviewErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : 'Erreur preview inconnue'
|
||||
}
|
||||
|
||||
function PreviewFallback({ message }: { message?: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center px-6 text-center">
|
||||
<div className="max-w-sm space-y-2">
|
||||
<p className="text-sm font-medium text-gray-300">Preview 3D indisponible pour ce modele.</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
L'upload reste possible. {message ? `Detail technique : ${message}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
class PreviewErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ message: string | null }
|
||||
> {
|
||||
state = { message: null }
|
||||
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
return { message: getPreviewErrorMessage(error) }
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
console.error('[ERROR] Preview 3D indisponible', error)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.message) {
|
||||
return <PreviewFallback message={this.state.message} />
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default function ModelViewer({ url, assetUrls, filename, size }: ModelViewerProps) {
|
||||
const canPreview = filename.toLowerCase().endsWith('.gltf')
|
||||
const [stats, setStats] = useState<ModelStats | null>(null)
|
||||
const [Scene, setScene] = useState<React.ComponentType<{
|
||||
const [sceneError, setSceneError] = useState<string | null>(null)
|
||||
const [Scene, setScene] = useState<ComponentType<{
|
||||
url: string
|
||||
assetUrls: Record<string, string>
|
||||
onStatsReady: (stats: ModelStats) => void
|
||||
@@ -23,13 +65,19 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
|
||||
if (!canPreview) return
|
||||
|
||||
let cancel = false
|
||||
setSceneError(null)
|
||||
setStats(null)
|
||||
|
||||
import('./SceneViewer').then((mod) => {
|
||||
if (!cancel) setScene(() => mod.default)
|
||||
})
|
||||
import('./SceneViewer')
|
||||
.then((mod) => {
|
||||
if (!cancel) setScene(() => mod.default)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancel) setSceneError(getPreviewErrorMessage(error))
|
||||
})
|
||||
|
||||
return () => { cancel = true }
|
||||
}, [canPreview])
|
||||
}, [canPreview, url])
|
||||
|
||||
if (!canPreview) {
|
||||
return (
|
||||
@@ -87,7 +135,13 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Scene url={url} assetUrls={assetUrls} onStatsReady={setStats} />
|
||||
{sceneError ? (
|
||||
<PreviewFallback message={sceneError} />
|
||||
) : (
|
||||
<PreviewErrorBoundary key={url}>
|
||||
<Scene url={url} assetUrls={assetUrls} onStatsReady={setStats} />
|
||||
</PreviewErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ function supportsAlphaMap(material: Material): material is AlphaMapMaterial {
|
||||
return 'alphaMap' in material
|
||||
}
|
||||
|
||||
function isAlphaImageSource(image: object | null | undefined): image is AlphaImageSource {
|
||||
function isAlphaImageSource(image: unknown): image is AlphaImageSource {
|
||||
return image instanceof HTMLImageElement
|
||||
|| image instanceof HTMLCanvasElement
|
||||
|| (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap)
|
||||
@@ -103,7 +103,7 @@ function createAlphaMapTexture(texture: Texture) {
|
||||
const cachedTexture = alphaMapTextureCache.get(texture)
|
||||
if (cachedTexture) return cachedTexture
|
||||
|
||||
const image = texture.image as object | null | undefined
|
||||
const image = texture.image
|
||||
|
||||
if (!isAlphaImageSource(image)) {
|
||||
texture.flipY = false
|
||||
@@ -243,7 +243,7 @@ function Model({
|
||||
loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls))
|
||||
})
|
||||
const opacityMapEntries = getOpacityMapEntries(assetUrls)
|
||||
const opacityMaps = useLoader(TextureLoader, opacityMapEntries.map((entry) => entry.url)) as Texture[]
|
||||
const opacityMaps = useLoader(TextureLoader, opacityMapEntries.map((entry) => entry.url))
|
||||
|
||||
useEffect(() => {
|
||||
onStatsReady(getModelStats(scene, assetUrls))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SpinnerIcon, XIcon, WarningIcon } from '@/components/ui/icons'
|
||||
import type { FolderEntry } from '@/lib/client-types'
|
||||
import type { DriveStatus } from '@/lib/client-types'
|
||||
|
||||
interface DriveStatusLineProps {
|
||||
driveStatus: NonNullable<FolderEntry['driveStatus']>
|
||||
driveStatus: DriveStatus
|
||||
driveError?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { FolderEntry } from '@/lib/client-types'
|
||||
import { formatBytes } from '@/lib/format-bytes'
|
||||
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon } from '@/components/ui/icons'
|
||||
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon, WarningIcon } from '@/components/ui/icons'
|
||||
import DriveStatusLine from './DriveStatusLine'
|
||||
import WarningBanner from './WarningBanner'
|
||||
|
||||
@@ -63,6 +63,13 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
|
||||
<DriveStatusLine driveStatus={entry.driveStatus} driveError={entry.driveError} />
|
||||
)}
|
||||
|
||||
{entry.uploadWarning && (
|
||||
<div className="mt-1.5 flex items-start gap-1.5 text-xs text-yellow-400">
|
||||
<WarningIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span className="line-clamp-2">{entry.uploadWarning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.status === 'uploading' && (
|
||||
<div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { FolderEntry } from '@/lib/client-types'
|
||||
import { validateFolder } from '@/lib/validate-folder'
|
||||
import { getErrorMessage } from '@/lib/guards'
|
||||
import { FolderIcon } from '@/components/ui/icons'
|
||||
|
||||
function buildAssetUrls(model: File, supportFiles: File[]) {
|
||||
@@ -156,8 +157,8 @@ export default function FolderDropzone({
|
||||
if (droppedFiles.length === 0) return
|
||||
|
||||
await processFiles(droppedFiles)
|
||||
} catch {
|
||||
onError('Impossible de lire le dossier depose')
|
||||
} catch (err) {
|
||||
onError(`Impossible de lire le dossier depose: ${getErrorMessage(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user