added server

This commit is contained in:
2025-10-20 06:32:14 -04:00
parent df7c275929
commit f2cf309d97
43 changed files with 1520 additions and 1 deletions

72
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,72 @@
import {useState} from 'react';
import {Header} from '@/components/Header';
import {Hero} from '@/components/Hero';
import {Gallery} from '@/components/Gallery';
import {LoginDialog} from '@/components/LoginDialog';
import {AddArtworkDialog} from '@/components/AddArtworkDialog';
import {useAuth} from '@/hooks/useAuth';
import {useArtwork} from '@/hooks/useArtwork';
function App() {
const {isAuthenticated, login, logout} = useAuth();
const {artworks, 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} />
<main>
<Hero />
<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">
<div className="mx-auto max-w-3xl space-y-4">
<h2 className="text-3xl font-bold tracking-tight">About the Artist</h2>
<div className="space-y-4 text-muted-foreground">
<p>
Albert Jeffers is a contemporary visual artist whose work explores the dynamic relationship between color, texture, and emotion. With a career
spanning over two decades, his paintings have been exhibited in galleries across the world.
</p>
<p>
Drawing inspiration from both natural landscapes and urban environments, Albert's work seeks to capture fleeting moments of beauty and
transform them into lasting visual experiences. His unique approach combines traditional techniques with contemporary sensibilities.
</p>
<p>
Each piece is a meditation on the interplay between light and shadow, movement and stillness, inviting viewers to discover their own emotional
connections within the work.
</p>
</div>
</div>
</div>
</section> */}
</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>
</div>
</div>
</div>
</footer>
<LoginDialog open={loginOpen} onOpenChange={setLoginOpen} onLogin={login} />
<AddArtworkDialog open={addArtworkOpen} onOpenChange={setAddArtworkOpen} onAdd={addArtwork} />
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,124 @@
import {useState} from 'react';
import {Upload} from 'lucide-react';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Button} from '@/components/ui/button';
import type {Artwork} from '@/types';
interface AddArtworkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (artwork: Omit<Artwork, 'id'>) => void;
}
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 [previewUrl, setPreviewUrl] = useState('');
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 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);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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">
<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))}
required
/>
</div>
<div className="space-y-2">
<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" />
<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)} />
</div>
</div>
{(previewUrl || imageUrl) && (
<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" />
</div>
)}
<Button type="submit" className="w-full">
Add Artwork
</Button>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,50 @@
import {useState} from 'react';
import {Trash2} from 'lucide-react';
import {Card, CardContent} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import type {Artwork} from '@/types';
interface ArtworkCardProps {
artwork: Artwork;
isAuthenticated: boolean;
onDelete: (id: string) => void;
onClick: () => void;
}
export const ArtworkCard = ({artwork, isAuthenticated, onDelete, onClick}: ArtworkCardProps) => {
const [isImageLoaded, setIsImageLoaded] = useState(false);
return (
<Card className="group relative overflow-hidden cursor-pointer transition-all hover:shadow-xl">
<div className="aspect-square overflow-hidden bg-muted">
<img
src={artwork.imageUrl}
alt={artwork.title}
className={`h-full w-full object-cover transition-all duration-300 group-hover:scale-105 ${isImageLoaded ? 'opacity-100' : 'opacity-0'}`}
loading="lazy"
onLoad={() => setIsImageLoaded(true)}
onClick={onClick}
/>
</div>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-lg">{artwork.title}</h3>
</div>
{isAuthenticated && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={e => {
e.stopPropagation();
onDelete(artwork.id);
}}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,26 @@
import {Dialog, DialogContent, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import type {Artwork} from '@/types';
interface ArtworkDetailProps {
artwork: Artwork | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const ArtworkDetail = ({artwork, open, onOpenChange}: ArtworkDetailProps) => {
if (!artwork) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="text-2xl">{artwork.title}</DialogTitle>
</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

@@ -0,0 +1,50 @@
import {useState} from 'react';
import {Plus} from 'lucide-react';
import {ArtworkCard} from '@/components/ArtworkCard';
import {ArtworkDetail} from '@/components/ArtworkDetail';
import {Button} from '@/components/ui/button';
import type {Artwork} from '@/types';
interface GalleryProps {
artworks: Artwork[];
isAuthenticated: boolean;
onDelete: (id: string) => void;
onAddClick: () => void;
}
export const Gallery = ({artworks, isAuthenticated, onDelete, onAddClick}: GalleryProps) => {
const [selectedArtwork, setSelectedArtwork] = useState<Artwork | null>(null);
return (
<section id="gallery" className="py-12 md:py-20">
<div className="container px-4 md:px-6">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-3xl font-bold tracking-tight">Gallery</h2>
<p className="text-muted-foreground mt-2">A collection of recent works</p>
</div>
{isAuthenticated && (
<Button onClick={onAddClick}>
<Plus className="mr-2 h-4 w-4" />
Add Artwork
</Button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{artworks.map(artwork => (
<ArtworkCard key={artwork.id} artwork={artwork} isAuthenticated={isAuthenticated} onDelete={onDelete} onClick={() => setSelectedArtwork(artwork)} />
))}
</div>
{artworks.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">No artworks yet. {isAuthenticated ? 'Add your first piece!' : ''}</p>
</div>
)}
</div>
<ArtworkDetail artwork={selectedArtwork} open={!!selectedArtwork} onOpenChange={open => !open && setSelectedArtwork(null)} />
</section>
);
};

View File

@@ -0,0 +1,40 @@
import {LogIn, LogOut, Palette} from 'lucide-react';
import {Button} from '@/components/ui/button';
interface HeaderProps {
isAuthenticated: boolean;
onLoginClick: () => void;
onLogout: () => void;
}
export const Header = ({isAuthenticated, onLoginClick, onLogout}: HeaderProps) => {
return (
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between px-4 md:px-6">
<div className="flex items-center gap-2">
<Palette className="h-6 w-6" />
<h1 className="text-2xl font-bold tracking-tight">Albert Jeffers</h1>
</div>
<nav className="flex items-center gap-4">
<a href="#gallery" className="text-sm font-medium transition-colors hover:text-primary">
Gallery
</a>
{/* <a href="#about" className="text-sm font-medium transition-colors hover:text-primary">
About
</a> */}
{isAuthenticated ? (
<Button onClick={onLogout} variant="outline" size="sm">
<LogOut className="mr-2 h-4 w-4" />
Logout
</Button>
) : (
<Button onClick={onLoginClick} variant="outline" size="sm">
<LogIn className="mr-2 h-4 w-4" />
Login
</Button>
)}
</nav>
</div>
</header>
);
};

View File

@@ -0,0 +1,18 @@
export const Hero = () => {
return (
<section className="relative overflow-hidden py-20 md:py-32">
<div className="absolute inset-0 -z-10 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl lg:text-7xl">Albert Jeffers Studios</h1>
<p className="font-bold tracking-tighter sm:text-2xl md:text-3xl lg:text-4xl">Visual Artist & Painter</p>
<p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400">
Exploring the intersection of color, emotion, and form through contemporary painting
</p>
</div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,59 @@
import {useState} from 'react';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Button} from '@/components/ui/button';
interface LoginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onLogin: (email: string, password: string) => boolean;
}
export const LoginDialog = ({open, onOpenChange, onLogin}: LoginDialogProps) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
const success = onLogin(email, password);
if (success) {
setEmail('');
setPassword('');
onOpenChange(false);
} else {
setError('Invalid credentials. Please try again.');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Artist Login</DialogTitle>
<DialogDescription>Enter your credentials to manage your portfolio</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input id="email" type="email" placeholder="artist@example.com" value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input id="password" type="password" placeholder="••••••••" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full">
Login
</Button>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,40 @@
import * as React from 'react';
import {cva, type VariantProps} from 'class-variance-authority';
import {cn} from '@/lib/utils';
/* eslint-disable */
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({className, variant, size, ...props}, ref) => {
return <button className={cn(buttonVariants({variant, size, className}))} ref={ref} {...props} />;
});
Button.displayName = 'Button';
export {Button, buttonVariants};

View File

@@ -0,0 +1,34 @@
import * as React from 'react';
import {cn} from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({className, ...props}, ref) => (
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)} {...props} />
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({className, ...props}, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(({className, ...props}, ref) => (
<h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({className, ...props}, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({className, ...props}, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({className, ...props}, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
));
CardFooter.displayName = 'CardFooter';
export {Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent};

View File

@@ -0,0 +1,124 @@
import * as React from 'react';
import {cn} from '@/lib/utils';
import {X} from 'lucide-react';
interface DialogContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DialogContext = React.createContext<DialogContextValue | undefined>(undefined);
const useDialog = () => {
const context = React.useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within a Dialog');
}
return context;
};
interface DialogProps {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const Dialog = ({children, open: controlledOpen, onOpenChange}: DialogProps) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen;
const setOpen = onOpenChange || setUncontrolledOpen;
return <DialogContext.Provider value={{open, onOpenChange: setOpen}}>{children}</DialogContext.Provider>;
};
const DialogTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(({className, onClick, ...props}, ref) => {
const {onOpenChange} = useDialog();
return (
<button
ref={ref}
className={className}
onClick={e => {
onOpenChange(true);
onClick?.(e);
}}
{...props}
/>
);
});
DialogTrigger.displayName = 'DialogTrigger';
const DialogPortal = ({children}: {children: React.ReactNode}) => {
return <>{children}</>;
};
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({className, ...props}, ref) => {
const {onOpenChange} = useDialog();
return (
<div
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
onClick={() => onOpenChange(false)}
{...props}
/>
);
});
DialogOverlay.displayName = 'DialogOverlay';
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({className, children, ...props}, ref) => {
const {open, onOpenChange} = useDialog();
if (!open) return null;
return (
<DialogPortal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<DialogOverlay />
<div
ref={ref}
className={cn(
'relative z-50 grid w-full max-w-lg gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className
)}
onClick={e => e.stopPropagation()}
{...props}>
{children}
<button
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</div>
</DialogPortal>
);
});
DialogContent.displayName = 'DialogContent';
const DialogHeader = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(({className, ...props}, ref) => (
<h2 ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
));
DialogTitle.displayName = 'DialogTitle';
const DialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({className, ...props}, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
DialogDescription.displayName = 'DialogDescription';
export {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription};

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import {cn} from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({className, type, ...props}, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = 'Input';
export {Input};

View File

@@ -0,0 +1,81 @@
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',
}
];
export const useArtwork = () => {
const [artworks, setArtworks] = useState<Artwork[]>([]);
const [loading, setLoading] = useState(true);
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));
}
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));
};
return {
artworks,
loading,
addArtwork,
deleteArtwork
};
};

View File

@@ -0,0 +1,43 @@
import {useState, useEffect} from 'react';
import type {User} from '@/types';
export const useAuth = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user is logged in (from localStorage)
const storedUser = localStorage.getItem('user');
if (storedUser) {
setUser(JSON.parse(storedUser));
}
setLoading(false);
}, []);
const login = (email: string, password: string) => {
// Simple authentication (in production, use a real backend)
if (email && password) {
const newUser: User = {
email,
isAuthenticated: true
};
localStorage.setItem('user', JSON.stringify(newUser));
setUser(newUser);
return true;
}
return false;
};
const logout = () => {
localStorage.removeItem('user');
setUser(null);
};
return {
user,
loading,
login,
logout,
isAuthenticated: !!user?.isAuthenticated
};
};

59
frontend/src/index.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,6 @@
import {type ClassValue, clsx} from 'clsx';
import {twMerge} from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,11 @@
export interface Artwork {
id: string;
title: string;
description: string;
imageUrl: string;
}
export interface User {
email: string;
isAuthenticated: boolean;
}