feat: Add new scripts and update project structure for database management and user authentication

This commit introduces several new scripts for managing database operations, including user creation, permission grants, and data migrations. It also adds new documentation files to guide users through the setup and configuration processes. Additionally, the project structure is updated to enhance organization and maintainability, ensuring a smoother development experience for contributors. These changes support the ongoing transition to a web-based architecture and improve overall project functionality.
This commit is contained in:
Tanya
2026-01-06 13:53:24 -05:00
parent 713584dc04
commit de2144be2a
175 changed files with 35854 additions and 0 deletions
@@ -0,0 +1,367 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import { Upload, X, CheckCircle2, AlertCircle, Loader2, Play, Pause } from 'lucide-react';
interface UploadedFile {
file: File;
preview: string;
id: string;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
}
interface FilePreviewItemProps {
uploadedFile: UploadedFile;
onRemove: (id: string) => void;
}
function FilePreviewItem({ uploadedFile, onRemove }: FilePreviewItemProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const isVideo = uploadedFile.file.type.startsWith('video/');
const togglePlay = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const video = videoRef.current;
if (!video) return;
try {
if (video.paused) {
await video.play();
setIsPlaying(true);
} else {
video.pause();
setIsPlaying(false);
}
} catch (error) {
console.error('Error playing video:', error);
// If play() fails, try with muted
try {
video.muted = true;
await video.play();
setIsPlaying(true);
} catch (mutedError) {
console.error('Error playing video even when muted:', mutedError);
}
}
}, []);
return (
<div className="group relative aspect-square overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-900">
{isVideo ? (
<>
<video
ref={videoRef}
src={uploadedFile.preview}
className="h-full w-full object-cover"
playsInline
preload="metadata"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
onLoadedMetadata={() => {
// Video is ready to play
}}
/>
{/* Play/Pause Button Overlay */}
{uploadedFile.status === 'pending' && (
<button
onClick={togglePlay}
type="button"
className="absolute inset-0 z-20 flex items-center justify-center bg-black/20 hover:bg-black/30 transition-colors"
aria-label={isPlaying ? 'Pause video' : 'Play video'}
>
{isPlaying ? (
<Pause className="h-12 w-12 text-white opacity-80" />
) : (
<Play className="h-12 w-12 text-white opacity-80" />
)}
</button>
)}
</>
) : (
<img
src={uploadedFile.preview}
alt={uploadedFile.file.name}
className="h-full w-full object-cover"
/>
)}
{!isVideo && (
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
)}
{/* Status Overlay */}
{uploadedFile.status !== 'pending' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
{uploadedFile.status === 'uploading' && (
<Loader2 className="h-8 w-8 animate-spin text-white" />
)}
{uploadedFile.status === 'success' && (
<CheckCircle2 className="h-8 w-8 text-green-400" />
)}
{uploadedFile.status === 'error' && (
<AlertCircle className="h-8 w-8 text-red-400" />
)}
</div>
)}
{/* Remove Button */}
{uploadedFile.status === 'pending' && (
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onRemove(uploadedFile.id);
}}
className="absolute right-2 top-2 z-30 rounded-full bg-red-500 p-1.5 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600"
aria-label="Remove file"
type="button"
>
<X className="h-4 w-4" />
</button>
)}
{/* File Name */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
<p className="truncate text-xs text-white">
{uploadedFile.file.name}
</p>
{uploadedFile.error && (
<p className="mt-1 text-xs text-red-300">
{uploadedFile.error}
</p>
)}
</div>
</div>
);
}
export function UploadContent() {
const { data: session } = useSession();
const [files, setFiles] = useState<UploadedFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleFileSelect = useCallback((selectedFiles: FileList | null) => {
if (!selectedFiles) return;
const newFiles: UploadedFile[] = Array.from(selectedFiles)
.filter((file) => file.type.startsWith('image/') || file.type.startsWith('video/'))
.map((file) => ({
file,
preview: URL.createObjectURL(file),
id: `${Date.now()}-${Math.random()}`,
status: 'pending' as const,
}));
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
handleFileSelect(e.dataTransfer.files);
},
[handleFileSelect]
);
const removeFile = useCallback((id: string) => {
setFiles((prev) => {
const file = prev.find((f) => f.id === id);
if (file) {
URL.revokeObjectURL(file.preview);
}
return prev.filter((f) => f.id !== id);
});
}, []);
const handleSubmit = useCallback(async () => {
if (files.length === 0 || !session?.user) return;
setIsSubmitting(true);
try {
const formData = new FormData();
files.forEach((uploadedFile) => {
formData.append('photos', uploadedFile.file);
});
// Update files to uploading status
setFiles((prev) =>
prev.map((f) => ({ ...f, status: 'uploading' as const }))
);
const response = await fetch('/api/photos/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload files');
}
const result = await response.json();
// Update files to success status
setFiles((prev) =>
prev.map((f) => ({ ...f, status: 'success' as const }))
);
// Clear files after 3 seconds
setTimeout(() => {
setFiles((currentFiles) => {
// Revoke object URLs to free memory
currentFiles.forEach((f) => URL.revokeObjectURL(f.preview));
return [];
});
}, 3000);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to upload files';
// Update files to error status
setFiles((prev) =>
prev.map((f) => ({
...f,
status: 'error' as const,
error: errorMessage,
}))
);
} finally {
setIsSubmitting(false);
}
}, [files, session]);
const pendingFiles = files.filter((f) => f.status === 'pending');
const hasPendingFiles = pendingFiles.length > 0;
const allSuccess = files.length > 0 && files.every((f) => f.status === 'success');
return (
<div className="space-y-6">
{/* Upload Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative rounded-lg border-2 border-dashed p-12 text-center transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600'
}`}
>
<input
type="file"
id="file-upload"
ref={fileInputRef}
multiple
accept="image/*,video/*"
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
/>
<label
htmlFor="file-upload"
className="flex cursor-pointer flex-col items-center justify-center space-y-4"
>
<Upload
className={`h-12 w-12 ${
isDragging
? 'text-primary'
: 'text-gray-400 dark:text-gray-500'
}`}
/>
<div>
<span className="text-lg font-medium text-secondary dark:text-gray-50">
Drop photos and videos here or click to browse
</span>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Images: JPEG, PNG, GIF, WebP (max 50MB) | Videos: MP4, MOV, AVI, WebM (max 500MB)
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.preventDefault();
fileInputRef.current?.click();
}}
>
Select Files
</Button>
</label>
</div>
{/* File List */}
{files.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-secondary dark:text-gray-50">
Selected Files ({files.length})
</h2>
{!allSuccess && (
<Button
onClick={handleSubmit}
disabled={!hasPendingFiles || isSubmitting}
size="sm"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Submit for Review
</>
)}
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{files.map((uploadedFile) => (
<FilePreviewItem
key={uploadedFile.id}
uploadedFile={uploadedFile}
onRemove={removeFile}
/>
))}
</div>
{allSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4">
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
<p className="text-sm font-medium text-green-800 dark:text-green-200">
Files submitted successfully! They are now pending admin review.
</p>
</div>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,72 @@
'use client';
import { useRouter } from 'next/navigation';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { UploadContent } from './UploadContent';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
export function UploadPageClient() {
const router = useRouter();
const handleClose = () => {
router.push('/');
};
return (
<div className="fixed inset-0 z-50 bg-background overflow-y-auto">
<div className="w-full px-4 py-8">
{/* Close button */}
<div className="mb-4 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close upload"
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Header */}
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<UserMenu />
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>
{/* Upload content */}
<div className="mt-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Upload Photos & Videos
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Upload your photos and videos for admin review. Once approved, they will be added to the collection.
</p>
</div>
<UploadContent />
</div>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { auth } from '@/app/api/auth/[...nextauth]/route';
import { redirect } from 'next/navigation';
import { UploadPageClient } from './UploadPageClient';
export default async function UploadPage() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
return <UploadPageClient />;
}