feat: Implement a web gallery interface with image thumbnail generation and new API endpoints.

This commit is contained in:
sebseb7
2025-12-18 15:35:17 +01:00
parent 6b0381aded
commit 253612bb3b
5 changed files with 1137 additions and 12 deletions

135
server.js
View File

@@ -3,6 +3,7 @@ import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import sharp from 'sharp';
import { validateApiKey, loadUsers } from './users.js';
const __filename = fileURLToPath(import.meta.url);
@@ -106,21 +107,46 @@ app.get('/health', (req, res) => {
});
// Upload endpoint
app.post('/upload', authenticate, upload.single('image'), (req, res) => {
app.post('/upload', authenticate, upload.single('image'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No image file provided. Use form field "image".' });
}
const cameraId = req.body.cameraId || req.query.cameraId || 'default';
res.json({
success: true,
cameraId,
filename: req.file.filename,
path: req.file.path,
size: req.file.size,
timestamp: new Date().toISOString()
});
// Generate thumbnail
try {
const thumbnailFilename = req.file.filename.replace(path.extname(req.file.filename), '_thumb.avif');
const thumbnailPath = path.join(path.dirname(req.file.path), thumbnailFilename);
await sharp(req.file.path)
.resize(320) // Resize to 320px width, auto height
.toFormat('avif')
.toFile(thumbnailPath);
res.json({
success: true,
cameraId,
filename: req.file.filename,
path: req.file.path,
thumbnail: thumbnailFilename,
thumbnailPath: thumbnailPath,
size: req.file.size,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Thumbnail generation failed:', error);
// Still return success for the upload even if thumbnail fails
res.json({
success: true,
cameraId,
filename: req.file.filename,
path: req.file.path,
thumbnailError: error.message,
size: req.file.size,
timestamp: new Date().toISOString()
});
}
});
// List cameras (directories in upload folder)
@@ -267,6 +293,97 @@ app.put('/settings/:cameraId', authenticate, express.json(), (req, res) => {
}
});
// === Web Interface Endpoints ===
// Serve static files (frontend)
app.use(express.static(path.join(__dirname, 'public')));
// Serve uploads (images)
app.use('/uploads', express.static(UPLOAD_DIR));
// Get available dates for a camera
app.get('/cameras/:cameraId/dates', async (req, res) => {
const { cameraId } = req.params;
const cameraPath = path.join(UPLOAD_DIR, cameraId);
if (!fs.existsSync(cameraPath)) {
return res.json({ dates: [] });
}
try {
const dates = [];
const years = fs.readdirSync(cameraPath).filter(name => /^\d{4}$/.test(name));
for (const year of years) {
const yearPath = path.join(cameraPath, year);
const months = fs.readdirSync(yearPath).filter(name => /^\d{2}$/.test(name));
for (const month of months) {
const monthPath = path.join(yearPath, month);
const days = fs.readdirSync(monthPath).filter(name => /^\d{2}$/.test(name));
for (const day of days) {
dates.push(`${year}-${month}-${day}`);
}
}
}
// Sort newest first
dates.sort((a, b) => b.localeCompare(a));
res.json({ dates });
} catch (error) {
res.status(500).json({ error: 'Failed to list dates', details: error.message });
}
});
// Get images for a specific date
app.get('/cameras/:cameraId/:year/:month/:day', async (req, res) => {
const { cameraId, year, month, day } = req.params;
const datePath = path.join(UPLOAD_DIR, cameraId, year, month, day);
if (!fs.existsSync(datePath)) {
return res.status(404).json({ error: 'Date not found' });
}
try {
const files = fs.readdirSync(datePath);
const images = [];
// Group files by timestamp (original and thumbnail)
// Expected format: {cameraId}_{timestamp}.jpg and {cameraId}_{timestamp}_thumb.avif
for (const file of files) {
// Skip existing thumbnails from the list loop to avoid duplicates
// We'll attach them to the main image entry
if (file.includes('_thumb.avif')) continue;
// Only process image files
if (!/\.(jpg|jpeg|png|webp|gif)$/i.test(file)) continue;
const thumbName = file.replace(path.extname(file), '_thumb.avif');
const hasThumb = files.includes(thumbName);
images.push({
filename: file,
url: `uploads/${cameraId}/${year}/${month}/${day}/${file}`,
thumbnailUrl: hasThumb
? `uploads/${cameraId}/${year}/${month}/${day}/${thumbName}`
: `uploads/${cameraId}/${year}/${month}/${day}/${file}`, // Fallback to original
timestamp: file.split('_')[1]?.replace(path.extname(file), '') || ''
});
}
// Sort by filename (timestamp) descending
images.sort((a, b) => b.filename.localeCompare(a.filename));
res.json({ images });
} catch (error) {
res.status(500).json({ error: 'Failed to list images', details: error.message });
}
});
// Error handling middleware
app.use((error, req, res, next) => {
if (error instanceof multer.MulterError) {