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 } });
|
await prisma.galleryCategory.delete({ where: { id } });
|
||||||
redirect('/admin/gallery/categories');
|
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 { prisma } from '@/lib/prisma';
|
||||||
import AdminNav from '@/components/AdminNav';
|
import AdminNav from '@/components/AdminNav';
|
||||||
import { uploadGalleryPhoto } from '../actions';
|
import { uploadGalleryPhoto } from '../actions';
|
||||||
@@ -8,10 +9,20 @@ export default async function NewGalleryPhotoPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-6 py-12">
|
<div className="mx-auto max-w-2xl px-6 py-12">
|
||||||
<AdminNav />
|
<AdminNav />
|
||||||
<h1 className="font-display text-3xl">Add a photo</h1>
|
<div className="flex items-baseline justify-between">
|
||||||
<p className="mt-2 text-sm text-muted">
|
<div>
|
||||||
Upload a photo of completed work to show on the public gallery.
|
<h1 className="font-display text-3xl">Add a photo</h1>
|
||||||
</p>
|
<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">
|
<form action={uploadGalleryPhoto} className="mt-10 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ export default async function AdminGalleryPage() {
|
|||||||
<AdminNav />
|
<AdminNav />
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h1 className="font-display text-3xl">Gallery</h1>
|
<h1 className="font-display text-3xl">Gallery</h1>
|
||||||
<Link
|
<div className="flex gap-2">
|
||||||
href="/admin/gallery/new"
|
<Link
|
||||||
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
href="/admin/gallery/bulk"
|
||||||
>
|
className="border border-line px-4 py-2 text-sm hover:border-clay hover:text-clay"
|
||||||
+ Add photo
|
>
|
||||||
</Link>
|
↑ 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>
|
</div>
|
||||||
<p className="mt-2 text-sm text-muted">
|
<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>.
|
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