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:
co-authored by
Claude Haiku 4.5
parent
d1f09f91f0
commit
3fe38a7c2f
@@ -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"
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>.
|
||||
|
||||
Reference in New Issue
Block a user