feat: Implement a web gallery interface with image thumbnail generation and new API endpoints.
This commit is contained in:
135
server.js
135
server.js
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user