photos phase 3
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
This commit is contained in:
25
.gitea/workflows/docker-build.yml
Normal file
25
.gitea/workflows/docker-build.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Build and Push Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.alwisp.com
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
run: |
|
||||
docker build -t registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest .
|
||||
docker push registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest
|
||||
@@ -1,3 +1,471 @@
|
||||
export default function PhotosPage() {
|
||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Photo Slideshow</h1><p className="text-secondary mt-2">Phase 3</p></div>;
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Image, Upload, Trash2, X, ChevronLeft, ChevronRight,
|
||||
Settings, CloudUpload, CheckCircle2, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
interface Photo {
|
||||
name: string;
|
||||
rel: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PhotosResponse {
|
||||
configured: boolean;
|
||||
count: number;
|
||||
photos: Photo[];
|
||||
}
|
||||
|
||||
interface UploadStatus {
|
||||
file: File;
|
||||
state: 'pending' | 'done' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────
|
||||
export default function PhotosPage() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploadQueue, setUploadQueue] = useState<UploadStatus[]>([]);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Photo | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Data ────────────────────────────────────────────────────────────────
|
||||
const { data, isLoading } = useQuery<PhotosResponse>({
|
||||
queryKey: ['photos'],
|
||||
queryFn: () => api.get('/photos').then((r) => r.data),
|
||||
});
|
||||
|
||||
const photos = data?.photos ?? [];
|
||||
const configured = data?.configured ?? true; // optimistic until loaded
|
||||
|
||||
// ── Upload ──────────────────────────────────────────────────────────────
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (files: File[]) => {
|
||||
const statuses: UploadStatus[] = files.map((f) => ({ file: f, state: 'pending' }));
|
||||
setUploadQueue(statuses);
|
||||
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append('photos', f));
|
||||
|
||||
try {
|
||||
const res = await api.post('/photos/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
setUploadQueue(statuses.map((s) => ({ ...s, state: 'done' })));
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error ?? 'Upload failed';
|
||||
setUploadQueue(statuses.map((s) => ({ ...s, state: 'error', message: msg })));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (photo: Photo) =>
|
||||
api.delete(`/photos/file/${encodeURIComponent(photo.rel)}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
if (lightboxIndex !== null && lightboxIndex >= photos.length - 1) {
|
||||
setLightboxIndex(null);
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Drag & drop ─────────────────────────────────────────────────────────
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (files.length) triggerUpload(files);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length) triggerUpload(files);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const triggerUpload = (files: File[]) => {
|
||||
setUploadOpen(true);
|
||||
uploadMutation.mutate(files);
|
||||
};
|
||||
|
||||
// ── Lightbox ─────────────────────────────────────────────────────────────
|
||||
const prevPhoto = () =>
|
||||
setLightboxIndex((i) => (i === null ? null : (i - 1 + photos.length) % photos.length));
|
||||
const nextPhoto = () =>
|
||||
setLightboxIndex((i) => (i === null ? null : (i + 1) % photos.length));
|
||||
|
||||
const handleLightboxKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') prevPhoto();
|
||||
if (e.key === 'ArrowRight') nextPhoto();
|
||||
if (e.key === 'Escape') setLightboxIndex(null);
|
||||
};
|
||||
|
||||
// ── Upload summary counts ─────────────────────────────────────────────────
|
||||
const doneCount = uploadQueue.filter((s) => s.state === 'done').length;
|
||||
const errorCount = uploadQueue.filter((s) => s.state === 'error').length;
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full overflow-hidden relative"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{/* Drag overlay */}
|
||||
<AnimatePresence>
|
||||
{isDragging && (
|
||||
<motion.div
|
||||
className="absolute inset-0 z-40 flex items-center justify-center bg-accent/20 border-4 border-dashed border-accent backdrop-blur-sm pointer-events-none"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<CloudUpload size={48} className="mx-auto mb-3 text-accent" />
|
||||
<p className="text-xl font-bold text-accent">Drop photos to upload</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── Header ───────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image size={22} className="text-accent" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-primary leading-tight">Photos</h1>
|
||||
{data?.configured && (
|
||||
<p className="text-xs text-muted">
|
||||
{data.count} photo{data.count !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{configured && (
|
||||
<Button onClick={() => fileInputRef.current?.click()} size="sm">
|
||||
<Upload size={15} /> Upload Photos
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
|
||||
{/* ── Upload progress banner ────────────────────────────────────────── */}
|
||||
<AnimatePresence>
|
||||
{uploadOpen && uploadQueue.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden border-b border-theme bg-surface-raised shrink-0"
|
||||
>
|
||||
<div className="px-6 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{uploadMutation.isPending ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4 text-accent shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
<span className="text-sm text-primary">
|
||||
Uploading {uploadQueue.length} photo{uploadQueue.length !== 1 ? 's' : ''}…
|
||||
</span>
|
||||
</>
|
||||
) : errorCount > 0 ? (
|
||||
<>
|
||||
<AlertCircle size={16} className="text-red-500 shrink-0" />
|
||||
<span className="text-sm text-red-500">
|
||||
{errorCount} upload{errorCount !== 1 ? 's' : ''} failed
|
||||
{doneCount > 0 && ` · ${doneCount} succeeded`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 size={16} className="text-green-500 shrink-0" />
|
||||
<span className="text-sm text-primary">
|
||||
{doneCount} photo{doneCount !== 1 ? 's' : ''} uploaded successfully
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setUploadOpen(false); setUploadQueue([]); }}
|
||||
className="p-1 rounded-lg text-muted hover:text-primary hover:bg-surface-raised shrink-0"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Per-file list — shown while pending or on error */}
|
||||
{(uploadMutation.isPending || errorCount > 0) && (
|
||||
<ul className="px-6 pb-3 space-y-1 max-h-32 overflow-y-auto">
|
||||
{uploadQueue.map((s, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-xs text-secondary">
|
||||
{s.state === 'pending' && (
|
||||
<svg className="animate-spin h-3 w-3 text-accent shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
)}
|
||||
{s.state === 'done' && <CheckCircle2 size={12} className="text-green-500 shrink-0" />}
|
||||
{s.state === 'error' && <AlertCircle size={12} className="text-red-500 shrink-0" />}
|
||||
<span className="truncate">{s.file.name}</span>
|
||||
<span className="text-muted shrink-0">({formatBytes(s.file.size)})</span>
|
||||
{s.message && <span className="text-red-500 truncate">{s.message}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── Content ───────────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 p-6">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square rounded-xl bg-surface-raised animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : !data?.configured ? (
|
||||
/* ── Not configured ─────────────────────────────────────────── */
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<div className="text-5xl mb-4">📂</div>
|
||||
<p className="text-lg font-semibold text-primary mb-1">Photo folder not configured</p>
|
||||
<p className="text-secondary text-sm mb-6 max-w-sm">
|
||||
Set a photo folder path in Settings, then come back to upload and manage your photos.
|
||||
</p>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-raised border border-theme text-sm font-medium text-primary hover:bg-accent-light hover:text-accent transition-colors"
|
||||
>
|
||||
<Settings size={15} /> Go to Settings
|
||||
</Link>
|
||||
</div>
|
||||
) : photos.length === 0 ? (
|
||||
/* ── Empty — drop zone ──────────────────────────────────────── */
|
||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-col items-center gap-4 p-10 rounded-2xl border-2 border-dashed border-theme hover:border-accent hover:bg-accent/5 transition-all cursor-pointer group"
|
||||
>
|
||||
<CloudUpload size={48} className="text-muted group-hover:text-accent transition-colors" />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-primary mb-1">Upload your first photos</p>
|
||||
<p className="text-secondary text-sm">
|
||||
Click to browse, or drag & drop images anywhere on this page.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted">
|
||||
Supports JPG, PNG, WEBP, GIF, AVIF · Up to 50 MB per file
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Photo grid ─────────────────────────────────────────────── */
|
||||
<div className="p-6">
|
||||
<p className="text-xs text-muted mb-4">
|
||||
Drag & drop images anywhere on this page, or use the Upload button to add more photos.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
<AnimatePresence initial={false}>
|
||||
{photos.map((photo, index) => (
|
||||
<motion.div
|
||||
key={photo.rel}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="group relative aspect-square rounded-xl overflow-hidden bg-surface-raised cursor-pointer shadow-sm"
|
||||
onClick={() => setLightboxIndex(index)}
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.name}
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors duration-200" />
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTarget(photo); }}
|
||||
className={clsx(
|
||||
'absolute top-2 right-2 p-1.5 rounded-full bg-black/60 text-white',
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity duration-150',
|
||||
'hover:bg-red-500'
|
||||
)}
|
||||
aria-label="Delete photo"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
{/* Filename on hover */}
|
||||
<div className={clsx(
|
||||
'absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-black/60 backdrop-blur-sm',
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity duration-150'
|
||||
)}>
|
||||
<p className="text-white text-xs truncate">{photo.name}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Lightbox ──────────────────────────────────────────────────────── */}
|
||||
<AnimatePresence>
|
||||
{lightboxIndex !== null && photos[lightboxIndex] && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setLightboxIndex(null)}
|
||||
onKeyDown={handleLightboxKey}
|
||||
tabIndex={-1}
|
||||
ref={(el) => el?.focus()}
|
||||
>
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={() => setLightboxIndex(null)}
|
||||
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* Counter */}
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-white/10 text-white text-sm select-none">
|
||||
{lightboxIndex + 1} / {photos.length}
|
||||
</div>
|
||||
|
||||
{/* Prev */}
|
||||
{photos.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); prevPhoto(); }}
|
||||
className="absolute left-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<motion.img
|
||||
key={photos[lightboxIndex].rel}
|
||||
src={photos[lightboxIndex].url}
|
||||
alt={photos[lightboxIndex].name}
|
||||
className="max-h-[90vh] max-w-[90vw] object-contain rounded-xl shadow-2xl"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* Next */}
|
||||
{photos.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); nextPhoto(); }}
|
||||
className="absolute right-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Filename */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-white/10 text-white text-sm select-none">
|
||||
{photos[lightboxIndex].name}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── Delete confirmation ────────────────────────────────────────────── */}
|
||||
<AnimatePresence>
|
||||
{deleteTarget && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-surface rounded-2xl border border-theme shadow-xl p-6 max-w-sm w-full"
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-base font-semibold text-primary mb-1">Delete photo?</h2>
|
||||
<p className="text-sm text-secondary mb-4">
|
||||
<span className="font-medium text-primary">{deleteTarget.name}</span> will be permanently
|
||||
deleted from your photo folder. This cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(deleteTarget)}
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import db from '../db/db';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import multer from 'multer';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -24,38 +25,133 @@ function scanDir(dir: string): string[] {
|
||||
}
|
||||
|
||||
// PHOTOS_DIR env var (set in Docker) overrides the DB setting.
|
||||
// This lets Unraid users bind-mount their photo library to /photos
|
||||
// without having to change the settings in the UI.
|
||||
function resolvePhotoFolder(): string {
|
||||
if (process.env.PHOTOS_DIR) return process.env.PHOTOS_DIR;
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'photo_folder'").get() as any;
|
||||
return row?.value ?? '';
|
||||
}
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
const folder = resolvePhotoFolder();
|
||||
const files = scanDir(folder);
|
||||
res.json({ folder, count: files.length, files: files.map((f) => path.basename(f)) });
|
||||
// Resolve and validate a relative path inside the photo folder.
|
||||
// rawRel may be percent-encoded (e.g. %2F for subdirectory slashes).
|
||||
// Returns the absolute filepath or null if it would escape the folder.
|
||||
function resolveFilePath(folder: string, rawRel: string): string | null {
|
||||
let rel: string;
|
||||
try { rel = decodeURIComponent(rawRel); } catch { rel = rawRel; }
|
||||
rel = rel.replace(/\\/g, '/');
|
||||
// Reject traversal attempts
|
||||
if (rel.split('/').some((p) => p === '..')) return null;
|
||||
const filepath = path.normalize(path.join(folder, rel));
|
||||
const base = path.normalize(folder);
|
||||
if (!filepath.startsWith(base + path.sep) && filepath !== base) return null;
|
||||
return filepath;
|
||||
}
|
||||
|
||||
// Multer storage — saves directly into the photo folder root
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const folder = resolvePhotoFolder();
|
||||
if (!folder) return cb(new Error('Photo folder not configured'), '');
|
||||
try { fs.mkdirSync(folder, { recursive: true }); } catch { /* already exists */ }
|
||||
cb(null, folder);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const base = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
cb(null, `${base}_${Date.now()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB per file
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (IMAGE_EXTS.has(path.extname(file.originalname).toLowerCase())) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`Unsupported file type: ${path.extname(file.originalname) || file.originalname}`));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
router.get('/file/:filename', (req, res) => {
|
||||
// ── List all photos (recursive) ───────────────────────────────────────────
|
||||
router.get('/', (_req, res) => {
|
||||
const folder = resolvePhotoFolder();
|
||||
const configured = !!folder;
|
||||
if (!configured) return res.json({ configured: false, count: 0, photos: [] });
|
||||
|
||||
const norm = path.normalize(folder);
|
||||
const files = scanDir(folder);
|
||||
const photos = files.map((f) => {
|
||||
const rel = path.relative(norm, f).replace(/\\/g, '/');
|
||||
return { name: path.basename(f), rel, url: `/api/photos/file/${encodeURIComponent(rel)}` };
|
||||
});
|
||||
res.json({ configured: true, folder, count: photos.length, photos });
|
||||
});
|
||||
|
||||
// ── Serve a photo by encoded relative path ────────────────────────────────
|
||||
// Express 4 wildcard: req.params[0] captures everything after /file/
|
||||
router.get('/file/*', (req, res) => {
|
||||
const folder = resolvePhotoFolder();
|
||||
if (!folder) return res.status(404).json({ error: 'Photo folder not configured' });
|
||||
|
||||
// Security: prevent path traversal
|
||||
const filename = path.basename(req.params.filename);
|
||||
const filepath = path.join(folder, filename);
|
||||
if (!filepath.startsWith(folder)) return res.status(403).json({ error: 'Forbidden' });
|
||||
const filepath = resolveFilePath(folder, (req.params as any)[0] ?? '');
|
||||
if (!filepath) return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'File not found' });
|
||||
res.sendFile(filepath);
|
||||
});
|
||||
|
||||
// Return all photos as a flat list with their relative paths for the slideshow
|
||||
// ── Slideshow list ────────────────────────────────────────────────────────
|
||||
router.get('/slideshow', (_req, res) => {
|
||||
const folder = resolvePhotoFolder();
|
||||
if (!folder) return res.json({ count: 0, photos: [] });
|
||||
|
||||
const norm = path.normalize(folder);
|
||||
const files = scanDir(folder);
|
||||
const urls = files.map((f) => `/api/photos/file/${encodeURIComponent(path.relative(folder, f).replace(/\\/g, '/'))}`);
|
||||
res.json({ count: urls.length, urls });
|
||||
const photos = files.map((f) => {
|
||||
const rel = path.relative(norm, f).replace(/\\/g, '/');
|
||||
return { name: path.basename(f), rel, url: `/api/photos/file/${encodeURIComponent(rel)}` };
|
||||
});
|
||||
res.json({ count: photos.length, photos });
|
||||
});
|
||||
|
||||
// ── Batch upload ──────────────────────────────────────────────────────────
|
||||
router.post('/upload', (req, res) => {
|
||||
const folder = resolvePhotoFolder();
|
||||
if (!folder) {
|
||||
return res.status(400).json({ error: 'Photo folder not configured. Set it in Settings first.' });
|
||||
}
|
||||
|
||||
upload.array('photos', 200)(req, res, (err) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
||||
}
|
||||
if (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
const norm = path.normalize(folder);
|
||||
const files = (req.files as Express.Multer.File[]) ?? [];
|
||||
const uploaded = files.map((f) => {
|
||||
const rel = path.relative(norm, f.path).replace(/\\/g, '/');
|
||||
return { name: f.filename, rel, url: `/api/photos/file/${encodeURIComponent(rel)}` };
|
||||
});
|
||||
res.status(201).json({ count: uploaded.length, uploaded });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete a photo ────────────────────────────────────────────────────────
|
||||
router.delete('/file/*', (req, res) => {
|
||||
const folder = resolvePhotoFolder();
|
||||
if (!folder) return res.status(404).json({ error: 'Photo folder not configured' });
|
||||
|
||||
const filepath = resolveFilePath(folder, (req.params as any)[0] ?? '');
|
||||
if (!filepath) return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filepath);
|
||||
res.status(204).end();
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to delete file' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user