photos phase 3
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s

This commit is contained in:
jason
2026-03-30 10:27:50 -05:00
parent 155e7849e1
commit 5431177b7a
3 changed files with 605 additions and 16 deletions

View 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

View File

@@ -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 &amp; 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 &amp; 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>
);
}

View File

@@ -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;