Bilder von Hand sortieren
This commit is contained in:
3
.env
3
.env
@@ -10,7 +10,7 @@ MYSQL_PASSWORD=dev_password_123
|
|||||||
MYSQL_ROOT_PASSWORD=dev_root_password_123
|
MYSQL_ROOT_PASSWORD=dev_root_password_123
|
||||||
|
|
||||||
# CORS Configuration for remote access
|
# CORS Configuration for remote access
|
||||||
CORS_ORIGIN=*
|
CORS_ORIGIN=http://esprimo:3000,http://localhost:3000,http://localhost:5173
|
||||||
|
|
||||||
# Development URLs:
|
# Development URLs:
|
||||||
# - Frontend: http://192.168.178.94:3000
|
# - Frontend: http://192.168.178.94:3000
|
||||||
@@ -27,3 +27,4 @@ DOCKER_REGISTRY=docker.citysensor.de
|
|||||||
# - Use this for testing Linux-specific behavior
|
# - Use this for testing Linux-specific behavior
|
||||||
# - Access from Mac via: http://192.168.178.94:3000
|
# - Access from Mac via: http://192.168.178.94:3000
|
||||||
# - SSH tunnel for secure access: ssh -L 3000:192.168.178.94:3000 user@server
|
# - SSH tunnel for secure access: ssh -L 3000:192.168.178.94:3000 user@server
|
||||||
|
ALLOW_INSECURE_CORS=0
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ npm run dev
|
|||||||
- `GET /api/images/recipe/:recipeId` - Get recipe images
|
- `GET /api/images/recipe/:recipeId` - Get recipe images
|
||||||
- `GET /api/images/serve/:imagePath` - Serve image file
|
- `GET /api/images/serve/:imagePath` - Serve image file
|
||||||
- `GET /api/images/:id` - Get image metadata
|
- `GET /api/images/:id` - Get image metadata
|
||||||
|
- `POST /api/images/reorder/:recipeId` - Reorder all images for a recipe (body: `{ "order": [imageId1, imageId2, ...] }`) renames files sequentially `<recipeNumber>_0.*`, `<recipeNumber>_1.*`, ...
|
||||||
|
|
||||||
### Health
|
### Health
|
||||||
- `GET /api/health` - Server health check
|
- `GET /api/health` - Server health check
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -6,10 +6,10 @@ Dieses Repository enthält zwei Welten: den modernen Node.js / React Stack sowie
|
|||||||
|
|
||||||
| Service | Profil | Port Host -> Container | Beschreibung |
|
| Service | Profil | Port Host -> Container | Beschreibung |
|
||||||
|--------------|-----------|------------------------|--------------|
|
|--------------|-----------|------------------------|--------------|
|
||||||
| mysql | default | 3307 -> 3306 | MySQL 8 Datenbank |
|
| mysql | (auto) | 3307 -> 3306 | MySQL 8 Datenbank |
|
||||||
| backend | default | 3001 -> 3001 | Node.js API (Express + Prisma) |
|
| backend | (auto) | 3001 -> 3001 | Node.js API (Express + Prisma) |
|
||||||
| frontend | default | 3000 -> 80 | React Build via nginx |
|
| frontend | (auto) | 3000 -> 80 | React Build via nginx |
|
||||||
| phpmyadmin | admin | 8083 -> 80 | DB Verwaltung |
|
| phpmyadmin | admin | 8083 -> 80 | DB Verwaltung (optional) |
|
||||||
|
|
||||||
## Node / Modern Stack
|
## Node / Modern Stack
|
||||||
Empfohlene Node Version: **22.12.0** (`.nvmrc`, `.tool-versions`).
|
Empfohlene Node Version: **22.12.0** (`.nvmrc`, `.tool-versions`).
|
||||||
@@ -59,11 +59,11 @@ docker compose logs -f mysql
|
|||||||
```
|
```
|
||||||
|
|
||||||
## CORS Hinweis
|
## CORS Hinweis
|
||||||
Aktuell ist `CORS_ORIGIN="*"` (Testphase). Für Produktion einschränken, z.B.:
|
Das Backend akzeptiert nur Ursprünge aus `CORS_ORIGIN` (kommagetrennt). In Produktion wird ein Wildcard `*` geblockt, außer du setzt bewusst `ALLOW_INSECURE_CORS=1` (nicht empfohlen). Beispiel:
|
||||||
```yaml
|
```env
|
||||||
CORS_ORIGIN: http://esprimo:3000,http://localhost:3000
|
CORS_ORIGIN=http://esprimo:3000,http://localhost:3000
|
||||||
```
|
```
|
||||||
Danach Backend neu bauen.
|
Nach Änderung Backend neu bauen / starten.
|
||||||
|
|
||||||
## Datenbankzugang
|
## Datenbankzugang
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# Database
|
# Database (inside container use service name 'mysql')
|
||||||
DATABASE_URL="mysql://rezepte_user:rezepte_pass@localhost:3307/rezepte"
|
DATABASE_URL="mysql://rezepte_user:rezepte_pass@mysql:3306/rezepte"
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=3001
|
PORT=3001
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
CORS_ORIGIN=*
|
# Limit default origin; override via compose env if needed
|
||||||
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
# Prisma
|
# Prisma
|
||||||
# DATABASE_URL="file:./dev.db"
|
# DATABASE_URL="file:./dev.db"
|
||||||
2
backend/dist/app.d.ts.map
vendored
2
backend/dist/app.d.ts.map
vendored
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAgBA,QAAA,MAAM,GAAG,6CAAY,CAAC;AA0HtB,eAAe,GAAG,CAAC"}
|
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAgBA,QAAA,MAAM,GAAG,6CAAY,CAAC;AAoJtB,eAAe,GAAG,CAAC"}
|
||||||
59
backend/dist/app.js
vendored
59
backend/dist/app.js
vendored
@@ -27,28 +27,49 @@ const limiter = (0, express_rate_limit_1.default)({
|
|||||||
message: 'Too many requests from this IP, please try again later.',
|
message: 'Too many requests from this IP, please try again later.',
|
||||||
});
|
});
|
||||||
app.use(limiter);
|
app.use(limiter);
|
||||||
const allowedOrigins = [
|
const insecureOverride = process.env.ALLOW_INSECURE_CORS === '1';
|
||||||
'http://localhost:5173',
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
'http://localhost:3000',
|
let allowedOrigins = [];
|
||||||
config_1.config.cors.origin
|
if (config_1.config.cors.origin.includes(',')) {
|
||||||
].filter(Boolean);
|
allowedOrigins = config_1.config.cors.origin.split(',').map(o => o.trim()).filter(Boolean);
|
||||||
const corsConfig = config_1.config.cors.origin === '*'
|
}
|
||||||
? {
|
else if (config_1.config.cors.origin === '*' && (!isProd || insecureOverride)) {
|
||||||
origin: true,
|
allowedOrigins = ['*'];
|
||||||
credentials: true,
|
}
|
||||||
|
else {
|
||||||
|
allowedOrigins = [config_1.config.cors.origin];
|
||||||
|
}
|
||||||
|
allowedOrigins = Array.from(new Set(allowedOrigins.map(o => o.replace(/\/$/, ''))));
|
||||||
|
if (!isProd && !allowedOrigins.includes('*')) {
|
||||||
|
['http://localhost:5173', 'http://localhost:3000'].forEach(def => {
|
||||||
|
if (!allowedOrigins.includes(def))
|
||||||
|
allowedOrigins.push(def);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isProd && allowedOrigins.includes('*') && !insecureOverride) {
|
||||||
|
console.warn('[CORS] Wildcard removed in production. Set CORS_ORIGIN explicitly or ALLOW_INSECURE_CORS=1 (NOT RECOMMENDED).');
|
||||||
|
allowedOrigins = allowedOrigins.filter(o => o !== '*');
|
||||||
|
}
|
||||||
|
app.use((0, cors_1.default)({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
if (!origin)
|
||||||
|
return callback(null, true);
|
||||||
|
if (allowedOrigins.includes('*') || allowedOrigins.includes(origin.replace(/\/$/, ''))) {
|
||||||
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
: {
|
console.warn(`[CORS] Blocked origin: ${origin}`);
|
||||||
origin: allowedOrigins,
|
return callback(new Error('CORS not allowed for this origin'));
|
||||||
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
};
|
}));
|
||||||
app.use((0, cors_1.default)(corsConfig));
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (config_1.config.cors.origin === '*') {
|
const normalized = origin?.replace(/\/$/, '');
|
||||||
|
if (allowedOrigins.includes('*')) {
|
||||||
res.header('Access-Control-Allow-Origin', origin || '*');
|
res.header('Access-Control-Allow-Origin', origin || '*');
|
||||||
}
|
}
|
||||||
else if (origin && allowedOrigins.includes(origin)) {
|
else if (normalized && allowedOrigins.includes(normalized)) {
|
||||||
res.header('Access-Control-Allow-Origin', origin);
|
res.header('Access-Control-Allow-Origin', normalized);
|
||||||
}
|
}
|
||||||
res.header('Access-Control-Allow-Credentials', 'true');
|
res.header('Access-Control-Allow-Credentials', 'true');
|
||||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
@@ -80,8 +101,12 @@ app.get('/serve/*', (req, res, next) => {
|
|||||||
resolvedPath: fullPath
|
resolvedPath: fullPath
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const requestOrigin = req.headers.origin;
|
||||||
|
const chosenOrigin = allowedOrigins.includes('*')
|
||||||
|
? (requestOrigin || '*')
|
||||||
|
: (requestOrigin && allowedOrigins.includes(requestOrigin) ? requestOrigin : allowedOrigins[0] || 'http://localhost:3000');
|
||||||
res.set({
|
res.set({
|
||||||
'Access-Control-Allow-Origin': 'http://localhost:5173',
|
'Access-Control-Allow-Origin': chosenOrigin,
|
||||||
'Access-Control-Allow-Credentials': 'true',
|
'Access-Control-Allow-Credentials': 'true',
|
||||||
'Cache-Control': 'public, max-age=31536000',
|
'Cache-Control': 'public, max-age=31536000',
|
||||||
});
|
});
|
||||||
|
|||||||
2
backend/dist/app.js.map
vendored
2
backend/dist/app.js.map
vendored
File diff suppressed because one or more lines are too long
2
backend/dist/routes/images.d.ts.map
vendored
2
backend/dist/routes/images.d.ts.map
vendored
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../src/routes/images.ts"],"names":[],"mappings":"AAOA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA0RxB,eAAe,MAAM,CAAC"}
|
{"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../src/routes/images.ts"],"names":[],"mappings":"AAOA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAiYxB,eAAe,MAAM,CAAC"}
|
||||||
201
backend/dist/routes/images.js
vendored
201
backend/dist/routes/images.js
vendored
@@ -1,11 +1,44 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
const express_1 = require("express");
|
const express_1 = require("express");
|
||||||
const client_1 = require("@prisma/client");
|
const client_1 = require("@prisma/client");
|
||||||
const multer_1 = __importDefault(require("multer"));
|
const multer_1 = __importStar(require("multer"));
|
||||||
const path_1 = __importDefault(require("path"));
|
const path_1 = __importDefault(require("path"));
|
||||||
const fs_1 = __importDefault(require("fs"));
|
const fs_1 = __importDefault(require("fs"));
|
||||||
const config_1 = require("../config/config");
|
const config_1 = require("../config/config");
|
||||||
@@ -19,29 +52,68 @@ const getUploadsDir = (subPath) => {
|
|||||||
: legacyUploadsDir;
|
: legacyUploadsDir;
|
||||||
return subPath ? path_1.default.join(baseDir, subPath) : baseDir;
|
return subPath ? path_1.default.join(baseDir, subPath) : baseDir;
|
||||||
};
|
};
|
||||||
|
const loadRecipe = async (req, res, next) => {
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
req.recipeNumber = recipe.recipeNumber;
|
||||||
|
res.locals.recipe = recipe;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const mimeExt = {
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/jpg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/webp': 'webp'
|
||||||
|
};
|
||||||
const storage = multer_1.default.diskStorage({
|
const storage = multer_1.default.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
const recipeNumber = req.body.recipeNumber || req.params.recipeNumber;
|
const recipeNumber = req.recipeNumber || req.body.recipeNumber || req.params.recipeNumber;
|
||||||
if (!recipeNumber) {
|
if (!recipeNumber) {
|
||||||
return cb(new Error('Recipe number is required'), '');
|
return cb(new Error('Recipe number is required'), '');
|
||||||
}
|
}
|
||||||
const uploadDir = getUploadsDir(recipeNumber);
|
const uploadDir = getUploadsDir(recipeNumber);
|
||||||
|
try {
|
||||||
if (!fs_1.default.existsSync(uploadDir)) {
|
if (!fs_1.default.existsSync(uploadDir)) {
|
||||||
fs_1.default.mkdirSync(uploadDir, { recursive: true });
|
fs_1.default.mkdirSync(uploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return cb(new Error('Failed to prepare upload directory'), '');
|
||||||
|
}
|
||||||
cb(null, uploadDir);
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const recipeNumber = req.body.recipeNumber || req.params.recipeNumber;
|
const recipeNumber = req.recipeNumber || req.body.recipeNumber || req.params.recipeNumber;
|
||||||
if (!recipeNumber) {
|
if (!recipeNumber) {
|
||||||
return cb(new Error('Recipe number is required'), '');
|
return cb(new Error('Recipe number is required'), '');
|
||||||
}
|
}
|
||||||
const uploadDir = getUploadsDir(recipeNumber);
|
const uploadDir = getUploadsDir(recipeNumber);
|
||||||
const existingFiles = fs_1.default.existsSync(uploadDir)
|
const existingFiles = fs_1.default.existsSync(uploadDir)
|
||||||
? fs_1.default.readdirSync(uploadDir).filter(f => f.match(new RegExp(`^${recipeNumber}_\\d+\\.jpg$`)))
|
? fs_1.default.readdirSync(uploadDir).filter(f => f.startsWith(`${recipeNumber}_`))
|
||||||
: [];
|
: [];
|
||||||
const nextIndex = existingFiles.length;
|
let maxIndex = -1;
|
||||||
const filename = `${recipeNumber}_${nextIndex}.jpg`;
|
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);
|
cb(null, filename);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -60,41 +132,31 @@ const upload = (0, multer_1.default)({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
router.post('/upload/:recipeId', upload.array('images', 10), async (req, res, next) => {
|
router.post('/upload/:recipeId', loadRecipe, (req, res, next) => {
|
||||||
|
const mw = upload.array('images', 10);
|
||||||
|
mw(req, res, function (err) {
|
||||||
|
if (err) {
|
||||||
|
if (err instanceof multer_1.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, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { recipeId } = req.params;
|
const { recipeId } = req.params;
|
||||||
const files = req.files;
|
const files = req.files;
|
||||||
if (!recipeId) {
|
const recipe = res.locals.recipe;
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Recipe ID is required',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: 'No files uploaded' });
|
||||||
success: false,
|
|
||||||
message: 'No files uploaded',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const recipe = await prisma.recipe.findUnique({
|
const images = await Promise.all(files.map(file => {
|
||||||
where: { id: parseInt(recipeId) }
|
|
||||||
});
|
|
||||||
if (!recipe) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Recipe not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const imagePromises = files.map(file => {
|
|
||||||
const relativePath = `uploads/${recipe.recipeNumber}/${file.filename}`;
|
const relativePath = `uploads/${recipe.recipeNumber}/${file.filename}`;
|
||||||
return prisma.recipeImage.create({
|
return prisma.recipeImage.create({
|
||||||
data: {
|
data: { recipeId: Number(recipeId), filePath: relativePath }
|
||||||
recipeId: parseInt(recipeId),
|
|
||||||
filePath: relativePath,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
}));
|
||||||
const images = await Promise.all(imagePromises);
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: images,
|
data: images,
|
||||||
@@ -104,11 +166,8 @@ router.post('/upload/:recipeId', upload.array('images', 10), async (req, res, ne
|
|||||||
catch (error) {
|
catch (error) {
|
||||||
if (req.files) {
|
if (req.files) {
|
||||||
const files = req.files;
|
const files = req.files;
|
||||||
files.forEach(file => {
|
files.forEach(file => { if (fs_1.default.existsSync(file.path))
|
||||||
if (fs_1.default.existsSync(file.path)) {
|
fs_1.default.unlinkSync(file.path); });
|
||||||
fs_1.default.unlinkSync(file.path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -234,5 +293,71 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
router.post('/reorder/:recipeId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { recipeId } = req.params;
|
||||||
|
const { order } = req.body;
|
||||||
|
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' });
|
||||||
|
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_1.default.existsSync(baseDir)) {
|
||||||
|
return res.status(500).json({ success: false, message: 'Upload directory missing on server' });
|
||||||
|
}
|
||||||
|
const idToImage = new Map(images.map(i => [i.id, i]));
|
||||||
|
const newSequence = order.map((id, idx) => ({ idx, image: idToImage.get(id) }));
|
||||||
|
const tempRenames = [];
|
||||||
|
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}`;
|
||||||
|
const oldFull = path_1.default.join(baseDir, path_1.default.basename(fileName));
|
||||||
|
const tempName = `${recipeNumber}__reorder_${idx}_${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
||||||
|
const tempFull = path_1.default.join(baseDir, tempName);
|
||||||
|
const finalFull = path_1.default.join(baseDir, finalName);
|
||||||
|
if (!fs_1.default.existsSync(oldFull)) {
|
||||||
|
return res.status(500).json({ success: false, message: `File missing on disk: ${fileName}` });
|
||||||
|
}
|
||||||
|
fs_1.default.renameSync(oldFull, tempFull);
|
||||||
|
tempRenames.push({ from: tempFull, to: finalFull, final: `uploads/${recipeNumber}/${finalName}`, idx, ext });
|
||||||
|
}
|
||||||
|
for (const r of tempRenames) {
|
||||||
|
fs_1.default.renameSync(r.from, r.to);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
exports.default = router;
|
exports.default = router;
|
||||||
//# sourceMappingURL=images.js.map
|
//# sourceMappingURL=images.js.map
|
||||||
2
backend/dist/routes/images.js.map
vendored
2
backend/dist/routes/images.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import multer from 'multer';
|
import multer, { MulterError } from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { config } from '../config/config';
|
import { config } from '../config/config';
|
||||||
@@ -21,38 +21,72 @@ const getUploadsDir = (subPath?: string): string => {
|
|||||||
return subPath ? path.join(baseDir, subPath) : baseDir;
|
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({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
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) {
|
if (!recipeNumber) {
|
||||||
return cb(new Error('Recipe number is required'), '');
|
return cb(new Error('Recipe number is required'), '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadDir = getUploadsDir(recipeNumber);
|
const uploadDir = getUploadsDir(recipeNumber);
|
||||||
|
try {
|
||||||
// Create directory if it doesn't exist
|
|
||||||
if (!fs.existsSync(uploadDir)) {
|
if (!fs.existsSync(uploadDir)) {
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return cb(new Error('Failed to prepare upload directory'), '');
|
||||||
|
}
|
||||||
cb(null, uploadDir);
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
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) {
|
if (!recipeNumber) {
|
||||||
return cb(new Error('Recipe number is required'), '');
|
return cb(new Error('Recipe number is required'), '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing files count to determine next index
|
|
||||||
const uploadDir = getUploadsDir(recipeNumber);
|
const uploadDir = getUploadsDir(recipeNumber);
|
||||||
const existingFiles = fs.existsSync(uploadDir)
|
const existingFiles = fs.existsSync(uploadDir)
|
||||||
? fs.readdirSync(uploadDir).filter(f => f.match(new RegExp(`^${recipeNumber}_\\d+\\.jpg$`)))
|
? fs.readdirSync(uploadDir).filter(f => f.startsWith(`${recipeNumber}_`))
|
||||||
: [];
|
: [];
|
||||||
|
// Determine next index by scanning existing indices
|
||||||
const nextIndex = existingFiles.length;
|
let maxIndex = -1;
|
||||||
const filename = `${recipeNumber}_${nextIndex}.jpg`;
|
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);
|
cb(null, filename);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -72,50 +106,34 @@ const upload = multer({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload images for a recipe
|
// Upload images for a recipe (preload recipe middleware before multer)
|
||||||
router.post('/upload/:recipeId', upload.array('images', 10), async (req: Request, res: Response, next: NextFunction) => {
|
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 {
|
try {
|
||||||
const { recipeId } = req.params;
|
const { recipeId } = req.params;
|
||||||
const files = req.files as Express.Multer.File[];
|
const files = req.files as Express.Multer.File[];
|
||||||
|
const recipe = res.locals.recipe;
|
||||||
if (!recipeId) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Recipe ID is required',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: 'No files uploaded' });
|
||||||
success: false,
|
|
||||||
message: 'No files uploaded',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get recipe to validate it exists and get recipe number
|
const images = await Promise.all(files.map(file => {
|
||||||
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 relativePath = `uploads/${recipe.recipeNumber}/${file.filename}`;
|
const relativePath = `uploads/${recipe.recipeNumber}/${file.filename}`;
|
||||||
return prisma.recipeImage.create({
|
return prisma.recipeImage.create({
|
||||||
data: {
|
data: { recipeId: Number(recipeId), filePath: relativePath }
|
||||||
recipeId: parseInt(recipeId),
|
|
||||||
filePath: relativePath,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
}));
|
||||||
|
|
||||||
const images = await Promise.all(imagePromises);
|
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -123,14 +141,9 @@ router.post('/upload/:recipeId', upload.array('images', 10), async (req: Request
|
|||||||
message: `${files.length} images uploaded successfully`,
|
message: `${files.length} images uploaded successfully`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up uploaded files if database operation fails
|
|
||||||
if (req.files) {
|
if (req.files) {
|
||||||
const files = req.files as Express.Multer.File[];
|
const files = req.files as Express.Multer.File[];
|
||||||
files.forEach(file => {
|
files.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); });
|
||||||
if (fs.existsSync(file.path)) {
|
|
||||||
fs.unlinkSync(file.path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
next(error);
|
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;
|
export default router;
|
||||||
@@ -4,8 +4,6 @@ services:
|
|||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
container_name: rezepte_mysql
|
container_name: rezepte_mysql
|
||||||
restart: always
|
restart: always
|
||||||
profiles:
|
|
||||||
- default
|
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change_this_root_password}
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change_this_root_password}
|
||||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-rezepte}
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-rezepte}
|
||||||
@@ -45,12 +43,11 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: rezepte-backend
|
container_name: rezepte-backend
|
||||||
profiles:
|
|
||||||
- default
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: ${NODE_ENV:-production}
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
PORT: ${BACKEND_PORT:-3001}
|
PORT: ${BACKEND_PORT:-3001}
|
||||||
DATABASE_URL: ${DATABASE_URL:-mysql://${MYSQL_USER:-rezepte_user}:${MYSQL_PASSWORD:-change_this_password}@mysql:3306/${MYSQL_DATABASE:-rezepte}}
|
# Explicit DB URL (override to ensure service hostname used inside container)
|
||||||
|
DATABASE_URL: mysql://${MYSQL_USER:-rezepte_user}:${MYSQL_PASSWORD:-dev_password_123}@mysql:3306/${MYSQL_DATABASE:-rezepte}
|
||||||
JWT_SECRET: ${JWT_SECRET:-please_change_to_secure_32_char_min}
|
JWT_SECRET: ${JWT_SECRET:-please_change_to_secure_32_char_min}
|
||||||
UPLOAD_PATH: ${UPLOAD_PATH:-/app/uploads}
|
UPLOAD_PATH: ${UPLOAD_PATH:-/app/uploads}
|
||||||
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-5242880}
|
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-5242880}
|
||||||
@@ -74,8 +71,6 @@ services:
|
|||||||
args:
|
args:
|
||||||
VITE_API_URL: http://localhost:3001/api
|
VITE_API_URL: http://localhost:3001/api
|
||||||
container_name: rezepte-frontend
|
container_name: rezepte-frontend
|
||||||
profiles:
|
|
||||||
- default
|
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:80"
|
- "${FRONTEND_PORT:-3000}:80"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -185,7 +185,8 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||||
background: #f8f9fa;
|
/* Use pure white so transparent PNGs are not auf grauem Untergrund leicht "ausgewaschen" */
|
||||||
|
background: #ffffff;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +476,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 150px;
|
height: 150px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
/* Neutraler Hintergrund für transparente Bereiche */
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-preview img {
|
.image-preview img {
|
||||||
|
|||||||
@@ -102,6 +102,28 @@ const RecipeDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReorder = async (imageId: number, direction: 'up' | 'down') => {
|
||||||
|
if (!recipe || !recipe.images || !id) return;
|
||||||
|
const idx = recipe.images.findIndex(img => img.id === imageId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
const swapWith = direction === 'up' ? idx - 1 : idx + 1;
|
||||||
|
if (swapWith < 0 || swapWith >= recipe.images.length) return;
|
||||||
|
|
||||||
|
const newOrder = [...recipe.images];
|
||||||
|
[newOrder[idx], newOrder[swapWith]] = [newOrder[swapWith], newOrder[idx]];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send ordered IDs
|
||||||
|
await imageApi.reorderImages(parseInt(id), newOrder.map(i => i.id));
|
||||||
|
// Reload recipe to pick up new filenames (indexes changed)
|
||||||
|
const response = await recipeApi.getRecipe(parseInt(id));
|
||||||
|
if (response.success) setRecipe(response.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Reorder failed', e);
|
||||||
|
setError('Fehler beim Neusortieren der Bilder');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="recipe-detail">
|
<div className="recipe-detail">
|
||||||
@@ -143,6 +165,15 @@ const RecipeDetail: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper utilities for image handling
|
||||||
|
const getFileName = (fp: string) => fp.split('/').pop() || '';
|
||||||
|
const isMainImage = (fileName: string) => /_0\.(jpg|jpeg|png|webp)$/i.test(fileName);
|
||||||
|
const isStepImage = (fileName: string) => /_[1-9]\d*\.(jpg|jpeg|png|webp)$/i.test(fileName);
|
||||||
|
const getStepIndex = (fileName: string) => {
|
||||||
|
const m = fileName.match(/_(\d+)\.(jpg|jpeg|png|webp)$/i);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="recipe-detail">
|
<div className="recipe-detail">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -185,11 +216,11 @@ const RecipeDetail: React.FC = () => {
|
|||||||
{recipe.images && recipe.images.length > 0 && (
|
{recipe.images && recipe.images.length > 0 && (
|
||||||
<div className="main-recipe-image">
|
<div className="main-recipe-image">
|
||||||
{(() => {
|
{(() => {
|
||||||
// Find the main image (xxx_0.jpg)
|
// Find the main image *_0.<ext>; if none, fallback to first image
|
||||||
const mainImage = recipe.images.find(image => {
|
let mainImage = recipe.images.find(img => isMainImage(getFileName(img.filePath)));
|
||||||
const fileName = image.filePath.split('/').pop() || '';
|
if (!mainImage && recipe.images.length > 0) {
|
||||||
return fileName.includes('_0.jpg');
|
mainImage = recipe.images[0];
|
||||||
});
|
}
|
||||||
|
|
||||||
if (mainImage) {
|
if (mainImage) {
|
||||||
return (
|
return (
|
||||||
@@ -255,6 +286,22 @@ const RecipeDetail: React.FC = () => {
|
|||||||
src={imageApi.getImageUrl(image.filePath)}
|
src={imageApi.getImageUrl(image.filePath)}
|
||||||
alt={`Bild ${index + 1}`}
|
alt={`Bild ${index + 1}`}
|
||||||
/>
|
/>
|
||||||
|
<div className="image-actions-inline" style={{ position: 'absolute', top: 4, left: 4, display: 'flex', gap: '4px' }}>
|
||||||
|
<button
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => handleReorder(image.id, 'up')}
|
||||||
|
title="Nach oben"
|
||||||
|
style={{ opacity: index === 0 ? 0.4 : 1 }}
|
||||||
|
className="reorder-btn"
|
||||||
|
>▲</button>
|
||||||
|
<button
|
||||||
|
disabled={index === recipe.images!.length - 1}
|
||||||
|
onClick={() => handleReorder(image.id, 'down')}
|
||||||
|
title="Nach unten"
|
||||||
|
style={{ opacity: index === recipe.images!.length - 1 ? 0.4 : 1 }}
|
||||||
|
className="reorder-btn"
|
||||||
|
>▼</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="delete-image-btn"
|
className="delete-image-btn"
|
||||||
onClick={() => handleImageDelete(image.id)}
|
onClick={() => handleImageDelete(image.id)}
|
||||||
@@ -267,7 +314,7 @@ const RecipeDetail: React.FC = () => {
|
|||||||
<span className="image-name">
|
<span className="image-name">
|
||||||
{image.filePath.split('/').pop()}
|
{image.filePath.split('/').pop()}
|
||||||
</span>
|
</span>
|
||||||
{image.filePath.includes('_0.jpg') && (
|
{isMainImage(getFileName(image.filePath)) && (
|
||||||
<span className="main-image-badge">Hauptbild</span>
|
<span className="main-image-badge">Hauptbild</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -330,19 +377,11 @@ const RecipeDetail: React.FC = () => {
|
|||||||
{(() => {
|
{(() => {
|
||||||
// Get all preparation images (exclude main image _0.jpg)
|
// Get all preparation images (exclude main image _0.jpg)
|
||||||
const preparationImages = recipe.images
|
const preparationImages = recipe.images
|
||||||
?.filter(image => {
|
?.filter(img => {
|
||||||
const fileName = image.filePath.split('/').pop() || '';
|
const fn = getFileName(img.filePath);
|
||||||
// Match pattern like R005_1.jpg, R005_2.jpg, etc. but not R005_0.jpg
|
return isStepImage(fn);
|
||||||
return fileName.match(/_[1-9]\d*\.jpg$/);
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => getStepIndex(getFileName(a.filePath)) - getStepIndex(getFileName(b.filePath))) || [];
|
||||||
// Sort by the number in the filename
|
|
||||||
const getNumber = (path: string) => {
|
|
||||||
const match = path.match(/_(\d+)\.jpg$/);
|
|
||||||
return match ? parseInt(match[1]) : 0;
|
|
||||||
};
|
|
||||||
return getNumber(a.filePath) - getNumber(b.filePath);
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
return recipe.instructions.split('\n').map((instruction, index) => {
|
return recipe.instructions.split('\n').map((instruction, index) => {
|
||||||
// Get the corresponding image for this step
|
// Get the corresponding image for this step
|
||||||
|
|||||||
@@ -178,6 +178,12 @@ export const imageApi = {
|
|||||||
const response = await api.delete(`/images/${imageId}`);
|
const response = await api.delete(`/images/${imageId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Reorder images for a recipe
|
||||||
|
reorderImages: async (recipeId: number, orderedIds: number[]): Promise<ApiResponse<RecipeImage[]>> => {
|
||||||
|
const response = await api.post(`/images/reorder/${recipeId}`, { order: orderedIds });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
|
|||||||
BIN
uploads/R100/R100_0_container.png
Normal file
BIN
uploads/R100/R100_0_container.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
Reference in New Issue
Block a user