Bilder von Hand sortieren
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import multer from 'multer';
|
||||
import multer, { MulterError } from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { config } from '../config/config';
|
||||
@@ -21,38 +21,72 @@ const getUploadsDir = (subPath?: string): string => {
|
||||
return subPath ? path.join(baseDir, subPath) : baseDir;
|
||||
};
|
||||
|
||||
// Configure multer for file uploads
|
||||
// Middleware: load recipe by :recipeId and attach recipeNumber for storage
|
||||
const loadRecipe = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
if (!recipeId || isNaN(Number(recipeId))) {
|
||||
return res.status(400).json({ success: false, message: 'Valid recipeId is required' });
|
||||
}
|
||||
const recipe = await prisma.recipe.findUnique({ where: { id: Number(recipeId) } });
|
||||
if (!recipe) {
|
||||
return res.status(404).json({ success: false, message: 'Recipe not found' });
|
||||
}
|
||||
// Attach for later usage
|
||||
(req as any).recipeNumber = recipe.recipeNumber;
|
||||
res.locals.recipe = recipe;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Map mimetypes to extensions
|
||||
const mimeExt: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp'
|
||||
};
|
||||
|
||||
// Configure multer for file uploads (uses injected recipeNumber)
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const recipeNumber = req.body.recipeNumber || req.params.recipeNumber;
|
||||
const recipeNumber = (req as any).recipeNumber || req.body.recipeNumber || req.params.recipeNumber;
|
||||
if (!recipeNumber) {
|
||||
return cb(new Error('Recipe number is required'), '');
|
||||
}
|
||||
|
||||
const uploadDir = getUploadsDir(recipeNumber);
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
try {
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
} catch (e) {
|
||||
return cb(new Error('Failed to prepare upload directory'), '');
|
||||
}
|
||||
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const recipeNumber = req.body.recipeNumber || req.params.recipeNumber;
|
||||
const recipeNumber = (req as any).recipeNumber || req.body.recipeNumber || req.params.recipeNumber;
|
||||
if (!recipeNumber) {
|
||||
return cb(new Error('Recipe number is required'), '');
|
||||
}
|
||||
|
||||
// Get existing files count to determine next index
|
||||
const uploadDir = getUploadsDir(recipeNumber);
|
||||
const existingFiles = fs.existsSync(uploadDir)
|
||||
? fs.readdirSync(uploadDir).filter(f => f.match(new RegExp(`^${recipeNumber}_\\d+\\.jpg$`)))
|
||||
const existingFiles = fs.existsSync(uploadDir)
|
||||
? fs.readdirSync(uploadDir).filter(f => f.startsWith(`${recipeNumber}_`))
|
||||
: [];
|
||||
|
||||
const nextIndex = existingFiles.length;
|
||||
const filename = `${recipeNumber}_${nextIndex}.jpg`;
|
||||
|
||||
// Determine next index by scanning existing indices
|
||||
let maxIndex = -1;
|
||||
existingFiles.forEach(f => {
|
||||
const m = f.match(new RegExp(`^${recipeNumber}_(\\d+)`));
|
||||
if (m && m[1] !== undefined) {
|
||||
const idx = parseInt(m[1]!, 10);
|
||||
if (!Number.isNaN(idx) && idx > maxIndex) maxIndex = idx;
|
||||
}
|
||||
});
|
||||
const nextIndex = maxIndex + 1;
|
||||
const ext = mimeExt[file.mimetype] || 'jpg';
|
||||
const filename = `${recipeNumber}_${nextIndex}.${ext}`;
|
||||
cb(null, filename);
|
||||
}
|
||||
});
|
||||
@@ -72,65 +106,44 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
// Upload images for a recipe
|
||||
router.post('/upload/:recipeId', upload.array('images', 10), async (req: Request, res: Response, next: NextFunction) => {
|
||||
// Upload images for a recipe (preload recipe middleware before multer)
|
||||
router.post('/upload/:recipeId', loadRecipe, (req, res, next) => {
|
||||
const mw = upload.array('images', 10);
|
||||
mw(req, res, function(err: any) {
|
||||
if (err) {
|
||||
if (err instanceof MulterError) {
|
||||
return res.status(400).json({ success: false, message: `Upload error: ${err.message}` });
|
||||
}
|
||||
return res.status(400).json({ success: false, message: err.message || 'Upload failed' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
if (!recipeId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Recipe ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const recipe = res.locals.recipe;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'No files uploaded',
|
||||
});
|
||||
return res.status(400).json({ success: false, message: 'No files uploaded' });
|
||||
}
|
||||
|
||||
// Get recipe to validate it exists and get recipe number
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: parseInt(recipeId) }
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Recipe not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Create database entries for uploaded images
|
||||
const imagePromises = files.map(file => {
|
||||
|
||||
const images = await Promise.all(files.map(file => {
|
||||
const relativePath = `uploads/${recipe.recipeNumber}/${file.filename}`;
|
||||
return prisma.recipeImage.create({
|
||||
data: {
|
||||
recipeId: parseInt(recipeId),
|
||||
filePath: relativePath,
|
||||
}
|
||||
data: { recipeId: Number(recipeId), filePath: relativePath }
|
||||
});
|
||||
});
|
||||
|
||||
const images = await Promise.all(imagePromises);
|
||||
|
||||
}));
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: images,
|
||||
message: `${files.length} images uploaded successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up uploaded files if database operation fails
|
||||
if (req.files) {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
files.forEach(file => {
|
||||
if (fs.existsSync(file.path)) {
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
});
|
||||
files.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
@@ -287,4 +300,94 @@ router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Reorder images for a recipe
|
||||
router.post('/reorder/:recipeId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
/*
|
||||
Payload: { order: number[] }
|
||||
- order: Array of image IDs in desired final sequence.
|
||||
- First becomes *_0.ext (main), second *_1.ext, ...
|
||||
*/
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
const { order } = req.body as { order?: number[] };
|
||||
|
||||
if (!recipeId || isNaN(Number(recipeId))) {
|
||||
return res.status(400).json({ success: false, message: 'Valid recipeId required' });
|
||||
}
|
||||
if (!Array.isArray(order) || order.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'order (non-empty array) required' });
|
||||
}
|
||||
|
||||
const rid = Number(recipeId);
|
||||
const recipe = await prisma.recipe.findUnique({ where: { id: rid } });
|
||||
if (!recipe) return res.status(404).json({ success: false, message: 'Recipe not found' });
|
||||
|
||||
const images = await prisma.recipeImage.findMany({ where: { recipeId: rid }, orderBy: { id: 'asc' } });
|
||||
if (images.length === 0) return res.status(400).json({ success: false, message: 'No images to reorder' });
|
||||
|
||||
// Validate order covers exactly all image IDs
|
||||
const existingIds = images.map(i => i.id).sort((a,b)=>a-b);
|
||||
const provided = [...order].sort((a,b)=>a-b);
|
||||
if (existingIds.length !== provided.length || !existingIds.every((v,i)=>v===provided[i])) {
|
||||
return res.status(400).json({ success: false, message: 'order must contain all image IDs exactly once' });
|
||||
}
|
||||
|
||||
const recipeNumber = recipe.recipeNumber;
|
||||
const baseDir = getUploadsDir(recipeNumber);
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
return res.status(500).json({ success: false, message: 'Upload directory missing on server' });
|
||||
}
|
||||
|
||||
// Build mapping: targetIndex -> image object
|
||||
const idToImage = new Map(images.map(i => [i.id, i] as const));
|
||||
const newSequence = order.map((id, idx) => ({ idx, image: idToImage.get(id)! }));
|
||||
|
||||
// Phase 1: rename to temporary names to avoid collisions
|
||||
const tempRenames: { from: string; to: string; final: string; idx: number; ext: string }[] = [];
|
||||
for (const { idx, image } of newSequence) {
|
||||
const fileName = image.filePath.split('/').pop() || '';
|
||||
const extMatch = fileName.match(/\.(jpg|jpeg|png|webp)$/i);
|
||||
const ext = extMatch && extMatch[1] ? extMatch[1].toLowerCase() : 'jpg';
|
||||
const finalName = `${recipeNumber}_${idx}.${ext === 'jpeg' ? 'jpg' : ext}`;
|
||||
// Previous implementation incorrectly traversed ../../ resulting in /uploads/... (missing /app prefix in container)
|
||||
// image.filePath is like 'uploads/R100/R100_0.png' and baseDir points to '/app/uploads/R100'
|
||||
// So we join baseDir with just the filename to locate the current file.
|
||||
const oldFull = path.join(baseDir, path.basename(fileName));
|
||||
const tempName = `${recipeNumber}__reorder_${idx}_${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const tempFull = path.join(baseDir, tempName);
|
||||
const finalFull = path.join(baseDir, finalName);
|
||||
if (!fs.existsSync(oldFull)) {
|
||||
return res.status(500).json({ success: false, message: `File missing on disk: ${fileName}` });
|
||||
}
|
||||
fs.renameSync(oldFull, tempFull);
|
||||
tempRenames.push({ from: tempFull, to: finalFull, final: `uploads/${recipeNumber}/${finalName}`, idx, ext });
|
||||
}
|
||||
|
||||
// Phase 2: rename temp -> final
|
||||
for (const r of tempRenames) {
|
||||
fs.renameSync(r.from, r.to);
|
||||
}
|
||||
|
||||
// Update DB entries in a transaction-like sequence
|
||||
// (MySQL autocommit; if one fails we attempt best-effort rollback is complex -> assume disk ops succeeded)
|
||||
await Promise.all(newSequence.map(({ idx, image }) => {
|
||||
const renameRecord = tempRenames.find(tr => tr.idx === idx)!;
|
||||
return prisma.recipeImage.update({
|
||||
where: { id: image.id },
|
||||
data: { filePath: renameRecord.final }
|
||||
});
|
||||
}));
|
||||
|
||||
const updated = await prisma.recipeImage.findMany({ where: { recipeId: rid }, orderBy: { id: 'asc' } });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Images reordered',
|
||||
data: updated,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user