diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml new file mode 100644 index 0000000..3d499eb --- /dev/null +++ b/.gitea/workflows/docker-build.yml @@ -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 \ No newline at end of file diff --git a/apps/client/src/pages/Photos.tsx b/apps/client/src/pages/Photos.tsx index 30e144d..7c4e8e6 100644 --- a/apps/client/src/pages/Photos.tsx +++ b/apps/client/src/pages/Photos.tsx @@ -1,3 +1,471 @@ -export default function PhotosPage() { - return

Photo Slideshow

Phase 3

; +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(null); + const [isDragging, setIsDragging] = useState(false); + const [uploadQueue, setUploadQueue] = useState([]); + const [uploadOpen, setUploadOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const fileInputRef = useRef(null); + + // ── Data ──────────────────────────────────────────────────────────────── + const { data, isLoading } = useQuery({ + 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) => { + 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 ( +
+ {/* Drag overlay */} + + {isDragging && ( + +
+ +

Drop photos to upload

+
+
+ )} +
+ + {/* ── Header ───────────────────────────────────────────────────────── */} +
+
+ +
+

Photos

+ {data?.configured && ( +

+ {data.count} photo{data.count !== 1 ? 's' : ''} +

+ )} +
+
+ {configured && ( + + )} +
+ + {/* Hidden file input */} + + + {/* ── Upload progress banner ────────────────────────────────────────── */} + + {uploadOpen && uploadQueue.length > 0 && ( + +
+
+ {uploadMutation.isPending ? ( + <> + + + + + + Uploading {uploadQueue.length} photo{uploadQueue.length !== 1 ? 's' : ''}… + + + ) : errorCount > 0 ? ( + <> + + + {errorCount} upload{errorCount !== 1 ? 's' : ''} failed + {doneCount > 0 && ` · ${doneCount} succeeded`} + + + ) : ( + <> + + + {doneCount} photo{doneCount !== 1 ? 's' : ''} uploaded successfully + + + )} +
+ +
+ + {/* Per-file list — shown while pending or on error */} + {(uploadMutation.isPending || errorCount > 0) && ( +
    + {uploadQueue.map((s, i) => ( +
  • + {s.state === 'pending' && ( + + + + + )} + {s.state === 'done' && } + {s.state === 'error' && } + {s.file.name} + ({formatBytes(s.file.size)}) + {s.message && {s.message}} +
  • + ))} +
+ )} +
+ )} +
+ + {/* ── Content ───────────────────────────────────────────────────────── */} +
+ {isLoading ? ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+ ) : !data?.configured ? ( + /* ── Not configured ─────────────────────────────────────────── */ +
+
📂
+

Photo folder not configured

+

+ Set a photo folder path in Settings, then come back to upload and manage your photos. +

+ + Go to Settings + +
+ ) : photos.length === 0 ? ( + /* ── Empty — drop zone ──────────────────────────────────────── */ +
+ +
+ ) : ( + /* ── Photo grid ─────────────────────────────────────────────── */ +
+

+ Drag & drop images anywhere on this page, or use the Upload button to add more photos. +

+
+ + {photos.map((photo, index) => ( + setLightboxIndex(index)} + > + {photo.name} + {/* Hover overlay */} +
+ {/* Delete button */} + + {/* Filename on hover */} +
+

{photo.name}

+
+ + ))} + +
+
+ )} +
+ + {/* ── Lightbox ──────────────────────────────────────────────────────── */} + + {lightboxIndex !== null && photos[lightboxIndex] && ( + setLightboxIndex(null)} + onKeyDown={handleLightboxKey} + tabIndex={-1} + ref={(el) => el?.focus()} + > + {/* Close */} + + + {/* Counter */} +
+ {lightboxIndex + 1} / {photos.length} +
+ + {/* Prev */} + {photos.length > 1 && ( + + )} + + {/* Image */} + e.stopPropagation()} + /> + + {/* Next */} + {photos.length > 1 && ( + + )} + + {/* Filename */} +
+ {photos[lightboxIndex].name} +
+
+ )} +
+ + {/* ── Delete confirmation ────────────────────────────────────────────── */} + + {deleteTarget && ( + setDeleteTarget(null)} + > + e.stopPropagation()} + > +

Delete photo?

+

+ {deleteTarget.name} will be permanently + deleted from your photo folder. This cannot be undone. +

+
+ + +
+
+
+ )} +
+
+ ); } diff --git a/apps/server/src/routes/photos.ts b/apps/server/src/routes/photos.ts index 3b5d2f0..85cf81c 100644 --- a/apps/server/src/routes/photos.ts +++ b/apps/server/src/routes/photos.ts @@ -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;