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>
This commit is contained in:
Yuriy Panov
2026-02-20 12:00:09 +06:00
commit b210e4d2fb
61 changed files with 3332 additions and 0 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.git

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

22
Dockerfile Normal file
View 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
View File

@@ -0,0 +1,6 @@
services:
fallback-site:
build: .
ports:
- "8081:80"
restart: unless-stopped

14
index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View 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
View File

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View 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
View 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">
&copy; 2024 Amber Valley Community Hub. All rights reserved.
</div>
</div>
</footer>
{/* Auth modal */}
{authOpen && <AuthModal onClose={() => setAuthOpen(false)} />}
</div>
)
}

View 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&apos;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>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})