Feature: user upload artwork
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,4 +16,4 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
||||
});
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export {Input};
|
||||
export {Input};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export interface Artwork {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user