From 3fe38a7c2fef2aa036879660ee3dfbac5f18e522 Mon Sep 17 00:00:00 2001 From: Andymick Date: Sun, 19 Jul 2026 15:06:28 +0100 Subject: [PATCH] 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 --- backup-database.ps1 | 37 ++++++++ src/app/admin/gallery/actions.ts | 30 ++++++ src/app/admin/gallery/bulk/form.tsx | 140 ++++++++++++++++++++++++++++ src/app/admin/gallery/bulk/page.tsx | 21 +++++ src/app/admin/gallery/new/page.tsx | 19 +++- src/app/admin/gallery/page.tsx | 20 ++-- 6 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 backup-database.ps1 create mode 100644 src/app/admin/gallery/bulk/form.tsx create mode 100644 src/app/admin/gallery/bulk/page.tsx diff --git a/backup-database.ps1 b/backup-database.ps1 new file mode 100644 index 0000000..4e69c78 --- /dev/null +++ b/backup-database.ps1 @@ -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" +} diff --git a/src/app/admin/gallery/actions.ts b/src/app/admin/gallery/actions.ts index ad3378e..799e4ef 100644 --- a/src/app/admin/gallery/actions.ts +++ b/src/app/admin/gallery/actions.ts @@ -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}`); +} diff --git a/src/app/admin/gallery/bulk/form.tsx b/src/app/admin/gallery/bulk/form.tsx new file mode 100644 index 0000000..bff96c5 --- /dev/null +++ b/src/app/admin/gallery/bulk/form.tsx @@ -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([]); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + const formRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + 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) => { + 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 ( +
+ {/* File input */} +
+ + +

+ You can select multiple files at once. Supported formats: JPG, PNG, GIF, WebP +

+
+ + {/* File list */} + {files.length > 0 && ( +
+

Selected files ({files.length})

+
+ {files.map((file, index) => ( +
+ {file.name} + +
+ ))} +
+
+ )} + + {/* Category */} +
+ + + + + Add a new category + +
+ + {/* Actions */} +
+ + + Cancel + +
+
+ ); +} diff --git a/src/app/admin/gallery/bulk/page.tsx b/src/app/admin/gallery/bulk/page.tsx new file mode 100644 index 0000000..590e9dc --- /dev/null +++ b/src/app/admin/gallery/bulk/page.tsx @@ -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 ( +
+ +
+

Bulk upload photos

+

+ Upload multiple gallery photos at once. Captions can be added individually later. +

+
+ + +
+ ); +} diff --git a/src/app/admin/gallery/new/page.tsx b/src/app/admin/gallery/new/page.tsx index eb22aa6..c1d9045 100644 --- a/src/app/admin/gallery/new/page.tsx +++ b/src/app/admin/gallery/new/page.tsx @@ -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 (
-

Add a photo

-

- Upload a photo of completed work to show on the public gallery. -

+
+
+

Add a photo

+

+ Upload a photo of completed work to show on the public gallery. +

+
+ + ↑ Bulk upload + +
diff --git a/src/app/admin/gallery/page.tsx b/src/app/admin/gallery/page.tsx index b8ac3ff..6fcb293 100644 --- a/src/app/admin/gallery/page.tsx +++ b/src/app/admin/gallery/page.tsx @@ -14,12 +14,20 @@ export default async function AdminGalleryPage() {

Gallery

- - + Add photo - +
+ + ↑ Bulk upload + + + + Add photo + +

Photos of completed work, shown on the public gallery page.