Feature: Add bulk upload for gallery photos and database backup script

- New bulk upload page at /admin/gallery/bulk for uploading multiple photos at once
- File preview list with ability to remove files before uploading
- Category assignment for all uploaded photos in bulk
- Accessible from both gallery page and single photo upload page
- Added PostgreSQL backup script for daily database snapshots
- Backups kept as local SQL dumps with automatic cleanup of old files

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-19 15:06:28 +01:00
co-authored by Claude Haiku 4.5
parent d1f09f91f0
commit 3fe38a7c2f
6 changed files with 257 additions and 10 deletions
+37
View File
@@ -0,0 +1,37 @@
# PostgreSQL Database Backup Script
# Backs up the craft2prints database daily
$backupDir = "D:\Craft2Prints\database-backups"
$pgBin = "C:\Program Files\PostgreSQL\18\bin"
$dbName = "craft2prints"
$dbUser = "postgres"
$dbHost = "localhost"
$dbPort = "5432"
# Create backup directory if it doesn't exist
if (!(Test-Path $backupDir)) {
New-Item -ItemType Directory -Path $backupDir | Out-Null
}
# Create timestamped backup filename
$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
$backupFile = "$backupDir\craft2prints_$timestamp.sql"
# Run pg_dump
Write-Host "Backing up database to $backupFile..."
$env:PGPASSWORD = "postgres"
& "$pgBin\pg_dump.exe" -h $dbHost -p $dbPort -U $dbUser -d $dbName -F p -f $backupFile
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
Write-Host "Backup completed successfully"
# Keep only the last 7 backups
$backups = Get-ChildItem $backupDir -Filter "craft2prints_*.sql" | Sort-Object CreationTime -Descending
if ($backups.Count -gt 7) {
$backups | Select-Object -Skip 7 | Remove-Item
Write-Host "Cleaned up old backups (kept 7 most recent)"
}
} else {
Write-Host "Backup failed with exit code $exitCode"
}
+30
View File
@@ -81,3 +81,33 @@ export async function deleteGalleryCategory(formData: FormData) {
await prisma.galleryCategory.delete({ where: { id } });
redirect('/admin/gallery/categories');
}
export async function uploadGalleryPhotoBulk(formData: FormData) {
const categoryId = String(formData.get('categoryId') ?? '').trim() || null;
const files = formData.getAll('images') as File[];
if (!files || files.length === 0) {
throw new Error('At least one photo is required.');
}
const uploaded = [];
for (const file of files) {
if (file.size === 0) continue;
try {
const imageDataUrl = await fileToDataUrl(file);
const photo = await prisma.galleryPhoto.create({
data: {
imageDataUrl,
caption: null,
categoryId,
},
});
uploaded.push(photo.id);
} catch (err) {
console.error(`Failed to upload ${file.name}:`, err);
}
}
redirect(`/admin/gallery?uploaded=${uploaded.length}`);
}
+140
View File
@@ -0,0 +1,140 @@
'use client';
import { useState, useRef } from 'react';
import Link from 'next/link';
import { uploadGalleryPhotoBulk } from '../actions';
interface Category {
id: string;
name: string;
}
export default function BulkUploadForm({ categories }: { categories: Category[] }) {
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
setFiles(selectedFiles);
};
const handleRemoveFile = (index: number) => {
setFiles(files.filter((_, i) => i !== index));
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (files.length === 0) {
alert('Please select at least one file');
return;
}
setUploading(true);
const formData = new FormData();
files.forEach((file) => {
formData.append('images', file);
});
const categorySelect = formRef.current?.querySelector('select[name="categoryId"]') as HTMLSelectElement;
if (categorySelect?.value) {
formData.append('categoryId', categorySelect.value);
}
try {
await uploadGalleryPhotoBulk(formData);
} catch (err) {
alert(`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
setUploading(false);
}
};
return (
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
{/* File input */}
<div>
<label htmlFor="images" className="tag-label mb-2 block">
Photos (select multiple)
</label>
<input
ref={fileInputRef}
id="images"
name="images"
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
You can select multiple files at once. Supported formats: JPG, PNG, GIF, WebP
</p>
</div>
{/* File list */}
{files.length > 0 && (
<div>
<p className="tag-label mb-2 block">Selected files ({files.length})</p>
<div className="space-y-2 max-h-64 overflow-y-auto border border-line rounded bg-surface p-4">
{files.map((file, index) => (
<div key={`${file.name}-${index}`} className="flex items-center justify-between text-sm">
<span className="text-ink truncate">{file.name}</span>
<button
type="button"
onClick={() => handleRemoveFile(index)}
className="text-xs text-splash-pink hover:text-splash-pink-dark whitespace-nowrap ml-2"
>
Remove
</button>
</div>
))}
</div>
</div>
)}
{/* Category */}
<div>
<label htmlFor="categoryId" className="tag-label mb-2 block">
Category (optional)
</label>
<select
id="categoryId"
name="categoryId"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
<option value=""> Uncategorized </option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<a href="/admin/gallery/categories/new?redirectTo=/admin/gallery/bulk" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
+ Add a new category
</a>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
type="submit"
disabled={uploading || files.length === 0}
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? 'Uploading...' : `Upload ${files.length} photo${files.length !== 1 ? 's' : ''}`}
</button>
<Link
href="/admin/gallery"
className="border border-line px-6 py-3 text-sm font-medium text-ink hover:border-clay hover:text-clay"
>
Cancel
</Link>
</div>
</form>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import BulkUploadForm from './form';
export default async function BulkGalleryUploadPage() {
const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } });
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="mb-8">
<h1 className="font-display text-3xl">Bulk upload photos</h1>
<p className="mt-2 text-sm text-muted">
Upload multiple gallery photos at once. Captions can be added individually later.
</p>
</div>
<BulkUploadForm categories={categories} />
</div>
);
}
+15 -4
View File
@@ -1,3 +1,4 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { uploadGalleryPhoto } from '../actions';
@@ -8,10 +9,20 @@ export default async function NewGalleryPhotoPage() {
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Add a photo</h1>
<p className="mt-2 text-sm text-muted">
Upload a photo of completed work to show on the public gallery.
</p>
<div className="flex items-baseline justify-between">
<div>
<h1 className="font-display text-3xl">Add a photo</h1>
<p className="mt-2 text-sm text-muted">
Upload a photo of completed work to show on the public gallery.
</p>
</div>
<Link
href="/admin/gallery/bulk"
className="shrink-0 border border-line px-3 py-2 text-xs text-clay hover:text-clay-dark"
>
Bulk upload
</Link>
</div>
<form action={uploadGalleryPhoto} className="mt-10 space-y-6">
<div>
+14 -6
View File
@@ -14,12 +14,20 @@ export default async function AdminGalleryPage() {
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Gallery</h1>
<Link
href="/admin/gallery/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add photo
</Link>
<div className="flex gap-2">
<Link
href="/admin/gallery/bulk"
className="border border-line px-4 py-2 text-sm hover:border-clay hover:text-clay"
>
Bulk upload
</Link>
<Link
href="/admin/gallery/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add photo
</Link>
</div>
</div>
<p className="mt-2 text-sm text-muted">
Photos of completed work, shown on the public <Link href="/gallery" className="text-clay hover:text-clay-dark">gallery page</Link>.