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

4
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM nginx
COPY dist/ /usr/share/nginx/html
EXPOSE 80

134
frontend/README.md Normal file
View File

@@ -0,0 +1,134 @@
# Albert Jeffers - Artist Portfolio
A beautiful, modern portfolio website for visual artists to showcase their work with authentication and image upload capabilities.
## Features
- **Stunning Gallery Layout**: Responsive grid layout with hover effects and smooth transitions
- **Artist Authentication**: Secure login system for the artist to manage their portfolio
- **Image Upload**: Upload artwork via file upload or URL with live preview
- **Artwork Management**: Add, view, and delete artworks when authenticated
- **Responsive Design**: Fully responsive layout that works beautifully on all devices
- **Modern UI**: Built with shadcn/ui components for a polished, professional look
- **Dark/Light Mode Support**: CSS variables for easy theme customization
## Tech Stack
- **React 18** with TypeScript
- **Vite** for fast development and optimized builds
- **Tailwind CSS** for utility-first styling
- **shadcn/ui** component library (NOT Bootstrap)
- **Lucide React** for beautiful icons
- **Local Storage** for data persistence (easily replaceable with a backend)
## Getting Started
### Prerequisites
- Node.js 18+ or Bun
- npm, yarn, or pnpm
### Installation
```bash
# Install dependencies
npm install
# Start development server
npm run dev
```
The application will be available at `http://localhost:5173`
### Build for Production
```bash
npm run build
npm run preview
```
## Usage
### For Visitors
- Browse the gallery of artworks
- Click on any artwork to view full details
- Read about the artist in the About section
### For the Artist
1. Click "Artist Login" in the header
2. Enter your credentials (any email/password for demo)
3. Once logged in, you can:
- Add new artworks using the "Add Artwork" button
- Delete existing artworks using the trash icon
- Upload images via file upload or URL
## Project Structure
```
src/
├── components/ # React components
│ ├── ui/ # shadcn/ui components
│ ├── Header.tsx # Navigation header
│ ├── Hero.tsx # Hero section
│ ├── Gallery.tsx # Gallery grid
│ ├── ArtworkCard.tsx # Individual artwork card
│ ├── ArtworkDetail.tsx # Artwork detail modal
│ ├── LoginDialog.tsx # Login modal
│ └── AddArtworkDialog.tsx # Add artwork modal
├── hooks/ # Custom React hooks
│ ├── useAuth.ts # Authentication logic
│ └── useArtwork.ts # Artwork management
├── lib/ # Utilities
│ └── utils.ts # cn() helper for classnames
├── types/ # TypeScript types
│ └── index.ts # Shared type definitions
├── App.tsx # Main application
└── main.tsx # Application entry point
```
## Customization
### Artist Information
Edit the about section in `src/App.tsx` to customize the artist's bio.
### Sample Artworks
Default artworks are defined in `src/hooks/useArtwork.ts`. Replace with your own images.
### Colors and Theme
Modify the CSS variables in `src/index.css` to change the color scheme.
### Adding a Backend
Currently uses localStorage for simplicity. To add a backend:
1. Update `src/hooks/useAuth.ts` to call your authentication API
2. Update `src/hooks/useArtwork.ts` to fetch/save artworks to your database
3. Consider using services like:
- Supabase (database + authentication + storage)
- Firebase
- AWS Amplify
- Your custom backend API
## Future Enhancements
- [ ] Image optimization and CDN integration
- [ ] Categories/tags for artworks
- [ ] Contact form
- [ ] Social media integration
- [ ] Image lightbox with zoom
- [ ] Admin dashboard with analytics
- [ ] Multiple artist support
- [ ] Comments/testimonials section
## License
MIT
## Credits
Built with React, TypeScript, Vite, Tailwind CSS, and shadcn/ui

18
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,18 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import {defineConfig, globalIgnores} from 'eslint/config';
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs['recommended-latest'], reactRefresh.configs.vite],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser
}
}
]);

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>painter-portfolio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "painter-portfolio",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"ci-build:dist": "npm run build",
"dev": "vite",
"prebuild": "npm run lint",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"preview": "vite preview",
"start": "vite preview --host"
},
"dependencies": {
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "0.545.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"tailwind-merge": "3.3.1",
"tailwindcss-animate": "1.0.7"
},
"devDependencies": {
"@eslint/js": "9.36.0",
"@types/node": "24.7.2",
"@types/react": "19.1.16",
"@types/react-dom": "19.1.9",
"@vitejs/plugin-react": "5.0.4",
"autoprefixer": "10.4.21",
"eslint": "9.36.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.22",
"globals": "16.4.0",
"postcss": "8.5.6",
"prettier": "3.6.2",
"tailwindcss": "3.4.18",
"typescript": "~5.9.3",
"typescript-eslint": "8.45.0",
"vite": "7.1.7"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

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;
}

View File

@@ -0,0 +1,50 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
}
}
},
plugins: [require('tailwindcss-animate')]
};

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

4
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{"path": "./tsconfig.app.json"}, {"path": "./tsconfig.node.json"}]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
assetsInclude: ['**/*.png', '**/*.jpg', '**/*.jpeg', '**/*.gif', '**/*.svg'],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
allowedHosts: ['albertjeffersstudios.com']
},
preview: {
allowedHosts: ['albertjeffersstudios.com']
}
});