upadte: clean code + add next cloud
This commit is contained in:
@@ -3,3 +3,9 @@ GITHUB_TOKEN=ghp_your-github-personal-access-token
|
||||
GIT_BRANCH=main
|
||||
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
||||
BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
|
||||
# Nextcloud Drive (WebDAV)
|
||||
NEXTCLOUD_URL=https://cloud.example.com
|
||||
NEXTCLOUD_USER=your-nextcloud-username
|
||||
NEXTCLOUD_PASSWORD=your-nextcloud-password
|
||||
NEXTCLOUD_BASE_PATH=Models
|
||||
@@ -1,6 +1,11 @@
|
||||
# upload-GLTF
|
||||
|
||||
A secure web interface for uploading 3D assets (GLTF/GLB + textures) with automatic Draco compression and GitHub push. Built for La Fabrik Durable.
|
||||
A secure web interface for uploading 3D assets (GLTF/GLB + textures) to two destinations:
|
||||
|
||||
- **Nextcloud Drive** — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions.
|
||||
- **GitHub** — Delivers compressed models (Draco via Blender) to the dev team's repository, ready for integration.
|
||||
|
||||
Built for La Fabrik Durable.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -8,6 +13,7 @@ A secure web interface for uploading 3D assets (GLTF/GLB + textures) with automa
|
||||
- **Three.js** (@react-three/fiber + @react-three/drei) for 3D preview
|
||||
- **Tailwind CSS** for styling
|
||||
- **Octokit** for pushing via the GitHub API
|
||||
- **Nextcloud WebDAV** for Drive archiving with automatic versioning
|
||||
- **Blender** (headless) for Draco mesh compression
|
||||
- **Coolify** (Docker) for hosting
|
||||
|
||||
@@ -29,6 +35,12 @@ GITHUB_TOKEN=ghp_your-github-personal-access-token
|
||||
GIT_BRANCH=main
|
||||
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
||||
BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
|
||||
# Nextcloud Drive (WebDAV)
|
||||
NEXTCLOUD_URL=https://cloud.example.com
|
||||
NEXTCLOUD_USER=your-nextcloud-username
|
||||
NEXTCLOUD_PASSWORD=your-nextcloud-password
|
||||
NEXTCLOUD_BASE_PATH=Models
|
||||
```
|
||||
|
||||
| Variable | Description | Required |
|
||||
@@ -38,8 +50,12 @@ BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
| `GIT_BRANCH` | Target branch (default: main) | No |
|
||||
| `GIT_REPO_URL` | Target GitHub repository URL | Yes |
|
||||
| `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No |
|
||||
| `NEXTCLOUD_URL` | Nextcloud instance URL | Yes |
|
||||
| `NEXTCLOUD_USER` | Nextcloud username (Basic auth) | Yes |
|
||||
| `NEXTCLOUD_PASSWORD` | Nextcloud password (Basic auth) | Yes |
|
||||
| `NEXTCLOUD_BASE_PATH` | Root folder on the Drive (default: `Models`) | No |
|
||||
|
||||
> To create a token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write.
|
||||
> To create a GitHub token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -61,6 +77,9 @@ docker run -p 3000:3000 \
|
||||
-e UPLOAD_SECRET_KEY=your-key \
|
||||
-e GITHUB_TOKEN=ghp_xxx \
|
||||
-e GIT_REPO_URL=https://github.com/org/repo.git \
|
||||
-e NEXTCLOUD_URL=https://cloud.example.com \
|
||||
-e NEXTCLOUD_USER=user \
|
||||
-e NEXTCLOUD_PASSWORD=pass \
|
||||
upload-gltf
|
||||
```
|
||||
|
||||
@@ -74,39 +93,66 @@ The Docker image includes Blender headless (installed once at build time). On st
|
||||
- `model.glb` or `model.gltf` (**required**)
|
||||
- Textures: `roughness`, `normal`, `metalness`, `color`, `displace` (`.png/.jpg/.webp`, **optional** — missing textures show a warning but don't block the upload)
|
||||
4. The model is displayed in a 3D preview
|
||||
5. On clicking "Envoyer sur GitHub":
|
||||
- The app computes the git SHA of each local file and compares with the remote repo
|
||||
5. On clicking "Envoyer":
|
||||
- The app checks the remote Git repo for existing files and computes diffs (textures by size, models always re-pushed)
|
||||
- If the folder doesn't exist, upload proceeds directly
|
||||
- If the folder exists and files differ, a confirmation dialog shows **only the actual changes** (modified, new, or deleted files)
|
||||
- If the folder exists and nothing changed, the upload is skipped entirely ("Aucun fichier modifie")
|
||||
6. For models: the file is written to `/tmp`, compressed with Blender Draco, then the compressed version is pushed
|
||||
7. For textures: pushed directly without compression
|
||||
8. **Only changed and new files are pushed** — unchanged files are skipped to save bandwidth and API calls
|
||||
9. All changes are pushed in a **single commit** with a formatted message that only lists what changed:
|
||||
- If the folder exists and files differ, a confirmation dialog shows **only the actual changes**
|
||||
- If nothing changed, the upload is skipped entirely
|
||||
|
||||
**New folder:**
|
||||
```
|
||||
update: upload-gltf add a new model -> farm/my-model
|
||||
### Upload flow: Drive first, then Git
|
||||
|
||||
📦 Model
|
||||
6. **Drive upload (archiving)** — Original files (before Blender compression) are uploaded to the Nextcloud Drive with automatic versioning (see below). This serves as the artists' source of truth and version history. If the Drive upload fails, a modal asks the user whether to send to Git only or cancel entirely.
|
||||
7. **Git upload (delivery to devs)** — Models are compressed with Blender Draco, then all changed files are pushed to GitHub in a single commit. This is what the dev team consumes in the application.
|
||||
|
||||
### Drive versioning (Nextcloud WebDAV)
|
||||
|
||||
The Drive uses a `VF` (version finale) / `Vx` (archived versions) structure:
|
||||
|
||||
```
|
||||
Models/
|
||||
VF/ ← latest version
|
||||
coffeetest/
|
||||
model.gltf
|
||||
color.jpg
|
||||
V1/ ← first archive
|
||||
coffeetest/
|
||||
V2/ ← second archive
|
||||
coffeetest/
|
||||
```
|
||||
|
||||
- **New folder** (doesn't exist in `VF/`): files are uploaded directly to `VF/{folderName}/`
|
||||
- **Replace** (folder exists in `VF/` with diffs): `VF/{folderName}` is moved to `Vx/{folderName}` (next available version), then all files are re-uploaded to `VF/{folderName}/`
|
||||
- **No changes**: nothing happens on the Drive
|
||||
|
||||
All files are uploaded to `VF/` (not just diffs), because the move operation empties the previous folder.
|
||||
|
||||
### Commit messages
|
||||
|
||||
All changes are pushed in a **single commit** with a formatted message:
|
||||
|
||||
**New folder:**
|
||||
```
|
||||
update: upload-gltf add a new model -> farm/my-model
|
||||
|
||||
📦 Model
|
||||
✅ model.gltf (compressed)
|
||||
🎨 Textures
|
||||
🎨 Textures
|
||||
✅ color.jpg
|
||||
❌ metalness (manquant)
|
||||
```
|
||||
```
|
||||
|
||||
**Update (only metalness changed):**
|
||||
```
|
||||
update: upload-gltf update -> general/coffeetest
|
||||
**Update (only metalness changed):**
|
||||
```
|
||||
update: upload-gltf update -> general/coffeetest
|
||||
|
||||
🎨 Textures
|
||||
🎨 Textures
|
||||
🔄 metalness.jpg
|
||||
```
|
||||
```
|
||||
|
||||
Symbols: `✅` new — `🔄` modified — `❌` missing or deleted
|
||||
Symbols: `✅` new — `🔄` modified — `❌` missing or deleted
|
||||
|
||||
10. Orphan files (present on remote but not in the new upload) are deleted in the same commit
|
||||
11. If Blender is unavailable, the original model is pushed as-is (graceful fallback)
|
||||
8. Orphan files (present on remote but not in the new upload) are deleted in the same commit
|
||||
9. If Blender is unavailable, the original model is pushed as-is (graceful fallback)
|
||||
|
||||
## Destinations
|
||||
|
||||
@@ -125,14 +171,43 @@ Uploaded models are pushed to `public/models/<destination>/<folderName>/` in the
|
||||
|
||||
```
|
||||
app/
|
||||
├── api/upload/route.ts # API: GET (check + SHA diff) + POST (compress + smart push)
|
||||
├── globals.css # Tailwind + Google Fonts
|
||||
├── layout.tsx # Root layout
|
||||
├── api/upload/
|
||||
│ ├── check/route.ts # GET: check remote folder + file sizes for diff
|
||||
│ ├── drive/route.ts # POST: upload originals to Nextcloud Drive (VF/Vx versioning)
|
||||
│ └── git/route.ts # POST: compress with Blender + push to GitHub
|
||||
├── globals.css # Tailwind + CSS variable fonts
|
||||
├── layout.tsx # Root layout (next/font/google)
|
||||
└── page.tsx # Home page
|
||||
components/
|
||||
├── UploadZone.tsx # UI: key input, destination picker, folder picker, SHA diff, overwrite confirmation, upload
|
||||
├── ModelViewer.tsx # Lazy wrapper for the 3D viewer
|
||||
├── upload/
|
||||
│ ├── SecretInput.tsx # Access key input
|
||||
│ ├── DestinationPicker.tsx # Destination selector
|
||||
│ ├── FolderDropzone.tsx # Folder drag & drop / picker
|
||||
│ ├── FolderCard.tsx # Folder status card (Drive + Git)
|
||||
│ ├── WarningBanner.tsx # Missing texture warnings
|
||||
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
|
||||
│ ├── NoChangesModal.tsx # "No changes detected" dialog
|
||||
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
|
||||
│ └── ActionButtons.tsx # Upload / Cancel / Reset buttons
|
||||
├── UploadZone.tsx # Main orchestrator (Drive → Git flow)
|
||||
├── ModelViewer.tsx # Lazy wrapper for 3D viewer
|
||||
└── SceneViewer.tsx # Three.js Canvas
|
||||
hooks/
|
||||
├── useSecret.ts # Secret key state management
|
||||
└── useFolderEntries.ts # Folder entries state management
|
||||
lib/
|
||||
├── constants.ts # Shared constants, destinations, extensions
|
||||
├── types.ts # Server types (ParsedFile, FileDiff, etc.)
|
||||
├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.)
|
||||
├── sanitize.ts # Filename sanitization
|
||||
├── auth.ts # Upload secret validation (timing-safe)
|
||||
├── github.ts # Octokit helpers (getRemoteFolder, pushAllToGitHub)
|
||||
├── nextcloud.ts # Nextcloud WebDAV client (native fetch)
|
||||
├── blender.ts # Blender Draco compression
|
||||
├── commit-message.ts # Commit message builder
|
||||
├── parse-upload.ts # FormData parser + validation
|
||||
├── validate-folder.ts # Client-side folder validation
|
||||
└── format-bytes.ts # Byte formatting utility
|
||||
scripts/
|
||||
└── compress.py # Blender Draco compression script
|
||||
Dockerfile # Multi-stage build: Node 20 slim + Blender headless + tini
|
||||
|
||||
@@ -1,26 +1,107 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateUploadSecret } from '@/lib/auth'
|
||||
import { parseMultiUpload } from '@/lib/parse-upload'
|
||||
import {
|
||||
folderExists,
|
||||
mkdirRecursive,
|
||||
moveFolder,
|
||||
uploadFile,
|
||||
findNextVersion,
|
||||
} from '@/lib/nextcloud'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TODO: POST /api/upload/drive
|
||||
// POST /api/upload/drive
|
||||
//
|
||||
// Upload files and push them to Google Drive.
|
||||
// This route will share the same auth (lib/auth.ts), parsing (lib/parse-upload.ts),
|
||||
// and Blender compression (lib/blender.ts) as the git route.
|
||||
// Upload **original** files (no Blender compression) to Nextcloud Drive.
|
||||
//
|
||||
// Implementation steps:
|
||||
// 1. Add `googleapis` package: npm install googleapis
|
||||
// 2. Add env vars: GOOGLE_DRIVE_FOLDER_ID, GOOGLE_SERVICE_ACCOUNT_KEY (JSON)
|
||||
// 3. Authenticate with Google Drive API using service account
|
||||
// 4. Upload files to the target folder (create subfolders as needed)
|
||||
// 5. Return the Drive folder URL
|
||||
// FormData fields:
|
||||
// - folderName, destination, files[], fileTypes[], textureNames[] (same as /api/upload/git)
|
||||
// - action: "new" | "replace"
|
||||
//
|
||||
// Versioning logic:
|
||||
// VF/{folderName} ← latest version
|
||||
// V1/{folderName} ← first archive, V2/ second, etc.
|
||||
//
|
||||
// action="new" → just mkdir + upload into VF/
|
||||
// action="replace" → archive VF → Vx, then re-upload all files into VF/
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function POST() {
|
||||
export async function POST(req: NextRequest) {
|
||||
// --- Auth ---
|
||||
const authError = validateUploadSecret(req)
|
||||
if (authError) return authError
|
||||
|
||||
// --- Check Nextcloud config ---
|
||||
if (!process.env.NEXTCLOUD_URL || !process.env.NEXTCLOUD_USER || !process.env.NEXTCLOUD_PASSWORD) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Google Drive upload not implemented yet' },
|
||||
{ status: 501 },
|
||||
{ success: false, error: 'Nextcloud non configure sur le serveur' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
// --- Parse files ---
|
||||
// Clone the request before parseMultiUpload consumes the body stream,
|
||||
// so we can read the `action` field separately.
|
||||
const cloned = req.clone()
|
||||
|
||||
let folderName: string
|
||||
let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files']
|
||||
let action: string
|
||||
|
||||
try {
|
||||
const parsed = await parseMultiUpload(req)
|
||||
folderName = parsed.folderName
|
||||
parsedFiles = parsed.files
|
||||
|
||||
// Read action from the cloned request (parseMultiUpload doesn't expose it)
|
||||
const formData = await cloned.formData()
|
||||
action = (formData.get('action') as string | null)?.trim() || 'new'
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
|
||||
const vfFolderPath = `${basePath}/VF/${folderName}`
|
||||
|
||||
try {
|
||||
if (action === 'replace') {
|
||||
// 1. Find the next available Vx
|
||||
const nextVersion = await findNextVersion(basePath, folderName)
|
||||
|
||||
// 2. Ensure Vx/ exists
|
||||
await mkdirRecursive(`${basePath}/${nextVersion}`)
|
||||
|
||||
// 3. Move VF/{folderName} → Vx/{folderName}
|
||||
await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`)
|
||||
|
||||
// 4. Re-create VF/{folderName}
|
||||
await mkdirRecursive(vfFolderPath)
|
||||
} else {
|
||||
// action === 'new': just ensure VF/{folderName} exists
|
||||
await mkdirRecursive(vfFolderPath)
|
||||
}
|
||||
|
||||
// --- Upload all original files ---
|
||||
for (const pf of parsedFiles) {
|
||||
const remotePath = `${vfFolderPath}/${pf.filename}`
|
||||
await uploadFile(remotePath, pf.buffer)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
folderName,
|
||||
filesCount: parsedFiles.length,
|
||||
message: `${parsedFiles.length} fichier(s) envoye(s) sur le Drive.`,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur Nextcloud inconnue'
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Drive echoue: ${message}` },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+2
-2
@@ -8,8 +8,8 @@ export default function Home() {
|
||||
Upload GLTF
|
||||
</h1>
|
||||
<p className="text-gray-400 text-base leading-relaxed">
|
||||
Deposez vos fichiers 3D — ils seront automatiquement versionnes
|
||||
<br />et envoyes sur votre depot GitHub.
|
||||
Deposez vos fichiers 3D — ils seront archives sur le Drive
|
||||
<br />avec versioning, puis envoyes aux devs via GitHub.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
+183
-24
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useCallback } 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'
|
||||
@@ -14,11 +13,11 @@ import FolderCard from './upload/FolderCard'
|
||||
import ActionButtons from './upload/ActionButtons'
|
||||
import OverwriteConfirmModal from './upload/OverwriteConfirmModal'
|
||||
import NoChangesModal from './upload/NoChangesModal'
|
||||
import DriveErrorModal from './upload/DriveErrorModal'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CheckResult {
|
||||
exists: boolean
|
||||
diffs: FileDiff[]
|
||||
@@ -48,15 +47,13 @@ async function checkFolderDiffs(
|
||||
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)
|
||||
// Textures: compare by size
|
||||
for (const tex of folder.textures) {
|
||||
const key = tex.name.toLowerCase()
|
||||
localNames.add(key)
|
||||
@@ -68,7 +65,7 @@ async function checkFolderDiffs(
|
||||
}
|
||||
}
|
||||
|
||||
// Files on remote but not in local → deleted
|
||||
// Deleted
|
||||
for (const [name] of remoteMap) {
|
||||
if (!localNames.has(name)) {
|
||||
diffs.push({ name, status: 'deleted' })
|
||||
@@ -81,7 +78,49 @@ async function checkFolderDiffs(
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFolder(
|
||||
/** Upload original files to Nextcloud Drive (no Blender compression). */
|
||||
async function uploadDrive(
|
||||
folder: FolderEntry,
|
||||
secret: string,
|
||||
destination: string,
|
||||
action: 'new' | 'replace',
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const formData = new FormData()
|
||||
formData.append('folderName', folder.folderName)
|
||||
formData.append('destination', destination)
|
||||
formData.append('action', action)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload/drive', {
|
||||
method: 'POST',
|
||||
headers: { 'x-upload-secret': secret.trim() },
|
||||
body: formData,
|
||||
signal,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) return { success: false, error: data.error }
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return { success: false, error: 'Upload annule' }
|
||||
}
|
||||
return { success: false, error: 'Erreur reseau (Drive)' }
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload files to GitHub (with Blender compression). */
|
||||
async function uploadGit(
|
||||
folder: FolderEntry,
|
||||
secret: string,
|
||||
destination: string,
|
||||
@@ -132,7 +171,6 @@ async function uploadFolder(
|
||||
// ---------------------------------------------------------------------------
|
||||
// UploadZone — orchestrator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UploadZone() {
|
||||
const {
|
||||
secret,
|
||||
@@ -156,14 +194,24 @@ export default function UploadZone() {
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [globalError, setGlobalError] = useState<string | null>(null)
|
||||
const [destination, setDestination] = useState<Destination>(DESTINATIONS[0].value)
|
||||
const [destination, setDestination] = useState<Destination | null>(null)
|
||||
const [overwriteConfirm, setOverwriteConfirm] = useState<{
|
||||
folderName: string
|
||||
diffs: FileDiff[]
|
||||
} | null>(null)
|
||||
const [noChangesFolder, setNoChangesFolder] = useState<string | null>(null)
|
||||
|
||||
// Drive error modal state
|
||||
const [driveError, setDriveError] = useState<{
|
||||
error: string
|
||||
folderIndex: number
|
||||
} | null>(null)
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Tracks the check result so we know if it's "new" or "replace" for the Drive
|
||||
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] })
|
||||
|
||||
// -- Handlers --
|
||||
|
||||
const handleFolderSelected = (entry: FolderEntry) => {
|
||||
@@ -183,13 +231,19 @@ export default function UploadZone() {
|
||||
setSecretError("La cle d'acces est requise")
|
||||
return
|
||||
}
|
||||
if (!destination) {
|
||||
setGlobalError('Veuillez choisir une destination')
|
||||
return
|
||||
}
|
||||
if (entries.length === 0) return
|
||||
|
||||
setSecretError(null)
|
||||
setGlobalError(null)
|
||||
|
||||
const folder = entries[0]
|
||||
const check = await checkFolderDiffs(folder, destination, secret, abortRef.current?.signal)
|
||||
const check = await checkFolderDiffs(folder, destination!, secret, abortRef.current?.signal)
|
||||
checkResultRef.current = check
|
||||
|
||||
if (check.exists) {
|
||||
if (check.diffs.length === 0) {
|
||||
setNoChangesFolder(folder.folderName)
|
||||
@@ -202,6 +256,10 @@ export default function UploadZone() {
|
||||
await proceedUpload()
|
||||
}
|
||||
|
||||
/**
|
||||
* Main upload flow: Drive first, then Git.
|
||||
* If Drive fails, show DriveErrorModal so user can decide.
|
||||
*/
|
||||
const proceedUpload = async () => {
|
||||
setOverwriteConfirm(null)
|
||||
setIsUploading(true)
|
||||
@@ -214,28 +272,118 @@ export default function UploadZone() {
|
||||
if (entries[i].status === 'success') continue
|
||||
if (controller.signal.aborted) break
|
||||
|
||||
updateEntry(i, { status: 'uploading', progress: 0, error: undefined })
|
||||
const folderEntry = entries[i]
|
||||
const driveAction = checkResultRef.current.exists ? 'replace' : 'new'
|
||||
|
||||
const result = await uploadFolder(
|
||||
entries[i],
|
||||
// ---- Step 1: Drive upload ----
|
||||
updateEntry(i, {
|
||||
status: 'uploading',
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
driveStatus: 'uploading',
|
||||
driveError: undefined,
|
||||
})
|
||||
|
||||
const driveResult = await uploadDrive(
|
||||
folderEntry,
|
||||
secret,
|
||||
destination,
|
||||
(pct) => updateEntry(i, { progress: pct }),
|
||||
destination!,
|
||||
driveAction as 'new' | 'replace',
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
updateEntry(i, {
|
||||
status: result.success ? 'success' : 'error',
|
||||
progress: result.success ? 100 : 0,
|
||||
error: result.success ? undefined : result.error,
|
||||
filename: result.filename,
|
||||
})
|
||||
if (!driveResult.success) {
|
||||
// Drive failed — pause and ask user
|
||||
updateEntry(i, { driveStatus: 'error', driveError: driveResult.error })
|
||||
setDriveError({ error: driveResult.error || 'Erreur inconnue', folderIndex: i })
|
||||
// Stop here — the DriveErrorModal callbacks will resume or cancel
|
||||
return
|
||||
}
|
||||
|
||||
updateEntry(i, { driveStatus: 'success' })
|
||||
|
||||
// ---- Step 2: Git upload ----
|
||||
await pushGit(i, controller.signal)
|
||||
}
|
||||
|
||||
abortRef.current = null
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
/** Push a single folder to Git. Called after Drive succeeds or user skips Drive. */
|
||||
const pushGit = async (index: number, signal?: AbortSignal) => {
|
||||
const folderEntry = entries[index]
|
||||
|
||||
updateEntry(index, { progress: 5 })
|
||||
|
||||
const gitResult = await uploadGit(
|
||||
folderEntry,
|
||||
secret,
|
||||
destination!,
|
||||
(pct) => updateEntry(index, { progress: pct }),
|
||||
signal,
|
||||
)
|
||||
|
||||
updateEntry(index, {
|
||||
status: gitResult.success ? 'success' : 'error',
|
||||
progress: gitResult.success ? 100 : 0,
|
||||
error: gitResult.success ? undefined : gitResult.error,
|
||||
filename: gitResult.filename,
|
||||
})
|
||||
}
|
||||
|
||||
/** User chose "Continue without Drive" in DriveErrorModal. */
|
||||
const handleDriveContinue = useCallback(async () => {
|
||||
if (!driveError) return
|
||||
const idx = driveError.folderIndex
|
||||
setDriveError(null)
|
||||
|
||||
updateEntry(idx, { driveStatus: 'skipped' })
|
||||
|
||||
// Continue with Git
|
||||
const signal = abortRef.current?.signal
|
||||
await pushGit(idx, signal)
|
||||
|
||||
// Continue with remaining entries
|
||||
for (let i = idx + 1; i < entries.length; i++) {
|
||||
if (entries[i].status === 'success') continue
|
||||
if (abortRef.current?.signal.aborted) break
|
||||
|
||||
// For subsequent entries after a Drive skip, also skip Drive
|
||||
updateEntry(i, {
|
||||
status: 'uploading',
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
driveStatus: 'skipped',
|
||||
})
|
||||
|
||||
await pushGit(i, abortRef.current?.signal)
|
||||
}
|
||||
|
||||
abortRef.current = null
|
||||
setIsUploading(false)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [driveError, entries, secret, destination])
|
||||
|
||||
/** User chose "Cancel" in DriveErrorModal. */
|
||||
const handleDriveCancel = useCallback(() => {
|
||||
if (!driveError) return
|
||||
const idx = driveError.folderIndex
|
||||
setDriveError(null)
|
||||
|
||||
updateEntry(idx, {
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
error: 'Upload annule (Drive echoue)',
|
||||
driveStatus: 'error',
|
||||
})
|
||||
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
setIsUploading(false)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [driveError])
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
@@ -246,6 +394,8 @@ export default function UploadZone() {
|
||||
resetEntries()
|
||||
setGlobalError(null)
|
||||
setIsUploading(false)
|
||||
setDriveError(null)
|
||||
checkResultRef.current = { exists: false, diffs: [] }
|
||||
}
|
||||
|
||||
const hasPendingOrErrors = entries.some((f) => f.status === 'pending' || f.status === 'error')
|
||||
@@ -298,6 +448,7 @@ export default function UploadZone() {
|
||||
<ActionButtons
|
||||
isUploading={isUploading}
|
||||
isSecretEmpty={isSecretEmpty}
|
||||
noDestination={!destination}
|
||||
hasPendingOrErrors={hasPendingOrErrors}
|
||||
allDone={allDone}
|
||||
hasErrors={hasErrors}
|
||||
@@ -308,7 +459,7 @@ export default function UploadZone() {
|
||||
|
||||
{overwriteConfirm && (
|
||||
<OverwriteConfirmModal
|
||||
destination={destination}
|
||||
destination={destination!}
|
||||
folderName={overwriteConfirm.folderName}
|
||||
diffs={overwriteConfirm.diffs}
|
||||
onCancel={() => setOverwriteConfirm(null)}
|
||||
@@ -318,7 +469,7 @@ export default function UploadZone() {
|
||||
|
||||
{noChangesFolder && (
|
||||
<NoChangesModal
|
||||
destination={destination}
|
||||
destination={destination!}
|
||||
folderName={noChangesFolder}
|
||||
onCancel={() => {
|
||||
setNoChangesFolder(null)
|
||||
@@ -327,6 +478,14 @@ export default function UploadZone() {
|
||||
onModify={() => setNoChangesFolder(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{driveError && (
|
||||
<DriveErrorModal
|
||||
error={driveError.error}
|
||||
onCancel={handleDriveCancel}
|
||||
onContinue={handleDriveContinue}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
interface ActionButtonsProps {
|
||||
isUploading: boolean
|
||||
isSecretEmpty: boolean
|
||||
noDestination: boolean
|
||||
hasPendingOrErrors: boolean
|
||||
allDone: boolean
|
||||
hasErrors: boolean
|
||||
@@ -12,6 +13,7 @@ interface ActionButtonsProps {
|
||||
export default function ActionButtons({
|
||||
isUploading,
|
||||
isSecretEmpty,
|
||||
noDestination,
|
||||
hasPendingOrErrors,
|
||||
allDone,
|
||||
hasErrors,
|
||||
@@ -19,20 +21,22 @@ export default function ActionButtons({
|
||||
onCancel,
|
||||
onReset,
|
||||
}: ActionButtonsProps) {
|
||||
const cantUpload = isSecretEmpty || noDestination
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{!isUploading && hasPendingOrErrors && (
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={isSecretEmpty}
|
||||
disabled={cantUpload}
|
||||
className={`flex-1 font-medium text-sm py-2.5 px-6 rounded-xl transition-all duration-150
|
||||
focus:outline-none focus:ring-2 focus:ring-white/50 border border-white/20
|
||||
${isSecretEmpty
|
||||
${cantUpload
|
||||
? 'bg-white/30 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-white text-[#000000] hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Envoyer sur GitHub
|
||||
Envoyer
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DESTINATIONS } from '@/lib/constants'
|
||||
import type { Destination } from '@/lib/constants'
|
||||
|
||||
interface DestinationPickerProps {
|
||||
destination: Destination
|
||||
destination: Destination | null
|
||||
disabled: boolean
|
||||
onChange: (value: Destination) => void
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
interface DriveErrorModalProps {
|
||||
error: string
|
||||
onCancel: () => void
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
export default function DriveErrorModal({
|
||||
error,
|
||||
onCancel,
|
||||
onContinue,
|
||||
}: DriveErrorModalProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="drive-error-title"
|
||||
>
|
||||
<div className="bg-black-900 border border-white/20 rounded-2xl p-6 max-w-md w-full mx-4 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-red-900/30 flex items-center justify-center shrink-0">
|
||||
<svg className="w-5 h-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="drive-error-title" className="text-sm font-semibold text-gray-100">
|
||||
Erreur Drive
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
L'archivage sur le Drive a echoue. Les fichiers n'ont pas ete versionnes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-black-800 border border-white/10 rounded-xl p-3">
|
||||
<p className="text-xs text-red-400 font-mono break-all">{error}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
Voulez-vous quand meme envoyer les fichiers aux devs via GitHub ?
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
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
|
||||
hover:bg-black-600"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={onContinue}
|
||||
className="flex-1 bg-white text-[#000000] font-medium text-sm
|
||||
py-2.5 px-4 rounded-xl transition-colors duration-150
|
||||
hover:bg-gray-200"
|
||||
>
|
||||
Envoyer sur Git seulement
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -67,9 +67,48 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
|
||||
<span className="text-xs text-red-400 truncate">{entry.error}</span>
|
||||
)}
|
||||
{entry.status === 'success' && entry.filename && (
|
||||
<span className="text-xs text-green-400 font-mono">{entry.filename}</span>
|
||||
<span className="text-xs text-green-400 font-mono">Drive + Git OK</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drive status sub-line */}
|
||||
{entry.driveStatus && entry.driveStatus !== 'pending' && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{entry.driveStatus === 'uploading' && (
|
||||
<>
|
||||
<svg className="w-3 h-3 text-gray-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span className="text-xs text-gray-400">Drive en cours...</span>
|
||||
</>
|
||||
)}
|
||||
{entry.driveStatus === 'success' && (
|
||||
<>
|
||||
<svg className="w-3 h-3 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-xs text-green-400">Drive OK</span>
|
||||
</>
|
||||
)}
|
||||
{entry.driveStatus === 'error' && (
|
||||
<>
|
||||
<svg className="w-3 h-3 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="text-xs text-red-400 truncate">Drive echoue{entry.driveError ? ` : ${entry.driveError}` : ''}</span>
|
||||
</>
|
||||
)}
|
||||
{entry.driveStatus === 'skipped' && (
|
||||
<>
|
||||
<svg className="w-3 h-3 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01" />
|
||||
</svg>
|
||||
<span className="text-xs text-yellow-400">Drive ignore</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{entry.status === 'uploading' && (
|
||||
<div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
|
||||
@@ -80,6 +80,7 @@ export default function FolderDropzone({
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Contenu attendu : model.glb/gltf + textures (roughness, normal, metalness, color, displace)
|
||||
<br />Les originaux sont archives sur le Drive, les comprimes sont envoyes sur Git.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function NoChangesModal({
|
||||
Aucun changement detecte
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Le dossier <span className="font-mono text-gray-300">{destination}/{folderName}</span> est identique au contenu distant.
|
||||
Le dossier <span className="font-mono text-gray-300">{destination}/{folderName}</span> est identique au contenu distant. Rien a envoyer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,8 @@ export default function OverwriteConfirmModal({
|
||||
Dossier deja existant
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
<span className="font-mono text-yellow-400">{destination}/{folderName}</span> existe deja sur le repo
|
||||
<span className="font-mono text-yellow-400">{destination}/{folderName}</span> existe deja.
|
||||
Les anciens fichiers seront archives sur le Drive, puis les nouveaux seront envoyes sur le Drive et Git.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,19 +18,13 @@ export function useSecret() {
|
||||
setSecretVisible((v) => !v)
|
||||
}, [])
|
||||
|
||||
const clearSecretError = useCallback(() => {
|
||||
setSecretError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
secret,
|
||||
secretError,
|
||||
secretVisible,
|
||||
isSecretEmpty,
|
||||
setSecret,
|
||||
setSecretError,
|
||||
handleSecretChange,
|
||||
toggleSecretVisible,
|
||||
clearSecretError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface TextureFile {
|
||||
file: File
|
||||
}
|
||||
|
||||
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
|
||||
|
||||
export interface FolderEntry {
|
||||
folderName: string
|
||||
modelFile: File
|
||||
@@ -20,4 +22,6 @@ export interface FolderEntry {
|
||||
modelUrl?: string
|
||||
viewerOpen?: boolean
|
||||
warnings: string[]
|
||||
driveStatus?: DriveStatus
|
||||
driveError?: string
|
||||
}
|
||||
|
||||
+3
-11
@@ -1,18 +1,17 @@
|
||||
import { Octokit } from '@octokit/rest'
|
||||
import { createHash } from 'crypto'
|
||||
import type { RemoteFile } from './types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Octokit helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getOctokit(): Octokit {
|
||||
function getOctokit(): Octokit {
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (!token) throw new Error('GITHUB_TOKEN non configure')
|
||||
return new Octokit({ auth: token })
|
||||
}
|
||||
|
||||
export function parseRepoUrl(): { owner: string; repo: string } {
|
||||
function parseRepoUrl(): { owner: string; repo: string } {
|
||||
const url = process.env.GIT_REPO_URL
|
||||
if (!url) throw new Error('GIT_REPO_URL non configure')
|
||||
|
||||
@@ -26,15 +25,8 @@ export function parseRepoUrl(): { owner: string; repo: string } {
|
||||
return { owner: match[1], repo: match[2] }
|
||||
}
|
||||
|
||||
/** Compute the SHA that Git would assign to a blob with this content */
|
||||
export function computeGitBlobSha(content: Buffer): string {
|
||||
const header = `blob ${content.length}\0`
|
||||
const store = Buffer.concat([Buffer.from(header), content])
|
||||
return createHash('sha1').update(store).digest('hex')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read remote folder contents (with SHA per file)
|
||||
// Read remote folder contents (with size per file)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getRemoteFolder(
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nextcloud WebDAV client
|
||||
// Uses native fetch — no npm package needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getConfig() {
|
||||
const url = process.env.NEXTCLOUD_URL
|
||||
const user = process.env.NEXTCLOUD_USER
|
||||
const password = process.env.NEXTCLOUD_PASSWORD
|
||||
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
|
||||
|
||||
if (!url || !user || !password) {
|
||||
throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD)')
|
||||
}
|
||||
|
||||
// WebDAV base: https://cloud.example.com/remote.php/dav/files/{user}/
|
||||
const davBase = `${url.replace(/\/+$/, '')}/remote.php/dav/files/${encodeURIComponent(user)}`
|
||||
const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64')
|
||||
|
||||
return { davBase, auth, basePath }
|
||||
}
|
||||
|
||||
function davUrl(davBase: string, path: string): string {
|
||||
const clean = path.replace(/^\/+/, '').replace(/\/+$/, '')
|
||||
return `${davBase}/${clean}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level WebDAV helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function davRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: Buffer | string | null,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
const { davBase, auth } = getConfig()
|
||||
const url = davUrl(davBase, path)
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
...extraHeaders,
|
||||
},
|
||||
body: body ?? undefined,
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if a folder exists on the Nextcloud instance. */
|
||||
export async function folderExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await davRequest('PROPFIND', path + '/', null, { Depth: '0' })
|
||||
return res.status >= 200 && res.status < 300
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a folder and all parent segments if they don't exist.
|
||||
* Like `mkdir -p`.
|
||||
*/
|
||||
export async function mkdirRecursive(path: string): Promise<void> {
|
||||
const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
|
||||
let current = ''
|
||||
|
||||
for (const seg of segments) {
|
||||
current += '/' + seg
|
||||
const exists = await folderExists(current)
|
||||
if (!exists) {
|
||||
const res = await davRequest('MKCOL', current + '/')
|
||||
if (res.status !== 201 && res.status !== 405) {
|
||||
// 405 = already exists (race condition), that's fine
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload a file (overwrite if exists). */
|
||||
export async function uploadFile(path: string, content: Buffer): Promise<void> {
|
||||
const res = await davRequest('PUT', path, content, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': String(content.length),
|
||||
})
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`PUT ${path} failed (${res.status}): ${text.slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Move (rename) a folder or file. */
|
||||
export async function moveFolder(from: string, to: string): Promise<void> {
|
||||
const { davBase } = getConfig()
|
||||
const destination = davUrl(davBase, to) + '/'
|
||||
|
||||
const res = await davRequest('MOVE', from + '/', null, {
|
||||
Destination: destination,
|
||||
Overwrite: 'F',
|
||||
})
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`MOVE ${from} -> ${to} failed (${res.status}): ${text.slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// High-level: find next available version folder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the next available Vx folder for archiving.
|
||||
* E.g. if V1/coffeetest exists but V2/coffeetest doesn't, returns "V2".
|
||||
*/
|
||||
export async function findNextVersion(
|
||||
basePath: string,
|
||||
folderName: string,
|
||||
): Promise<string> {
|
||||
for (let i = 1; ; i++) {
|
||||
const versionPath = `${basePath}/V${i}/${folderName}`
|
||||
const exists = await folderExists(versionPath)
|
||||
if (!exists) return `V${i}`
|
||||
}
|
||||
}
|
||||
@@ -20,18 +20,3 @@ export interface RemoteFile {
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export type UploadResponse =
|
||||
| {
|
||||
success: true
|
||||
folderName: string
|
||||
filesCount: number
|
||||
compressed: boolean
|
||||
compressionError?: string
|
||||
message: string
|
||||
commitUrl?: string
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
|
||||
+2
-2
@@ -174,7 +174,7 @@ def process_file(input_path, output_path=None, draco_level=7,
|
||||
if not quiet:
|
||||
print("Importing mesh...")
|
||||
|
||||
import_output = import_mesh(input_path)
|
||||
import_mesh(input_path)
|
||||
|
||||
if len(bpy.data.objects) == 0:
|
||||
raise RuntimeError(f"No objects imported from {input_path}")
|
||||
@@ -198,7 +198,7 @@ def process_file(input_path, output_path=None, draco_level=7,
|
||||
if not quiet:
|
||||
print(f"Exporting with Draco compression (level={draco_level})...")
|
||||
|
||||
export_output = export_mesh(output_path, draco_level, format_type)
|
||||
export_mesh(output_path, draco_level, format_type)
|
||||
|
||||
if not os.path.exists(output_path):
|
||||
raise RuntimeError(f"Export failed: {output_path} not created")
|
||||
|
||||
@@ -9,19 +9,12 @@ const config: Config = {
|
||||
extend: {
|
||||
colors: {
|
||||
black: {
|
||||
50: '#F7F7F7',
|
||||
100: '#E5E5E5',
|
||||
200: '#CCCCCC',
|
||||
300: '#999999',
|
||||
400: '#666666',
|
||||
500: '#333333',
|
||||
600: '#1A1A1A',
|
||||
700: '#0D0D0D',
|
||||
800: '#050505',
|
||||
900: '#000000',
|
||||
},
|
||||
gray: {
|
||||
50: '#FAFAFA',
|
||||
100: '#F5F5F5',
|
||||
200: '#E5E5E5',
|
||||
300: '#D4D4D4',
|
||||
@@ -29,18 +22,12 @@ const config: Config = {
|
||||
500: '#737373',
|
||||
600: '#525252',
|
||||
700: '#404040',
|
||||
800: '#262626',
|
||||
900: '#171717',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
|
||||
mono: ['var(--font-jetbrains-mono)', 'Fira Code', 'monospace'],
|
||||
},
|
||||
boxShadow: {
|
||||
soft: '0 2px 16px 0 rgba(0, 0, 0, 0.06)',
|
||||
card: '0 4px 24px 0 rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
Reference in New Issue
Block a user