Initial commit: Amber Valley Community Hub fallback site
Vite + React 18 + Tailwind CSS community events video library with 42 topic-matched thumbnails, auth modal that always fails, and Docker/nginx setup on port 8081. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run generate-thumbnails
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# SPA fallback
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
server_name _; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
location / { \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
6
docker-compose.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
fallback-site:
|
||||
build: .
|
||||
ports:
|
||||
- "8081:80"
|
||||
restart: unless-stopped
|
||||
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/thumbnails/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Amber Valley Community Hub - Video Library</title>
|
||||
<meta name="description" content="Watch community events, workshops, and activities from Amber Valley Community Hub." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2714
package-lock.json
generated
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "amber-valley-community-hub",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"generate-thumbnails": "node scripts/generate-thumbnails.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
public/thumbnails/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#d97706"/>
|
||||
<text x="16" y="22" text-anchor="middle" fill="white" font-size="18" font-family="sans-serif" font-weight="bold">AV</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 247 B |
BIN
public/thumbnails/thumb-01.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/thumbnails/thumb-02.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
public/thumbnails/thumb-03.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/thumbnails/thumb-04.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/thumbnails/thumb-05.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/thumbnails/thumb-06.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/thumbnails/thumb-07.jpg
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/thumbnails/thumb-08.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/thumbnails/thumb-09.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/thumbnails/thumb-10.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/thumbnails/thumb-11.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/thumbnails/thumb-12.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/thumbnails/thumb-13.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/thumbnails/thumb-14.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/thumbnails/thumb-15.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/thumbnails/thumb-16.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/thumbnails/thumb-17.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/thumbnails/thumb-18.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/thumbnails/thumb-19.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/thumbnails/thumb-20.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/thumbnails/thumb-21.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/thumbnails/thumb-22.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/thumbnails/thumb-23.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/thumbnails/thumb-24.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/thumbnails/thumb-25.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/thumbnails/thumb-26.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/thumbnails/thumb-27.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/thumbnails/thumb-28.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/thumbnails/thumb-29.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/thumbnails/thumb-30.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/thumbnails/thumb-31.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
public/thumbnails/thumb-32.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/thumbnails/thumb-33.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/thumbnails/thumb-34.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/thumbnails/thumb-35.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/thumbnails/thumb-36.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/thumbnails/thumb-37.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/thumbnails/thumb-38.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/thumbnails/thumb-39.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/thumbnails/thumb-40.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/thumbnails/thumb-41.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/thumbnails/thumb-42.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
98
scripts/generate-thumbnails.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { mkdirSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = join(__dirname, '..', 'public', 'thumbnails');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// Favicon
|
||||
const favicon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#d97706"/>
|
||||
<text x="16" y="22" text-anchor="middle" fill="white" font-size="18" font-family="sans-serif" font-weight="bold">AV</text>
|
||||
</svg>`;
|
||||
writeFileSync(join(outDir, 'favicon.svg'), favicon);
|
||||
|
||||
// Each entry: [index, search keywords] — keywords chosen to match the video title
|
||||
const thumbnails = [
|
||||
[1, 'jazz,concert,stage'],
|
||||
[2, 'pasta,cooking,italian'],
|
||||
[3, 'soccer,football,tournament'],
|
||||
[4, 'watercolor,painting,art'],
|
||||
[5, 'python,programming,laptop'],
|
||||
[6, 'garden,spring,flowers'],
|
||||
[7, 'book,reading,library'],
|
||||
[8, 'acoustic,guitar,microphone'],
|
||||
[9, 'thai,food,cooking'],
|
||||
[10, 'basketball,court,game'],
|
||||
[11, 'oil,painting,landscape'],
|
||||
[12, 'web,development,code'],
|
||||
[13, 'rose,pruning,garden'],
|
||||
[14, 'science,fiction,books'],
|
||||
[15, 'blues,folk,music'],
|
||||
[16, 'pastry,baking,french'],
|
||||
[17, 'table,tennis,pingpong'],
|
||||
[18, 'digital,art,illustration'],
|
||||
[19, 'cloud,computing,server'],
|
||||
[20, 'compost,soil,garden'],
|
||||
[21, 'mystery,detective,book'],
|
||||
[22, 'piano,recital,concert'],
|
||||
[23, 'sushi,japanese,food'],
|
||||
[24, 'volleyball,beach,sport'],
|
||||
[25, 'pottery,ceramics,clay'],
|
||||
[26, 'artificial,intelligence,robot'],
|
||||
[27, 'herbs,garden,plants'],
|
||||
[28, 'classic,literature,novel'],
|
||||
[29, 'latin,dance,salsa'],
|
||||
[30, 'bread,baking,dough'],
|
||||
[31, 'badminton,racket,sport'],
|
||||
[32, 'sketch,drawing,pencil'],
|
||||
[33, 'cybersecurity,hacker,lock'],
|
||||
[34, 'winter,vegetables,growing'],
|
||||
[35, 'fantasy,tolkien,books'],
|
||||
[36, 'choir,singing,holiday'],
|
||||
[37, 'curry,indian,spices'],
|
||||
[38, 'running,marathon,race'],
|
||||
[39, 'photography,camera,walk'],
|
||||
[40, 'blockchain,crypto,technology'],
|
||||
[41, 'indoor,plants,houseplant'],
|
||||
[42, 'poetry,reading,writing'],
|
||||
];
|
||||
|
||||
async function downloadImage(index, keywords) {
|
||||
const num = String(index).padStart(2, '0');
|
||||
const filePath = join(outDir, `thumb-${num}.jpg`);
|
||||
|
||||
// loremflickr serves real Creative Commons photos matching keywords
|
||||
// lock parameter ensures deterministic results
|
||||
const url = `https://loremflickr.com/640/360/${keywords}?lock=${index}`;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.length < 1000) throw new Error('Image too small, likely error');
|
||||
writeFileSync(filePath, buffer);
|
||||
console.log(` [${num}] OK (${(buffer.length / 1024).toFixed(0)} KB) — ${keywords}`);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt < 2) {
|
||||
console.log(` [${num}] Retry ${attempt + 1}...`);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
} else {
|
||||
console.error(` [${num}] FAILED: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Downloading 42 topic-matched thumbnails from loremflickr.com...\n');
|
||||
|
||||
const BATCH = 4;
|
||||
for (let i = 0; i < thumbnails.length; i += BATCH) {
|
||||
const batch = thumbnails.slice(i, i + BATCH);
|
||||
await Promise.all(batch.map(([idx, kw]) => downloadImage(idx, kw)));
|
||||
}
|
||||
|
||||
console.log('\nDone! 42 topic-matched thumbnails in public/thumbnails/');
|
||||
110
src/App.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import VideoGrid from './components/VideoGrid'
|
||||
import AuthModal from './components/AuthModal'
|
||||
import { categories } from './data/videos'
|
||||
|
||||
export default function App() {
|
||||
const [activeCategory, setActiveCategory] = useState('All')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [authOpen, setAuthOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-amber-200 sticky top-0 z-30 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-500 flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">AV</span>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<h1 className="font-bold text-amber-900 text-lg leading-tight">Amber Valley</h1>
|
||||
<p className="text-xs text-amber-600">Community Hub</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar (decorative) */}
|
||||
<div className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search videos..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-full border border-amber-200 bg-amber-50 px-4 py-2 pl-10 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent"
|
||||
/>
|
||||
<svg className="absolute left-3 top-2.5 h-4 w-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setAuthOpen(true)}
|
||||
className="shrink-0 bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-full text-sm font-medium transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className="max-w-7xl mx-auto px-4 pb-2">
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
activeCategory === cat
|
||||
? 'bg-amber-500 text-white'
|
||||
: 'bg-amber-100 text-amber-700 hover:bg-amber-200'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
|
||||
<VideoGrid
|
||||
activeCategory={activeCategory}
|
||||
searchQuery={searchQuery}
|
||||
onVideoClick={() => setAuthOpen(true)}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-amber-900 text-amber-200 py-8 mt-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-100 mb-2">Amber Valley Community Hub</h3>
|
||||
<p className="text-sm text-amber-300">Bringing our community together through shared experiences and learning.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-100 mb-2">Quick Links</h4>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li><a href="#" onClick={(e) => { e.preventDefault(); setAuthOpen(true); }} className="hover:text-amber-100 transition-colors">My Account</a></li>
|
||||
<li><a href="#" onClick={(e) => e.preventDefault()} className="hover:text-amber-100 transition-colors">About Us</a></li>
|
||||
<li><a href="#" onClick={(e) => e.preventDefault()} className="hover:text-amber-100 transition-colors">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-100 mb-2">Community Hours</h4>
|
||||
<p className="text-sm text-amber-300">Mon-Fri: 9am - 9pm<br />Sat-Sun: 10am - 6pm</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-amber-800 mt-6 pt-4 text-xs text-amber-400 text-center">
|
||||
© 2024 Amber Valley Community Hub. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Auth modal */}
|
||||
{authOpen && <AuthModal onClose={() => setAuthOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
src/components/AuthModal.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function AuthModal({ onClose }) {
|
||||
const [mode, setMode] = useState('signin') // signin | signup
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const delay = 2000 + Math.random() * 1000
|
||||
setTimeout(() => {
|
||||
setLoading(false)
|
||||
if (mode === 'signin') {
|
||||
setError('Invalid email or password. Please try again.')
|
||||
} else {
|
||||
setError('Registration is temporarily unavailable. Please try again later.')
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 animate-slide-up">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500 flex items-center justify-center mx-auto mb-3">
|
||||
<span className="text-white font-bold text-xl">AV</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
{mode === 'signin' ? 'Welcome back' : 'Create an account'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{mode === 'signin'
|
||||
? 'Sign in to watch community videos'
|
||||
: 'Join the Amber Valley community'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
placeholder="John Doe"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-amber-500 hover:bg-amber-600 disabled:bg-amber-300 text-white font-medium py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{mode === 'signin' ? 'Signing in...' : 'Creating account...'}
|
||||
</>
|
||||
) : (
|
||||
mode === 'signin' ? 'Sign In' : 'Create Account'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Toggle mode */}
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
{mode === 'signin' ? (
|
||||
<>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
onClick={() => { setMode('signup'); setError(''); }}
|
||||
className="text-amber-600 hover:text-amber-700 font-medium"
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => { setMode('signin'); setError(''); }}
|
||||
className="text-amber-600 hover:text-amber-700 font-medium"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/VideoCard.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
export default function VideoCard({ video, index, onClick }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="group cursor-pointer rounded-xl overflow-hidden bg-white shadow-sm border border-amber-100 hover:shadow-lg hover:border-amber-300 transition-all duration-300 hover:-translate-y-1 opacity-0 animate-fade-in"
|
||||
style={{ animationDelay: `${Math.min(index * 60, 600)}ms` }}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="relative aspect-video overflow-hidden bg-amber-100">
|
||||
<img
|
||||
src={video.thumbnailPath}
|
||||
alt={video.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-1.5 py-0.5 rounded">
|
||||
{video.duration}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-sm text-gray-900 line-clamp-2 group-hover:text-amber-700 transition-colors">
|
||||
{video.title}
|
||||
</h3>
|
||||
<div className="mt-1.5 flex items-center justify-between text-xs text-gray-500">
|
||||
<span className="text-amber-600 font-medium">{video.category}</span>
|
||||
<span>{video.views.toLocaleString()} views</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{new Date(video.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/components/VideoGrid.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useMemo } from 'react'
|
||||
import VideoCard from './VideoCard'
|
||||
import videos from '../data/videos'
|
||||
|
||||
export default function VideoGrid({ activeCategory, searchQuery, onVideoClick }) {
|
||||
const filtered = useMemo(() => {
|
||||
return videos.filter((v) => {
|
||||
const matchCat = activeCategory === 'All' || v.category === activeCategory
|
||||
const matchSearch = !searchQuery || v.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchCat && matchSearch
|
||||
})
|
||||
}, [activeCategory, searchQuery])
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 text-amber-600">
|
||||
<p className="text-lg font-medium">No videos found</p>
|
||||
<p className="text-sm mt-1">Try a different search or category</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{filtered.map((video, i) => (
|
||||
<VideoCard key={video.id} video={video} index={i} onClick={onVideoClick} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
src/data/videos.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const videos = [
|
||||
{ id: 1, title: "Summer Jazz Night 2024", category: "Music Nights", date: "2024-08-15", duration: "1:24:30", views: 1243, thumbnailPath: "/thumbnails/thumb-01.jpg" },
|
||||
{ id: 2, title: "Italian Pasta Workshop", category: "Cooking Workshops", date: "2024-07-22", duration: "58:12", views: 892, thumbnailPath: "/thumbnails/thumb-02.jpg" },
|
||||
{ id: 3, title: "Youth Soccer Tournament Finals", category: "Sports Events", date: "2024-09-01", duration: "2:15:44", views: 2105, thumbnailPath: "/thumbnails/thumb-03.jpg" },
|
||||
{ id: 4, title: "Watercolor Basics for Beginners", category: "Art Classes", date: "2024-06-10", duration: "45:20", views: 634, thumbnailPath: "/thumbnails/thumb-04.jpg" },
|
||||
{ id: 5, title: "Intro to Python Programming", category: "Tech Talks", date: "2024-08-28", duration: "1:10:05", views: 1567, thumbnailPath: "/thumbnails/thumb-05.jpg" },
|
||||
{ id: 6, title: "Spring Garden Planning", category: "Gardening", date: "2024-03-12", duration: "52:18", views: 445, thumbnailPath: "/thumbnails/thumb-06.jpg" },
|
||||
{ id: 7, title: "October Book Club: Dune", category: "Book Club", date: "2024-10-05", duration: "1:35:00", views: 312, thumbnailPath: "/thumbnails/thumb-07.jpg" },
|
||||
{ id: 8, title: "Acoustic Open Mic Night", category: "Music Nights", date: "2024-09-20", duration: "2:05:11", views: 1876, thumbnailPath: "/thumbnails/thumb-08.jpg" },
|
||||
{ id: 9, title: "Thai Cooking Masterclass", category: "Cooking Workshops", date: "2024-05-18", duration: "1:02:45", views: 1023, thumbnailPath: "/thumbnails/thumb-09.jpg" },
|
||||
{ id: 10, title: "Community Basketball League", category: "Sports Events", date: "2024-07-14", duration: "1:48:30", views: 1654, thumbnailPath: "/thumbnails/thumb-10.jpg" },
|
||||
{ id: 11, title: "Oil Painting Landscapes", category: "Art Classes", date: "2024-08-03", duration: "1:15:22", views: 789, thumbnailPath: "/thumbnails/thumb-11.jpg" },
|
||||
{ id: 12, title: "Web Development with React", category: "Tech Talks", date: "2024-09-15", duration: "1:30:00", views: 2340, thumbnailPath: "/thumbnails/thumb-12.jpg" },
|
||||
{ id: 13, title: "Rose Pruning Techniques", category: "Gardening", date: "2024-04-20", duration: "38:45", views: 367, thumbnailPath: "/thumbnails/thumb-13.jpg" },
|
||||
{ id: 14, title: "Sci-Fi Reads: Foundation", category: "Book Club", date: "2024-11-02", duration: "1:22:10", views: 278, thumbnailPath: "/thumbnails/thumb-14.jpg" },
|
||||
{ id: 15, title: "Blues & Folk Evening", category: "Music Nights", date: "2024-10-18", duration: "1:55:30", views: 1432, thumbnailPath: "/thumbnails/thumb-15.jpg" },
|
||||
{ id: 16, title: "French Pastry Basics", category: "Cooking Workshops", date: "2024-06-29", duration: "1:12:08", views: 1198, thumbnailPath: "/thumbnails/thumb-16.jpg" },
|
||||
{ id: 17, title: "Table Tennis Championship", category: "Sports Events", date: "2024-08-10", duration: "1:30:15", views: 987, thumbnailPath: "/thumbnails/thumb-17.jpg" },
|
||||
{ id: 18, title: "Digital Art & Illustration", category: "Art Classes", date: "2024-09-08", duration: "1:05:40", views: 1102, thumbnailPath: "/thumbnails/thumb-18.jpg" },
|
||||
{ id: 19, title: "Cloud Computing Explained", category: "Tech Talks", date: "2024-07-25", duration: "55:30", views: 1890, thumbnailPath: "/thumbnails/thumb-19.jpg" },
|
||||
{ id: 20, title: "Composting 101", category: "Gardening", date: "2024-05-05", duration: "42:15", views: 523, thumbnailPath: "/thumbnails/thumb-20.jpg" },
|
||||
{ id: 21, title: "Mystery Month: Agatha Christie", category: "Book Club", date: "2024-09-07", duration: "1:18:30", views: 345, thumbnailPath: "/thumbnails/thumb-21.jpg" },
|
||||
{ id: 22, title: "Piano Recital Night", category: "Music Nights", date: "2024-11-22", duration: "1:40:00", views: 1678, thumbnailPath: "/thumbnails/thumb-22.jpg" },
|
||||
{ id: 23, title: "Sushi Making Workshop", category: "Cooking Workshops", date: "2024-08-05", duration: "1:25:30", views: 1456, thumbnailPath: "/thumbnails/thumb-23.jpg" },
|
||||
{ id: 24, title: "Volleyball Beach Day", category: "Sports Events", date: "2024-07-04", duration: "1:58:20", views: 1234, thumbnailPath: "/thumbnails/thumb-24.jpg" },
|
||||
{ id: 25, title: "Pottery & Ceramics Intro", category: "Art Classes", date: "2024-10-12", duration: "1:08:15", views: 654, thumbnailPath: "/thumbnails/thumb-25.jpg" },
|
||||
{ id: 26, title: "AI & Machine Learning Basics", category: "Tech Talks", date: "2024-10-30", duration: "1:20:45", views: 2567, thumbnailPath: "/thumbnails/thumb-26.jpg" },
|
||||
{ id: 27, title: "Herb Garden Setup Guide", category: "Gardening", date: "2024-06-15", duration: "35:50", views: 489, thumbnailPath: "/thumbnails/thumb-27.jpg" },
|
||||
{ id: 28, title: "Classic Literature: Jane Eyre", category: "Book Club", date: "2024-12-01", duration: "1:28:00", views: 298, thumbnailPath: "/thumbnails/thumb-28.jpg" },
|
||||
{ id: 29, title: "Latin Dance Social", category: "Music Nights", date: "2024-12-14", duration: "2:10:30", views: 1987, thumbnailPath: "/thumbnails/thumb-29.jpg" },
|
||||
{ id: 30, title: "Bread Baking From Scratch", category: "Cooking Workshops", date: "2024-09-28", duration: "1:35:20", views: 1345, thumbnailPath: "/thumbnails/thumb-30.jpg" },
|
||||
{ id: 31, title: "Badminton Doubles Tournament", category: "Sports Events", date: "2024-11-09", duration: "1:42:10", views: 876, thumbnailPath: "/thumbnails/thumb-31.jpg" },
|
||||
{ id: 32, title: "Sketch & Draw Workshop", category: "Art Classes", date: "2024-11-18", duration: "55:30", views: 712, thumbnailPath: "/thumbnails/thumb-32.jpg" },
|
||||
{ id: 33, title: "Cybersecurity for Everyone", category: "Tech Talks", date: "2024-11-25", duration: "1:05:15", views: 1765, thumbnailPath: "/thumbnails/thumb-33.jpg" },
|
||||
{ id: 34, title: "Winter Vegetable Growing", category: "Gardening", date: "2024-10-22", duration: "48:30", views: 412, thumbnailPath: "/thumbnails/thumb-34.jpg" },
|
||||
{ id: 35, title: "Fantasy Picks: Tolkien Night", category: "Book Club", date: "2025-01-10", duration: "1:45:00", views: 567, thumbnailPath: "/thumbnails/thumb-35.jpg" },
|
||||
{ id: 36, title: "Choir Holiday Concert", category: "Music Nights", date: "2024-12-22", duration: "1:30:45", views: 2234, thumbnailPath: "/thumbnails/thumb-36.jpg" },
|
||||
{ id: 37, title: "Indian Curry Workshop", category: "Cooking Workshops", date: "2024-11-15", duration: "1:18:00", views: 1089, thumbnailPath: "/thumbnails/thumb-37.jpg" },
|
||||
{ id: 38, title: "Community Fun Run 5K", category: "Sports Events", date: "2024-10-06", duration: "1:12:30", views: 1543, thumbnailPath: "/thumbnails/thumb-38.jpg" },
|
||||
{ id: 39, title: "Photography Walk & Talk", category: "Art Classes", date: "2024-12-08", duration: "1:02:20", views: 834, thumbnailPath: "/thumbnails/thumb-39.jpg" },
|
||||
{ id: 40, title: "Blockchain Demystified", category: "Tech Talks", date: "2025-01-18", duration: "58:40", views: 1432, thumbnailPath: "/thumbnails/thumb-40.jpg" },
|
||||
{ id: 41, title: "Indoor Plants Care Guide", category: "Gardening", date: "2024-12-20", duration: "40:10", views: 678, thumbnailPath: "/thumbnails/thumb-41.jpg" },
|
||||
{ id: 42, title: "New Year Poetry Reading", category: "Book Club", date: "2025-01-25", duration: "1:15:30", views: 389, thumbnailPath: "/thumbnails/thumb-42.jpg" },
|
||||
];
|
||||
|
||||
export const categories = ["All", "Music Nights", "Cooking Workshops", "Sports Events", "Art Classes", "Tech Talks", "Gardening", "Book Club"];
|
||||
|
||||
export default videos;
|
||||
15
src/index.css
Normal file
@@ -0,0 +1,15 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-amber-50 text-gray-800 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animation-delay-100 { animation-delay: 100ms; }
|
||||
.animation-delay-200 { animation-delay: 200ms; }
|
||||
.animation-delay-300 { animation-delay: 300ms; }
|
||||
}
|
||||
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
31
tailwind.config.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,jsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
||||
'slide-up': 'slideUp 0.3s ease-out forwards',
|
||||
'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(40px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
pulseSoft: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.8' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
6
vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||