Feature: user upload artwork

This commit is contained in:
2025-10-25 02:22:39 -04:00
parent 28d0443f44
commit d376b48d5e
23 changed files with 1288 additions and 177 deletions

View File

@@ -9,21 +9,34 @@ import {useArtwork} from '@/hooks/useArtwork';
function App() {
const {isAuthenticated, login, logout} = useAuth();
const {artworks, addArtwork, deleteArtwork} = useArtwork();
const {artworks, loading, error, addArtwork, deleteArtwork} = useArtwork();
const [loginOpen, setLoginOpen] = useState(false);
const [addArtworkOpen, setAddArtworkOpen] = useState(false);
return (
<div style={{display: 'flex', width: '100vw', height: '100vh', flexDirection: 'column'}}>
<div className="min-h-screen bg-background" style={{margin: 'auto'}}>
<Header isAuthenticated={isAuthenticated} onLoginClick={() => setLoginOpen(true)} onLogout={logout} />
<div className="min-h-screen bg-background" style={{margin: 'auto'}}>
<Header isAuthenticated={isAuthenticated} onLoginClick={() => setLoginOpen(true)} onLogout={logout} />
<main>
<Hero />
<Gallery artworks={artworks} isAuthenticated={isAuthenticated} onDelete={deleteArtwork} onAddClick={() => setAddArtworkOpen(true)} />
<main>
<Hero />
{loading && (
<div className="container px-4 py-12 text-center">
<p className="text-muted-foreground">Loading artworks...</p>
</div>
)}
{error && (
<div className="container px-4 py-12">
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-center">
{error}
</div>
</div>
)}
{!loading && !error && (
<Gallery artworks={artworks} isAuthenticated={isAuthenticated} onDelete={deleteArtwork} onAddClick={() => setAddArtworkOpen(true)} />
)}
{/*
{/*
TODO (AZ): Waiting on client to provide bio...
<section id="about" className="py-12 md:py-20 bg-muted/50">
<div className="container px-4 md:px-6">
@@ -46,26 +59,26 @@ function App() {
</div>
</div>
</section> */}
</main>
</main>
<footer className="border-t py-6 md:py-8">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<p className="text-sm text-muted-foreground">© 2025 Albert Jeffers. All rights reserved.</p>
<div className="flex gap-4">
<a href="https://www.instagram.com/albert_jeffers_og/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
Instagram
</a>
<footer className="border-t py-6 md:py-8">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<p className="text-sm text-muted-foreground">© 2025 Albert Jeffers. All rights reserved.</p>
<div className="flex gap-4">
<a href="https://www.instagram.com/albert_jeffers_og/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
Instagram
</a>
</div>
</div>
</div>
</div>
</footer>
</footer>
<LoginDialog open={loginOpen} onOpenChange={setLoginOpen} onLogin={login} />
<LoginDialog open={loginOpen} onOpenChange={setLoginOpen} onLogin={login} />
<AddArtworkDialog open={addArtworkOpen} onOpenChange={setAddArtworkOpen} onAdd={addArtwork} />
<AddArtworkDialog open={addArtworkOpen} onOpenChange={setAddArtworkOpen} onAdd={addArtwork} />
</div>
</div>
</div>
);
}

View File

@@ -8,114 +8,156 @@ import type {Artwork} from '@/types';
interface AddArtworkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (artwork: Omit<Artwork, 'id'>) => void;
onAdd: (artwork: Omit<Artwork, 'id'>, file: File) => Promise<Artwork>;
}
export const AddArtworkDialog = ({open, onOpenChange, onAdd}: AddArtworkDialogProps) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [year, setYear] = useState(new Date().getFullYear());
const [medium, setMedium] = useState('');
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setPreviewUrl(result);
setImageUrl(result); // Use data URL for local storage
};
reader.readAsDataURL(file);
const selectedFile = e.target.files?.[0];
if (!selectedFile) {
return;
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(selectedFile.type)) {
setError('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.');
return;
}
// Validate file size (10MB)
if (selectedFile.size > 10 * 1024 * 1024) {
setError('File size too large. Maximum size is 10MB.');
return;
}
setError(null);
setFile(selectedFile);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setPreviewUrl(result);
};
reader.readAsDataURL(selectedFile);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!title.trim()) {
setError('Please enter a title');
return;
}
if (!file) {
setError('Please select an image file');
return;
}
setIsSubmitting(true);
try {
await onAdd({title: title.trim(), imageUrl: ''}, file);
// Reset form
setTitle('');
setFile(null);
setPreviewUrl('');
setError(null);
onOpenChange(false);
} catch (err) {
console.error('Error submitting artwork:', err);
setError(err instanceof Error ? err.message : 'Failed to upload artwork');
} finally {
setIsSubmitting(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onAdd({
title,
description,
imageUrl: imageUrl || previewUrl
});
// Reset form
setTitle('');
setDescription('');
setImageUrl('');
setYear(new Date().getFullYear());
setMedium('');
setPreviewUrl('');
onOpenChange(false);
const handleOpenChange = (newOpen: boolean) => {
if (!isSubmitting) {
if (!newOpen) {
// Reset form when closing
setTitle('');
setFile(null);
setPreviewUrl('');
setError(null);
}
onOpenChange(newOpen);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[525px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add New Artwork</DialogTitle>
<DialogDescription>Upload a new piece to your portfolio</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="title" className="text-sm font-medium">
Title *
</label>
<Input id="title" placeholder="Artwork title" value={title} onChange={e => setTitle(e.target.value)} required />
</div>
<div className="space-y-2">
<label htmlFor="description" className="text-sm font-medium">
Description *
</label>
<Input id="description" placeholder="Describe your artwork" value={description} onChange={e => setDescription(e.target.value)} required />
</div>
<div className="space-y-2">
<label htmlFor="medium" className="text-sm font-medium">
Medium *
</label>
<Input id="medium" placeholder="e.g., Oil on Canvas, Acrylic, Watercolor" value={medium} onChange={e => setMedium(e.target.value)} required />
</div>
<div className="space-y-2">
<label htmlFor="year" className="text-sm font-medium">
Year *
</label>
<Input
id="year"
type="number"
min="1900"
max={new Date().getFullYear() + 1}
value={year}
onChange={e => setYear(parseInt(e.target.value))}
id="title"
placeholder="Artwork title"
value={title}
onChange={e => setTitle(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Image</label>
<label className="text-sm font-medium">Image *</label>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input id="image-file" type="file" accept="image/*" onChange={handleFileChange} className="cursor-pointer" />
<Input
id="image-file"
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onChange={handleFileChange}
className="cursor-pointer"
disabled={isSubmitting}
required
/>
<Upload className="h-4 w-4 text-muted-foreground" />
</div>
<div className="text-xs text-muted-foreground">or</div>
<Input id="image-url" type="url" placeholder="https://example.com/image.jpg" value={imageUrl} onChange={e => setImageUrl(e.target.value)} />
<p className="text-xs text-muted-foreground">
Supported formats: JPEG, PNG, GIF, WebP (max 10MB)
</p>
</div>
</div>
{(previewUrl || imageUrl) && (
{previewUrl && (
<div className="space-y-2">
<label className="text-sm font-medium">Preview</label>
<img src={previewUrl || imageUrl} alt="Preview" className="w-full h-48 object-cover rounded-md" />
<img
src={previewUrl}
alt="Preview"
className="w-full h-48 object-cover rounded-md border border-gray-200"
/>
</div>
)}
<Button type="submit" className="w-full">
Add Artwork
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Uploading...' : 'Add Artwork'}
</Button>
</form>
</DialogContent>

View File

@@ -18,7 +18,6 @@ export const ArtworkDetail = ({artwork, open, onOpenChange}: ArtworkDetailProps)
</DialogHeader>
<div className="mt-4">
<img src={artwork.imageUrl} alt={artwork.title} className="w-full rounded-lg object-contain max-h-[60vh]" />
<p className="mt-4 text-sm text-muted-foreground">{artwork.description}</p>
</div>
</DialogContent>
</Dialog>

View File

@@ -16,4 +16,4 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
});
Input.displayName = 'Input';
export {Input};
export {Input};

View File

@@ -1,81 +1,107 @@
import {useState, useEffect} from 'react';
import type {Artwork} from '@/types';
const SAMPLE_ARTWORKS: Artwork[] = [
{
id: '1',
title: 'Dragon 1',
description: '',
imageUrl: '/gallery/ArtFixture_2.jpg?inline',
},
{
id: '2',
title: 'Dragon 2',
description: '',
imageUrl: '/gallery/ArtFixture_3.jpg?inline',
},
{
id: '3',
title: 'Dragon 3',
description: '',
imageUrl: '/gallery/ArtFixture_4.jpg?inline',
},
{
id: '4',
title: 'Abstract Emotions',
description: '',
imageUrl: '/gallery/ArtFixture_5.jpg?inline',
},
{
id: '5',
title: 'Humorous',
description: '',
imageUrl: '/gallery/ArtFixture_6.png?inline',
},
{
id: '6',
title: 'Rainbows',
description: '',
imageUrl: '/gallery/ArtFixture_7.jpg?inline',
}
];
interface ArtworkResponse {
id: string;
title: string;
filename: string;
mimetype: string;
size: number;
created_at: string;
updated_at: string;
}
export const useArtwork = () => {
const [artworks, setArtworks] = useState<Artwork[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Load from localStorage or use sample data
const stored = localStorage.getItem('artworks');
if (stored) {
setArtworks(JSON.parse(stored));
} else {
setArtworks(SAMPLE_ARTWORKS);
localStorage.setItem('artworks', JSON.stringify(SAMPLE_ARTWORKS));
const fetchArtworks = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/artworks');
if (!response.ok) {
throw new Error('Failed to fetch artworks');
}
const data: ArtworkResponse[] = await response.json();
// Transform backend data to frontend format
const transformedArtworks: Artwork[] = data.map(artwork => ({
id: artwork.id,
title: artwork.title,
imageUrl: `/api/artworks/${artwork.id}/image`
}));
setArtworks(transformedArtworks);
} catch (err) {
console.error('Error fetching artworks:', err);
setError(err instanceof Error ? err.message : 'Failed to load artworks');
setArtworks([]);
} finally {
setLoading(false);
}
setLoading(false);
}, []);
const addArtwork = (artwork: Omit<Artwork, 'id'>) => {
const newArtwork: Artwork = {
...artwork,
id: Date.now().toString()
};
const updated = [...artworks, newArtwork];
setArtworks(updated);
localStorage.setItem('artworks', JSON.stringify(updated));
};
const deleteArtwork = (id: string) => {
const updated = artworks.filter(art => art.id !== id);
setArtworks(updated);
localStorage.setItem('artworks', JSON.stringify(updated));
useEffect(() => {
fetchArtworks();
}, []);
const addArtwork = async (artwork: Omit<Artwork, 'id'>, file: File) => {
try {
const formData = new FormData();
formData.append('title', artwork.title);
formData.append('image', file);
const response = await fetch('/api/artworks', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to upload artwork');
}
const newArtworkData: ArtworkResponse = await response.json();
const newArtwork: Artwork = {
id: newArtworkData.id,
title: newArtworkData.title,
imageUrl: `/api/artworks/${newArtworkData.id}/image`
};
setArtworks(prev => [...prev, newArtwork]);
return newArtwork;
} catch (err) {
console.error('Error adding artwork:', err);
throw err;
}
};
const deleteArtwork = async (id: string) => {
try {
const response = await fetch(`/api/artworks/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete artwork');
}
setArtworks(prev => prev.filter(art => art.id !== id));
} catch (err) {
console.error('Error deleting artwork:', err);
throw err;
}
};
return {
artworks,
loading,
error,
addArtwork,
deleteArtwork
deleteArtwork,
refetch: fetchArtworks
};
};

View File

@@ -19,12 +19,12 @@ export const useAuth = () => {
const response = await fetch('/api/users/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
password,
}),
password
})
});
if (!response.ok) {
@@ -32,7 +32,7 @@ export const useAuth = () => {
}
const responseData = await response.json();
if (responseData.status === 'ok') {
setUser({username, isAuthenticated: true});
localStorage.setItem('user', JSON.stringify({username, isAuthenticated: true}));

View File

@@ -1,7 +1,6 @@
export interface Artwork {
id: string;
title: string;
description: string;
imageUrl: string;
}