update: add gestion erreur si dossier est existant
This commit is contained in:
@@ -2,3 +2,4 @@ UPLOAD_SECRET_KEY=your-secret-key-here
|
|||||||
GITHUB_TOKEN=ghp_your-github-personal-access-token
|
GITHUB_TOKEN=ghp_your-github-personal-access-token
|
||||||
GIT_BRANCH=main
|
GIT_BRANCH=main
|
||||||
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
||||||
|
BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
|
||||||
@@ -28,17 +28,18 @@ UPLOAD_SECRET_KEY=your-secret-key-here
|
|||||||
GITHUB_TOKEN=ghp_your-github-personal-access-token
|
GITHUB_TOKEN=ghp_your-github-personal-access-token
|
||||||
GIT_BRANCH=main
|
GIT_BRANCH=main
|
||||||
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
GIT_REPO_URL=https://github.com/your-org/your-repo.git
|
||||||
|
BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
|
||||||
```
|
```
|
||||||
|
|
||||||
| Variable | Description | Required |
|
| Variable | Description | Required |
|
||||||
|----------|-------------|----------|
|
|----------|-------------|----------|
|
||||||
| `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes |
|
| `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes |
|
||||||
| `GITHUB_TOKEN` | GitHub Personal Access Token (scope `repo`) | Yes |
|
| `GITHUB_TOKEN` | GitHub Personal Access Token (fine-grained, `Contents: Read and write`) | Yes |
|
||||||
| `GIT_BRANCH` | Target branch (default: main) | No |
|
| `GIT_BRANCH` | Target branch (default: main) | No |
|
||||||
| `GIT_REPO_URL` | Target GitHub repository URL | Yes |
|
| `GIT_REPO_URL` | Target GitHub repository URL | Yes |
|
||||||
| `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No |
|
| `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No |
|
||||||
|
|
||||||
> To create a token: GitHub > Settings > Developer settings > Personal access tokens > Generate new token (classic) with the `repo` scope.
|
> To create a token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ npm run dev
|
|||||||
|
|
||||||
Access the app at `http://localhost:3000`
|
Access the app at `http://localhost:3000`
|
||||||
|
|
||||||
> **Note:** Draco compression requires Blender installed locally. If Blender is not available, models are pushed to GitHub without compression.
|
> **Note:** Draco compression requires Blender installed locally. On macOS, Blender is typically at `/Applications/Blender.app/Contents/MacOS/Blender`. Set `BLENDER_PATH` in `.env.local` accordingly. If Blender is not available, models are pushed to GitHub without compression.
|
||||||
|
|
||||||
### Production (Coolify / Docker)
|
### Production (Coolify / Docker)
|
||||||
|
|
||||||
@@ -68,25 +69,53 @@ The Dockerfile includes Blender headless for automatic Draco compression.
|
|||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. The user enters their access key
|
1. The user enters their access key
|
||||||
2. They select a folder containing:
|
2. They pick a **destination** (`farm`, `map`, `powergrid`, `workshop`, `general`, `environment`)
|
||||||
|
3. They select a folder containing:
|
||||||
- `model.glb` or `model.gltf` (required)
|
- `model.glb` or `model.gltf` (required)
|
||||||
- Textures: `roughness`, `normal`, `metalness`, `color`, `displace` (`.png/.jpg/.webp`, optional)
|
- Textures: `roughness`, `normal`, `metalness`, `color`, `displace` (`.png/.jpg/.webp`, optional)
|
||||||
3. The model is displayed in a 3D preview
|
4. The model is displayed in a 3D preview
|
||||||
4. On clicking "Upload & Push to GitHub", each file is sent to the `/api/upload` endpoint
|
5. On clicking "Envoyer sur GitHub":
|
||||||
5. For models: the file is written to `/tmp`, compressed with Blender Draco, then the compressed version is pushed to GitHub
|
- The app checks if the folder already exists on the remote repo
|
||||||
6. For textures: pushed directly to GitHub without compression
|
- If it exists, a confirmation dialog is shown listing the existing files that will be replaced
|
||||||
7. If Blender is unavailable, the original model is pushed as-is (graceful fallback)
|
- On confirmation (or if the folder is new), all files are sent to the `/api/upload` endpoint
|
||||||
|
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. All files are pushed in a **single commit** with a formatted message:
|
||||||
|
```
|
||||||
|
update: upload-gltf add a new model -> farm/my-model
|
||||||
|
|
||||||
|
📦 Model
|
||||||
|
✅ model.gltf (compressed)
|
||||||
|
🎨 Textures
|
||||||
|
✅ color.jpg
|
||||||
|
❌ metalness (manquant)
|
||||||
|
```
|
||||||
|
9. If the folder already existed, orphan files (present in the old version but not in the new upload) are deleted in the same commit
|
||||||
|
10. If Blender is unavailable, the original model is pushed as-is (graceful fallback)
|
||||||
|
|
||||||
|
## Destinations
|
||||||
|
|
||||||
|
Uploaded models are pushed to `public/models/<destination>/<folderName>/` in the target repo:
|
||||||
|
|
||||||
|
| Destination | Path |
|
||||||
|
|-------------|------|
|
||||||
|
| Farm | `public/models/farm/` |
|
||||||
|
| Map | `public/models/map/` |
|
||||||
|
| Powergrid | `public/models/powergrid/` |
|
||||||
|
| Workshop | `public/models/workshop/` |
|
||||||
|
| General | `public/models/general/` |
|
||||||
|
| Environment | `public/models/environment/` |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
├── api/upload/route.ts # API: validation + Draco compression + GitHub push
|
├── api/upload/route.ts # API: GET (check existence) + POST (compress + push)
|
||||||
├── globals.css # Tailwind + Google Fonts
|
├── globals.css # Tailwind + Google Fonts
|
||||||
├── layout.tsx # Root layout
|
├── layout.tsx # Root layout
|
||||||
└── page.tsx # Home page
|
└── page.tsx # Home page
|
||||||
components/
|
components/
|
||||||
├── UploadZone.tsx # UI: key input, folder picker, validation, upload
|
├── UploadZone.tsx # UI: key input, destination picker, folder picker, validation, overwrite confirmation, upload
|
||||||
├── ModelViewer.tsx # Lazy wrapper for the 3D viewer
|
├── ModelViewer.tsx # Lazy wrapper for the 3D viewer
|
||||||
└── SceneViewer.tsx # Three.js Canvas
|
└── SceneViewer.tsx # Three.js Canvas
|
||||||
scripts/
|
scripts/
|
||||||
|
|||||||
+120
-15
@@ -15,6 +15,7 @@ const MODEL_EXTENSIONS = new Set(['.glb', '.gltf'])
|
|||||||
const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
||||||
const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS])
|
const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS])
|
||||||
const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace']
|
const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace']
|
||||||
|
const VALID_DESTINATIONS = new Set(['farm', 'map', 'powergrid', 'workshop', 'general', 'environment'])
|
||||||
|
|
||||||
const TMP_DIR = join('/tmp', 'assets')
|
const TMP_DIR = join('/tmp', 'assets')
|
||||||
|
|
||||||
@@ -89,11 +90,12 @@ async function compressWithBlender(
|
|||||||
|
|
||||||
function buildCommitMessage(
|
function buildCommitMessage(
|
||||||
folderName: string,
|
folderName: string,
|
||||||
|
destination: string,
|
||||||
modelFilename: string,
|
modelFilename: string,
|
||||||
textureNames: string[],
|
textureNames: string[],
|
||||||
compressed: boolean,
|
compressed: boolean,
|
||||||
): string {
|
): string {
|
||||||
const title = `update: from upload-gltf add a new model -> ${folderName}`
|
const title = `update: upload-gltf add a new model -> ${destination}/${folderName}`
|
||||||
|
|
||||||
const foundTextures = new Set(
|
const foundTextures = new Set(
|
||||||
textureNames.map(t => t.toLowerCase().replace(/\.[^.]+$/, ''))
|
textureNames.map(t => t.toLowerCase().replace(/\.[^.]+$/, ''))
|
||||||
@@ -111,8 +113,7 @@ function buildCommitMessage(
|
|||||||
title,
|
title,
|
||||||
'',
|
'',
|
||||||
'📦 Model',
|
'📦 Model',
|
||||||
` ✅ ${modelFilename}${compressed ? ' (Draco)' : ''}`,
|
` ✅ ${modelFilename}${compressed ? ' (compressed)' : ''}`,
|
||||||
'',
|
|
||||||
'🎨 Textures',
|
'🎨 Textures',
|
||||||
...textureLines,
|
...textureLines,
|
||||||
]
|
]
|
||||||
@@ -133,12 +134,19 @@ interface ParsedFile {
|
|||||||
|
|
||||||
async function parseMultiUpload(req: NextRequest): Promise<{
|
async function parseMultiUpload(req: NextRequest): Promise<{
|
||||||
folderName: string
|
folderName: string
|
||||||
|
destination: string
|
||||||
files: ParsedFile[]
|
files: ParsedFile[]
|
||||||
}> {
|
}> {
|
||||||
const formData = await req.formData()
|
const formData = await req.formData()
|
||||||
const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets'
|
const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets'
|
||||||
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
|
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
|
||||||
|
|
||||||
|
const rawDestination = (formData.get('destination') as string | null)?.trim() || 'general'
|
||||||
|
if (!VALID_DESTINATIONS.has(rawDestination)) {
|
||||||
|
throw new Error(`Destination invalide: "${rawDestination}"`)
|
||||||
|
}
|
||||||
|
const destination = rawDestination
|
||||||
|
|
||||||
const fileEntries = formData.getAll('files') as File[]
|
const fileEntries = formData.getAll('files') as File[]
|
||||||
const fileTypes = formData.getAll('fileTypes') as string[]
|
const fileTypes = formData.getAll('fileTypes') as string[]
|
||||||
const textureNames = formData.getAll('textureNames') as string[]
|
const textureNames = formData.getAll('textureNames') as string[]
|
||||||
@@ -176,7 +184,7 @@ async function parseMultiUpload(req: NextRequest): Promise<{
|
|||||||
parsed.push({ filename, buffer, isModel, textureName: texName || undefined })
|
parsed.push({ filename, buffer, isModel, textureName: texName || undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { folderName: safeFolderName, files: parsed }
|
return { folderName: safeFolderName, destination, files: parsed }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -186,6 +194,7 @@ async function parseMultiUpload(req: NextRequest): Promise<{
|
|||||||
async function pushAllToGitHub(
|
async function pushAllToGitHub(
|
||||||
folderName: string,
|
folderName: string,
|
||||||
files: { path: string; contentBase64: string }[],
|
files: { path: string; contentBase64: string }[],
|
||||||
|
deletePaths: string[],
|
||||||
commitMessage: string
|
commitMessage: string
|
||||||
): Promise<{ commitUrl: string }> {
|
): Promise<{ commitUrl: string }> {
|
||||||
const octokit = getOctokit()
|
const octokit = getOctokit()
|
||||||
@@ -219,17 +228,30 @@ async function pushAllToGitHub(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 4. Create a single tree with all files
|
// 4. Create a single tree with all files (add new + delete orphans)
|
||||||
|
const newFilePaths = new Set(files.map(f => f.path))
|
||||||
|
const deleteEntries = deletePaths
|
||||||
|
.filter(p => !newFilePaths.has(p))
|
||||||
|
.map(p => ({
|
||||||
|
path: p,
|
||||||
|
mode: '100644' as const,
|
||||||
|
type: 'blob' as const,
|
||||||
|
sha: null,
|
||||||
|
}))
|
||||||
|
|
||||||
const { data: newTree } = await octokit.git.createTree({
|
const { data: newTree } = await octokit.git.createTree({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
base_tree: commit.tree.sha,
|
base_tree: commit.tree.sha,
|
||||||
tree: files.map((f, i) => ({
|
tree: [
|
||||||
path: f.path,
|
...files.map((f, i) => ({
|
||||||
mode: '100644' as const,
|
path: f.path,
|
||||||
type: 'blob' as const,
|
mode: '100644' as const,
|
||||||
sha: blobResults[i].data.sha,
|
type: 'blob' as const,
|
||||||
})),
|
sha: blobResults[i].data.sha,
|
||||||
|
})),
|
||||||
|
...deleteEntries,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// 5. Create a single commit
|
// 5. Create a single commit
|
||||||
@@ -252,6 +274,65 @@ async function pushAllToGitHub(
|
|||||||
return { commitUrl: newCommit.html_url }
|
return { commitUrl: newCommit.html_url }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET handler — check if folder already exists on remote
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const destination = searchParams.get('destination')?.trim()
|
||||||
|
const folderName = searchParams.get('folderName')?.trim()
|
||||||
|
const secret = req.headers.get('x-upload-secret')
|
||||||
|
const expectedSecret = process.env.UPLOAD_SECRET_KEY
|
||||||
|
|
||||||
|
if (!expectedSecret || !secret || secret !== expectedSecret) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Non autorise' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!destination || !folderName) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Parametres manquants' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_DESTINATIONS.has(destination)) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Destination invalide' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
|
||||||
|
const folderPath = `public/models/${destination}/${safeFolderName}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const octokit = getOctokit()
|
||||||
|
const { owner, repo } = parseRepoUrl()
|
||||||
|
const branch = process.env.GIT_BRANCH ?? 'main'
|
||||||
|
|
||||||
|
const { data } = await octokit.repos.getContent({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
path: folderPath,
|
||||||
|
ref: branch,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const existingFiles = data.map(f => f.name)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
exists: true,
|
||||||
|
path: folderPath,
|
||||||
|
files: existingFiles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, exists: false })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status = (err as { status?: number })?.status
|
||||||
|
if (status === 404) {
|
||||||
|
return NextResponse.json({ success: true, exists: false })
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST handler
|
// POST handler
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -277,10 +358,11 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// --- Parse all files ---
|
// --- Parse all files ---
|
||||||
let folderName: string
|
let folderName: string
|
||||||
|
let destination: string
|
||||||
let parsedFiles: ParsedFile[]
|
let parsedFiles: ParsedFile[]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
;({ folderName, files: parsedFiles } = await parseMultiUpload(req))
|
;({ folderName, destination, files: parsedFiles } = await parseMultiUpload(req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
||||||
@@ -325,17 +407,40 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filesToPush.push({
|
filesToPush.push({
|
||||||
path: `public/assets/${folderName}/${pf.filename}`,
|
path: `public/models/${destination}/${folderName}/${pf.filename}`,
|
||||||
contentBase64: content.toString('base64'),
|
contentBase64: content.toString('base64'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Build commit message ---
|
// --- Build commit message ---
|
||||||
const commitMessage = buildCommitMessage(folderName, modelFilename, textureNames, compressed)
|
const commitMessage = buildCommitMessage(folderName, destination, modelFilename, textureNames, compressed)
|
||||||
|
|
||||||
|
// --- Detect existing files to clean up orphans ---
|
||||||
|
const folderPath = `public/models/${destination}/${folderName}`
|
||||||
|
let existingFilePaths: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const octokit = getOctokit()
|
||||||
|
const { owner, repo } = parseRepoUrl()
|
||||||
|
const branch = process.env.GIT_BRANCH ?? 'main'
|
||||||
|
|
||||||
|
const { data } = await octokit.repos.getContent({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
path: folderPath,
|
||||||
|
ref: branch,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
existingFilePaths = data.map(f => `${folderPath}/${f.name}`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 404 = folder doesn't exist yet, no cleanup needed
|
||||||
|
}
|
||||||
|
|
||||||
// --- Push all in one commit ---
|
// --- Push all in one commit ---
|
||||||
try {
|
try {
|
||||||
const { commitUrl } = await pushAllToGitHub(folderName, filesToPush, commitMessage)
|
const { commitUrl } = await pushAllToGitHub(folderName, filesToPush, existingFilePaths, commitMessage)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ interface FolderEntry {
|
|||||||
const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace']
|
const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace']
|
||||||
const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp']
|
const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp']
|
||||||
|
|
||||||
|
const DESTINATIONS = [
|
||||||
|
{ value: 'farm', label: 'Farm' },
|
||||||
|
{ value: 'map', label: 'Map' },
|
||||||
|
{ value: 'powergrid', label: 'Powergrid' },
|
||||||
|
{ value: 'workshop', label: 'Workshop' },
|
||||||
|
{ value: 'general', label: 'General' },
|
||||||
|
{ value: 'environment', label: 'Environment' },
|
||||||
|
] as const
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes === 0) return '0 B'
|
if (bytes === 0) return '0 B'
|
||||||
const k = 1024
|
const k = 1024
|
||||||
@@ -79,13 +88,35 @@ function validateFolder(files: File[]): { model?: File; textures: TextureFile[];
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkFolderExists(
|
||||||
|
folderName: string,
|
||||||
|
destination: string,
|
||||||
|
secret: string,
|
||||||
|
): Promise<{ exists: boolean; files: string[] }> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ folderName, destination })
|
||||||
|
const res = await fetch(`/api/upload?${params}`, {
|
||||||
|
headers: { 'x-upload-secret': secret.trim() },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success && data.exists) {
|
||||||
|
return { exists: true, files: data.files || [] }
|
||||||
|
}
|
||||||
|
return { exists: false, files: [] }
|
||||||
|
} catch {
|
||||||
|
return { exists: false, files: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadFolder(
|
async function uploadFolder(
|
||||||
folder: FolderEntry,
|
folder: FolderEntry,
|
||||||
secret: string,
|
secret: string,
|
||||||
|
destination: string,
|
||||||
onProgress: (pct: number) => void
|
onProgress: (pct: number) => void
|
||||||
): Promise<{ success: boolean; filename?: string; error?: string }> {
|
): Promise<{ success: boolean; filename?: string; error?: string }> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('folderName', folder.folderName)
|
formData.append('folderName', folder.folderName)
|
||||||
|
formData.append('destination', destination)
|
||||||
|
|
||||||
// Model file
|
// Model file
|
||||||
formData.append('files', folder.modelFile)
|
formData.append('files', folder.modelFile)
|
||||||
@@ -130,7 +161,9 @@ export default function UploadZone() {
|
|||||||
const [secretVisible, setSecretVisible] = useState(false)
|
const [secretVisible, setSecretVisible] = useState(false)
|
||||||
const [globalError, setGlobalError] = useState<string | null>(null)
|
const [globalError, setGlobalError] = useState<string | null>(null)
|
||||||
const [secretError, setSecretError] = useState<string | null>(null)
|
const [secretError, setSecretError] = useState<string | null>(null)
|
||||||
|
const [destination, setDestination] = useState<typeof DESTINATIONS[number]['value']>(DESTINATIONS[0].value)
|
||||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||||
|
const [overwriteConfirm, setOverwriteConfirm] = useState<{ folderName: string; files: string[] } | null>(null)
|
||||||
|
|
||||||
const isSecretEmpty = !secret.trim()
|
const isSecretEmpty = !secret.trim()
|
||||||
|
|
||||||
@@ -146,6 +179,21 @@ export default function UploadZone() {
|
|||||||
if (files.length === 0) return
|
if (files.length === 0) return
|
||||||
|
|
||||||
setSecretError(null)
|
setSecretError(null)
|
||||||
|
setGlobalError(null)
|
||||||
|
|
||||||
|
// Check if folder already exists on remote
|
||||||
|
const folder = files[0]
|
||||||
|
const check = await checkFolderExists(folder.folderName, destination, secret)
|
||||||
|
if (check.exists) {
|
||||||
|
setOverwriteConfirm({ folderName: folder.folderName, files: check.files })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await proceedUpload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const proceedUpload = async () => {
|
||||||
|
setOverwriteConfirm(null)
|
||||||
setIsUploading(true)
|
setIsUploading(true)
|
||||||
setGlobalError(null)
|
setGlobalError(null)
|
||||||
|
|
||||||
@@ -157,6 +205,7 @@ export default function UploadZone() {
|
|||||||
const result = await uploadFolder(
|
const result = await uploadFolder(
|
||||||
files[i],
|
files[i],
|
||||||
secret,
|
secret,
|
||||||
|
destination,
|
||||||
(pct) => updateFile(i, { progress: pct })
|
(pct) => updateFile(i, { progress: pct })
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -237,6 +286,28 @@ export default function UploadZone() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">Destination</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{DESTINATIONS.map((dest) => (
|
||||||
|
<button
|
||||||
|
key={dest.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDestination(dest.value)}
|
||||||
|
disabled={isUploading}
|
||||||
|
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all duration-150 border
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
${destination === dest.value
|
||||||
|
? 'bg-white text-[#000000] border-white'
|
||||||
|
: 'bg-black-800 text-gray-400 border-white/20 hover:border-white/40 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dest.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id="folder-input"
|
id="folder-input"
|
||||||
@@ -448,6 +519,60 @@ export default function UploadZone() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{overwriteConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<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-yellow-900/30 flex items-center justify-center shrink-0">
|
||||||
|
<svg className="w-5 h-5 text-yellow-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 className="text-sm font-semibold text-gray-100">Dossier deja existant</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
<span className="font-mono text-yellow-400">{destination}/{overwriteConfirm.folderName}</span> existe deja sur le repo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overwriteConfirm.files.length > 0 && (
|
||||||
|
<div className="bg-black-800 border border-white/10 rounded-xl p-3 max-h-32 overflow-y-auto">
|
||||||
|
<p className="text-xs text-gray-500 mb-1.5">Fichiers existants qui seront remplaces :</p>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{overwriteConfirm.files.map((f) => (
|
||||||
|
<li key={f} className="text-xs text-gray-400 font-mono">{f}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Les anciens fichiers seront supprimes et remplaces par les nouveaux. Cette action est irreversible.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setOverwriteConfirm(null)}
|
||||||
|
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={proceedUpload}
|
||||||
|
className="flex-1 bg-yellow-600 text-[#000000] font-medium text-sm
|
||||||
|
py-2.5 px-4 rounded-xl transition-colors duration-150
|
||||||
|
hover:bg-yellow-500"
|
||||||
|
>
|
||||||
|
Remplacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user