From c4a29225af906144e22d13ce094c90bcba931b6d Mon Sep 17 00:00:00 2001 From: Nicholai Date: Mon, 6 Oct 2025 04:51:57 -0600 Subject: [PATCH 1/5] __New File Created:__ (500+ lines) __Features Implemented:__ 1. __Image Display Grid__ - Responsive grid layout (2-4 columns) - Image previews with hover effects - Public/Private visibility badges - Caption display on images 2. __Upload Functionality__ - Drag-and-drop upload zone - Multiple file upload support - File type validation (PNG, JPG, WebP) - Size limit enforcement (5MB per file) - Upload progress feedback - Integration with endpoint 3. __Edit Capabilities__ - Modal dialog for editing images - Caption editor (textarea) - Tag management (add/remove tags) - Visibility toggle (public/private) - Image preview in edit dialog - Form validation with Zod 4. __Delete Functionality__ - Confirmation dialog before deletion - Permanent deletion warning - Integration with DELETE endpoint 5. __User Experience__ - Loading states during fetch/upload/edit/delete - Error handling with toast notifications - Success confirmations - Optimistic UI updates - Automatic data refresh after operations __Integration:__ - Added PortfolioManager to - Positioned below artist form for logical workflow - Automatic refresh of artist data when portfolio changes - Callback system for parent component updates ## --- app/admin/artists/[id]/page.tsx | 11 +- components/admin/portfolio-manager.tsx | 918 +++++++++++-------------- 2 files changed, 393 insertions(+), 536 deletions(-) diff --git a/app/admin/artists/[id]/page.tsx b/app/admin/artists/[id]/page.tsx index 8a96bd407..d6179009e 100644 --- a/app/admin/artists/[id]/page.tsx +++ b/app/admin/artists/[id]/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react' import { useParams } from 'next/navigation' import { ArtistForm } from '@/components/admin/artist-form' +import { PortfolioManager } from '@/components/admin/portfolio-manager' import { useToast } from '@/hooks/use-toast' import type { Artist } from '@/types/database' @@ -57,7 +58,7 @@ export default function EditArtistPage() {

Edit Artist

- Update {artist.name}'s information and portfolio + Update {artist.name}'s information and portfolio

@@ -71,6 +72,14 @@ export default function EditArtistPage() { fetchArtist() // Refresh the data }} /> + + {/* Portfolio Management */} + { + fetchArtist() // Refresh artist data when images are updated + }} + /> ) } diff --git a/components/admin/portfolio-manager.tsx b/components/admin/portfolio-manager.tsx index 27fad0e6b..0406f46fe 100644 --- a/components/admin/portfolio-manager.tsx +++ b/components/admin/portfolio-manager.tsx @@ -1,608 +1,456 @@ -'use client' +"use client" import { useState, useEffect } from 'react' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import Image from 'next/image' import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' import { Badge } from '@/components/ui/badge' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' -import { Checkbox } from '@/components/ui/checkbox' +import { Switch } from '@/components/ui/switch' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { useToast } from '@/hooks/use-toast' -import { useFileUpload } from '@/hooks/use-file-upload' -import { LoadingSpinner } from './loading-states' -import { ErrorBoundary } from './error-boundary' -import { - Upload, - Search, - Filter, - Grid, - List, - Eye, - Edit, - Trash2, - Download, - Star, - Calendar, - User, - Tag, - BarChart3, - Image as ImageIcon, - Plus, - X -} from 'lucide-react' -import Image from 'next/image' -import { PortfolioImage, Artist } from '@/types/database' +import { Loader2, Upload, Edit, Trash2, Eye, EyeOff, X, Plus } from 'lucide-react' +import type { PortfolioImage } from '@/types/database' -interface PortfolioStats { - totalImages: number - totalViews: number - totalLikes: number - averageRating: number - storageUsed: string - recentUploads: number +const imageEditSchema = z.object({ + caption: z.string().optional(), + tags: z.array(z.string()), + isPublic: z.boolean(), +}) + +type ImageEditData = z.infer + +interface PortfolioManagerProps { + artistId: string + onImagesUpdate?: () => void } -export function PortfolioManager() { - const [portfolioImages, setPortfolioImages] = useState([]) - const [artists, setArtists] = useState([]) - const [stats, setStats] = useState(null) - const [loading, setLoading] = useState(true) - const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') - const [searchTerm, setSearchTerm] = useState('') - const [selectedArtist, setSelectedArtist] = useState('all') - const [selectedCategory, setSelectedCategory] = useState('all') - const [selectedImages, setSelectedImages] = useState>(new Set()) - const [showUploadDialog, setShowUploadDialog] = useState(false) - +export function PortfolioManager({ artistId, onImagesUpdate }: PortfolioManagerProps) { const { toast } = useToast() - const { uploadFiles, isUploading, progress } = useFileUpload({ - maxFiles: 20, - maxSize: 5 * 1024 * 1024, // 5MB - allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], + const [images, setImages] = useState([]) + const [loading, setLoading] = useState(true) + const [uploading, setUploading] = useState(false) + const [editingImage, setEditingImage] = useState(null) + const [deletingImage, setDeletingImage] = useState(null) + const [newTag, setNewTag] = useState('') + + const { + register, + handleSubmit, + watch, + setValue, + reset, + formState: { errors, isSubmitting } + } = useForm({ + resolver: zodResolver(imageEditSchema), + defaultValues: { + caption: '', + tags: [], + isPublic: true, + } }) - useEffect(() => { - loadPortfolioData() - loadArtists() - loadStats() - }, []) + const tags = watch('tags') - const loadPortfolioData = async () => { + const fetchImages = async () => { try { - const response = await fetch('/api/portfolio') - if (!response.ok) throw new Error('Failed to load portfolio') + setLoading(true) + const response = await fetch(`/api/artists/${artistId}`) + if (!response.ok) throw new Error('Failed to fetch images') + const data = await response.json() - setPortfolioImages(data) + setImages(data.portfolioImages || []) } catch (error) { + console.error('Error fetching images:', error) toast({ title: 'Error', description: 'Failed to load portfolio images', variant: 'destructive', }) - } - } - - const loadArtists = async () => { - try { - const response = await fetch('/api/artists') - if (!response.ok) throw new Error('Failed to load artists') - const data = await response.json() - setArtists(data) - } catch (error) { - console.error('Failed to load artists:', error) - } - } - - const loadStats = async () => { - try { - const response = await fetch('/api/portfolio/stats') - if (!response.ok) throw new Error('Failed to load stats') - const data = await response.json() - setStats(data) - } catch (error) { - console.error('Failed to load stats:', error) } finally { setLoading(false) } } - const handleFileUpload = async (files: FileList) => { + useEffect(() => { + fetchImages() + }, [artistId]) + + const handleFileUpload = async (files: FileList | null) => { + if (!files || files.length === 0) return + + setUploading(true) + const formData = new FormData() + formData.append('artistId', artistId) + + Array.from(files).forEach((file) => { + formData.append('files', file) + }) + try { - const fileArray = Array.from(files) - await uploadFiles(fileArray) - await loadPortfolioData() - await loadStats() - setShowUploadDialog(false) + const response = await fetch('/api/portfolio', { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Upload failed') + } + toast({ title: 'Success', - description: `Uploaded ${fileArray.length} images successfully`, + description: 'Images uploaded successfully', }) + + fetchImages() + onImagesUpdate?.() } catch (error) { + console.error('Upload error:', error) toast({ title: 'Error', - description: 'Failed to upload images', + description: error instanceof Error ? error.message : 'Failed to upload images', + variant: 'destructive', + }) + } finally { + setUploading(false) + } + } + + const openEditDialog = (image: PortfolioImage) => { + setEditingImage(image) + reset({ + caption: image.caption || '', + tags: image.tags || [], + isPublic: image.isPublic, + }) + } + + const closeEditDialog = () => { + setEditingImage(null) + reset() + } + + const addTag = () => { + if (newTag.trim() && !tags.includes(newTag.trim())) { + setValue('tags', [...tags, newTag.trim()]) + setNewTag('') + } + } + + const removeTag = (tag: string) => { + setValue('tags', tags.filter(t => t !== tag)) + } + + const onSubmitEdit = async (data: ImageEditData) => { + if (!editingImage) return + + try { + const response = await fetch(`/api/portfolio/${editingImage.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Update failed') + } + + toast({ + title: 'Success', + description: 'Image updated successfully', + }) + + closeEditDialog() + fetchImages() + onImagesUpdate?.() + } catch (error) { + console.error('Update error:', error) + toast({ + title: 'Error', + description: error instanceof Error ? error.message : 'Failed to update image', variant: 'destructive', }) } } - const handleDeleteImage = async (imageId: string) => { + const handleDelete = async () => { + if (!deletingImage) return + try { - const response = await fetch(`/api/portfolio/${imageId}`, { + const response = await fetch(`/api/portfolio/${deletingImage.id}`, { method: 'DELETE', }) - if (!response.ok) throw new Error('Failed to delete image') - - await loadPortfolioData() - await loadStats() + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Delete failed') + } + toast({ title: 'Success', description: 'Image deleted successfully', }) + + setDeletingImage(null) + fetchImages() + onImagesUpdate?.() } catch (error) { + console.error('Delete error:', error) toast({ title: 'Error', - description: 'Failed to delete image', + description: error instanceof Error ? error.message : 'Failed to delete image', variant: 'destructive', }) } } - const handleBulkDelete = async () => { - try { - const response = await fetch('/api/portfolio/bulk-delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ imageIds: Array.from(selectedImages) }), - }) - if (!response.ok) throw new Error('Failed to delete images') - - await loadPortfolioData() - await loadStats() - setSelectedImages(new Set()) - toast({ - title: 'Success', - description: `Deleted ${selectedImages.size} images successfully`, - }) - } catch (error) { - toast({ - title: 'Error', - description: 'Failed to delete images', - variant: 'destructive', - }) - } - } - - const toggleImageSelection = (imageId: string) => { - const newSelection = new Set(selectedImages) - if (newSelection.has(imageId)) { - newSelection.delete(imageId) - } else { - newSelection.add(imageId) - } - setSelectedImages(newSelection) - } - - const selectAllImages = () => { - setSelectedImages(new Set(filteredImages.map(img => img.id))) - } - - const clearSelection = () => { - setSelectedImages(new Set()) - } - - const filteredImages = portfolioImages.filter(image => { - const matchesSearch = image.caption?.toLowerCase().includes(searchTerm.toLowerCase()) || - image.tags?.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())) - - const matchesArtist = selectedArtist === 'all' || image.artistId === selectedArtist - - return matchesSearch && matchesArtist - }) - - const categories = ['Traditional', 'Realism', 'Blackwork', 'Watercolor', 'Geometric', 'Japanese'] - if (loading) { - return + return ( + + + Portfolio Images + + +
+ +
+
+
+ ) } return ( - -
- {/* Stats Cards */} - {stats && ( -
- - - Total Images - - - -
{stats.totalImages}
-

- +{stats.recentUploads} this week -

-
-
- - - - Total Views - - - -
{stats.totalViews.toLocaleString()}
-

- Portfolio engagement -

-
-
- - - - Average Rating - - - -
{stats.averageRating.toFixed(1)}
-

- Out of 5.0 stars -

-
-
- - - - Storage Used - - - -
{stats.storageUsed}
-

- R2 storage usage -

-
-
-
- )} - - {/* Controls */} - - - Portfolio Management - - Manage your portfolio images, organize galleries, and track performance. - - - - {/* Search and Filters */} -
-
- - setSearchTerm(e.target.value)} - className="max-w-sm" - /> -
- -
- - - -
+ <> + + + Portfolio Images ({images.length}) + + + {/* Upload Section */} +
+ +
+ + handleFileUpload(e.target.files)} + disabled={uploading} + />
- - {/* Action Bar */} -
-
- - - - - - - Upload Portfolio Images - - Select multiple images to upload to the portfolio. - - -
-
- - e.target.files && handleFileUpload(e.target.files)} - disabled={isUploading} - /> -
- {isUploading && ( -
-
- Uploading... {progress.length > 0 ? Math.round(progress[0].progress || 0) : 0}% -
-
-
0 ? progress[0].progress || 0 : 0}%` }} - /> -
-
- )} -
- -
- - {selectedImages.size > 0 && ( - <> - - - - - - - Delete Images - - Are you sure you want to delete {selectedImages.size} selected images? - This action cannot be undone. - - - - Cancel - - Delete - - - - - - - - )} + {uploading && ( +
+
- -
- - -
- - -
-
-
- - - - {/* Portfolio Grid/List */} -
-
-

- Portfolio Images ({filteredImages.length}) -

+ )}
- {viewMode === 'grid' ? ( -
- {filteredImages.map((image) => ( - -
- {image.caption -
- toggleImageSelection(image.id)} - className="bg-background" - /> -
-
- - - - - - - - - Delete Image - - Are you sure you want to delete this image? This action cannot be undone. - - - - Cancel - handleDeleteImage(image.id)}> - Delete - - - - -
-
- -
-

{image.caption || 'Untitled'}

-
- {artists.find(a => a.id === image.artistId)?.name || 'Unknown'} - {new Date(image.createdAt).toLocaleDateString()} -
- {image.tags && image.tags.length > 0 && ( -
- {image.tags.slice(0, 3).map((tag, index) => ( - - {tag} - - ))} - {image.tags.length > 3 && ( - - +{image.tags.length - 3} - - )} -
- )} -
-
-
- ))} + {/* Images Grid */} + {images.length === 0 ? ( +
+

No portfolio images yet. Upload some to get started!

) : ( -
- {filteredImages.map((image) => ( - - -
- toggleImageSelection(image.id)} - /> -
- {image.caption -
-
-

{image.caption || 'Untitled'}

-

- {artists.find(a => a.id === image.artistId)?.name || 'Unknown Artist'} -

-
-
- Portfolio - - {new Date(image.createdAt).toLocaleDateString()} - -
- - - - - - - - - Delete Image - - Are you sure you want to delete this image? This action cannot be undone. - - - - Cancel - handleDeleteImage(image.id)}> - Delete - - - - -
-
+
+ {images.map((image) => ( +
+ {/* Image */} + {image.caption + + {/* Visibility Badge */} +
+ + {image.isPublic ? ( + <> Public + ) : ( + <> Private + )} + +
+ + {/* Hover Overlay */} +
+ + +
+ + {/* Caption */} + {image.caption && ( +
+

{image.caption}

- - + )} +
))}
)} + + - {filteredImages.length === 0 && ( - - - -

No images found

-

- {searchTerm || selectedArtist !== 'all' || selectedCategory !== 'all' - ? 'Try adjusting your search or filters' - : 'Upload your first portfolio images to get started'} -

- {!searchTerm && selectedArtist === 'all' && selectedCategory === 'all' && ( - + {/* Edit Dialog */} + !open && closeEditDialog()}> + + + Edit Portfolio Image + + Update image details, tags, and visibility + + + + {editingImage && ( +
+ {/* Image Preview */} +
+ {editingImage.caption +
+ + {/* Caption */} +
+ +