Add rate limiting, security headers, fix CORS for production
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
import { Heart, CheckCircle } from 'lucide-react';
|
||||
import type { Campaign } from '@/lib/api';
|
||||
|
||||
const AMOUNTS = [10, 25, 50, 100, 250];
|
||||
|
||||
export default function DonationCard({ campaign }: { campaign: Campaign }) {
|
||||
const [selected, setSelected] = useState(25);
|
||||
const [custom, setCustom] = useState('');
|
||||
const [donated, setDonated] = useState(false);
|
||||
const pct = Math.min(100, Math.round((campaign.raised / campaign.goal) * 100));
|
||||
|
||||
const handleDonate = () => {
|
||||
// Stripe would go here
|
||||
setDonated(true);
|
||||
setTimeout(() => setDonated(false), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surfaceGreen border border-white/5 rounded-2xl p-7 hover:border-gold/20 transition-all space-y-6">
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-xl">{campaign.title}</h3>
|
||||
<p className="text-sm text-stone-400 leading-relaxed mt-2">{campaign.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-semibold">
|
||||
<span className="text-gold">${campaign.raised.toLocaleString()} raised</span>
|
||||
<span className="text-stone-500">Goal: ${campaign.goal.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-black/40 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-teal to-gold rounded-full transition-all duration-1000"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 font-medium text-right">{pct}% complete</p>
|
||||
</div>
|
||||
|
||||
{/* Amount selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{AMOUNTS.map((amt) => (
|
||||
<button
|
||||
key={amt}
|
||||
onClick={() => { setSelected(amt); setCustom(''); }}
|
||||
className={`py-2 rounded-lg text-sm font-bold transition-all ${
|
||||
selected === amt && !custom
|
||||
? 'bg-gold text-deepGreen shadow-lg shadow-gold/20'
|
||||
: 'bg-black/40 text-stone-300 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
${amt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={custom}
|
||||
onChange={(e) => { setCustom(e.target.value); setSelected(0); }}
|
||||
placeholder="Custom amount"
|
||||
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder:text-stone-600 focus:outline-none focus:border-teal/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDonate}
|
||||
className={`w-full py-3.5 rounded-xl font-bold text-sm flex items-center justify-center gap-2 transition-all ${
|
||||
donated
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-gold text-deepGreen hover:bg-goldLight shadow-lg shadow-gold/20 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{donated ? (
|
||||
<><CheckCircle size={18} /> Thank you!</>
|
||||
) : (
|
||||
<><Heart size={16} /> Donate ${custom || selected}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface DonationPanelProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const DonationPanel: React.FC<DonationPanelProps> = ({ id }) => {
|
||||
const [amount, setAmount] = useState<number>(25);
|
||||
const [donated, setDonated] = useState<boolean>(false);
|
||||
const [donationInProgress, setDonationInProgress] = useState<boolean>(false);
|
||||
|
||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value)) {
|
||||
setAmount(value);
|
||||
} else {
|
||||
setAmount(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDonate = () => {
|
||||
if (amount > 0) {
|
||||
setDonationInProgress(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
setDonated(true);
|
||||
setDonationInProgress(false);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const predefinedAmounts = [10, 25, 50, 100];
|
||||
|
||||
return (
|
||||
<Card className="border-ccfw-teal/30 bg-gradient-to-b from-ccfw-beige/20 to-ccfw-beige/5 backdrop-blur-sm">
|
||||
<CardHeader className="border-b border-ccfw-teal/20 bg-ccfw-beige/10">
|
||||
<CardTitle className="text-ccfw-teal">Support Wildlife</CardTitle>
|
||||
<CardDescription className="text-ccfw-maroon font-medium">
|
||||
Help protect the wildlife featured in Livestream {id}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{!donated ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-ccfw-maroon font-medium">Your donation helps protect and preserve the habitats of these amazing creatures.</p>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 my-4">
|
||||
{predefinedAmounts.map((presetAmount) => (
|
||||
<Button
|
||||
key={presetAmount}
|
||||
variant={amount === presetAmount ? "default" : "outline"}
|
||||
className={amount === presetAmount ? "bg-ccfw-teal text-white" : "border-ccfw-teal/30 text-foreground hover:bg-ccfw-teal/10"}
|
||||
onClick={() => setAmount(presetAmount)}
|
||||
>
|
||||
${presetAmount}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<span className="text-ccfw-maroon font-medium">$</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={handleAmountChange}
|
||||
className="bg-ccfw-beige/20 border-ccfw-teal/30 text-foreground pl-7"
|
||||
placeholder="Custom amount"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<div className="h-10 w-10 rounded-full bg-ccfw-gold/20 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-ccfw-gold" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-ccfw-maroon font-medium">100% of donations go directly to CCFW conservation efforts</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDonate}
|
||||
disabled={amount <= 0 || donationInProgress}
|
||||
className="w-full bg-ccfw-teal text-white hover:bg-ccfw-teal/80 relative overflow-hidden"
|
||||
>
|
||||
{donationInProgress ? (
|
||||
<>
|
||||
<span className="opacity-0">Donate Now</span>
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
`Donate $${amount}`
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-ccfw-gold/20 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-ccfw-gold" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-ccfw-teal">Thank You!</h3>
|
||||
<p className="text-ccfw-maroon font-medium">Your donation of ${amount} will help protect Florida wildlife.</p>
|
||||
<div className="text-sm text-ccfw-maroon font-medium mt-2">
|
||||
Cape Coral Friends of Wildlife is a 501(c)(3) non-profit organization.
|
||||
All donations are tax-deductible.
|
||||
</div>
|
||||
<Button onClick={() => setDonated(false)} variant="outline" className="border-ccfw-teal/30 text-ccfw-teal mt-4 hover:bg-ccfw-teal/10">
|
||||
Make Another Donation
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DonationPanel;
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, MapPin, Users, CheckCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import type { Event } from '@/lib/api';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function EventCard({ event }: { event: Event }) {
|
||||
const [rsvpd, setRsvpd] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [showEmail, setShowEmail] = useState(false);
|
||||
const date = new Date(event.date);
|
||||
|
||||
const handleRsvp = async () => {
|
||||
if (!email) { setShowEmail(true); return; }
|
||||
try {
|
||||
await api.rsvpEvent(event.id, email);
|
||||
setRsvpd(true);
|
||||
} catch {
|
||||
setRsvpd(true); // optimistic
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surfaceGreen border border-white/5 rounded-2xl p-6 hover:border-teal/20 transition-all flex flex-col md:flex-row gap-6">
|
||||
{/* Date block */}
|
||||
<div className="shrink-0 w-20 h-20 rounded-xl bg-teal/10 border border-teal/20 flex flex-col items-center justify-center text-center">
|
||||
<span className="text-teal text-xs font-bold uppercase tracking-wider">{format(date, 'MMM')}</span>
|
||||
<span className="text-white text-3xl font-black leading-none">{format(date, 'd')}</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-3">
|
||||
<h3 className="font-bold text-white text-lg leading-tight">{event.title}</h3>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-stone-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar size={14} />
|
||||
{format(date, 'EEEE, MMMM d, yyyy')} at {format(date, 'h:mm a')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MapPin size={14} />
|
||||
{event.location}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users size={14} />
|
||||
{event.rsvpCount} registered{event.capacity ? ` / ${event.capacity} max` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-stone-400 leading-relaxed">{event.description}</p>
|
||||
|
||||
{/* RSVP */}
|
||||
<div className="pt-2 flex flex-col sm:flex-row gap-3">
|
||||
{showEmail && !rsvpd && (
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="flex-1 bg-black/40 border border-white/10 rounded-lg px-4 py-2 text-sm text-white placeholder:text-stone-600 focus:outline-none focus:border-teal/50"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRsvp}
|
||||
disabled={rsvpd}
|
||||
className={`px-6 py-2.5 rounded-lg font-bold text-sm flex items-center gap-2 transition-all ${
|
||||
rsvpd
|
||||
? 'bg-emerald-700/30 text-emerald-400 cursor-default'
|
||||
: 'bg-teal text-white hover:bg-tealLight active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{rsvpd ? <><CheckCircle size={16} /> Registered!</> : 'Register'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Eye, Facebook, Instagram, Youtube, Mail } from 'lucide-react';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-surfaceGreen border-t border-white/5 mt-20">
|
||||
<div className="max-w-7xl mx-auto px-6 py-16 grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
{/* Brand */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye size={24} className="text-teal" />
|
||||
<span className="font-bold text-gold text-xl">Owl Stream</span>
|
||||
</div>
|
||||
<p className="text-stone-400 text-sm leading-relaxed max-w-xs">
|
||||
Cape Coral Friends of Wildlife — protecting burrowing owls and native species in Southwest Florida.
|
||||
</p>
|
||||
<div className="flex gap-4 pt-2">
|
||||
{[Facebook, Instagram, Youtube].map((Icon, i) => (
|
||||
<a key={i} href="#" className="p-2 bg-white/5 rounded-lg text-stone-400 hover:text-teal hover:bg-teal/10 transition-all">
|
||||
<Icon size={18} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-stone-500 mb-5">Navigation</h4>
|
||||
<ul className="space-y-3">
|
||||
{[
|
||||
{ href: '/streams', label: 'Live Cams' },
|
||||
{ href: '/wildlife', label: 'Wildlife Guide' },
|
||||
{ href: '/events', label: 'Events' },
|
||||
{ href: '/donate', label: 'Donate' },
|
||||
{ href: '/volunteer', label: 'Volunteer' },
|
||||
{ href: '/about', label: 'About CCFW' },
|
||||
].map(({ href, label }) => (
|
||||
<li key={href}>
|
||||
<Link href={href} className="text-sm text-stone-400 hover:text-gold transition-colors font-medium">
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-stone-500 mb-5">Contact</h4>
|
||||
<div className="space-y-4">
|
||||
<a href="mailto:info@ccfriendsofwildlife.org" className="flex items-center gap-3 text-sm text-stone-400 hover:text-teal transition-colors">
|
||||
<Mail size={16} />
|
||||
info@ccfriendsofwildlife.org
|
||||
</a>
|
||||
<p className="text-xs text-stone-500 leading-relaxed italic">
|
||||
Cape Coral, Florida<br />
|
||||
Cape Coral Friends of Wildlife
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/5 py-6 px-6 max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] text-stone-600 uppercase tracking-widest font-bold">
|
||||
<p>© {new Date().getFullYear()} Cape Coral Friends of Wildlife. All rights reserved.</p>
|
||||
<a href="https://ccfriendsofwildlife.org" target="_blank" rel="noreferrer" className="hover:text-gold transition-colors">
|
||||
ccfriendsofwildlife.org ↗
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface LiveStreamProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Define burrowing owl info based on stream ID
|
||||
const getStreamInfo = (id: string) => {
|
||||
switch (id) {
|
||||
case "1":
|
||||
return {
|
||||
title: "Cape Coral Burrowing Owl",
|
||||
location: "Cape Coral, FL",
|
||||
fact: "The burrowing owl is the official city bird of Cape Coral. These unique owls nest underground and are active during the day."
|
||||
};
|
||||
case "2":
|
||||
return {
|
||||
title: "Burrowing Owl Habitat",
|
||||
location: "Cape Coral, FL",
|
||||
fact: "Burrowing owls prefer open areas with low vegetation and create underground burrows that provide shelter for many wildlife species."
|
||||
};
|
||||
case "3":
|
||||
return {
|
||||
title: "Owl Burrow Monitoring",
|
||||
location: "Cape Coral, FL",
|
||||
fact: "CCFW volunteers maintain over 2,500 burrows throughout Cape Coral to protect these threatened ground-dwelling owls."
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: `Burrowing Owl Cam ${id}`,
|
||||
location: "Cape Coral, FL",
|
||||
fact: "Burrowing owls are Florida's smallest owl species and are known for their distinctive long legs and daytime activity."
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Client-side component for dynamic time to avoid hydration errors
|
||||
const ClientTimeDisplay: React.FC = () => {
|
||||
const [currentTime, setCurrentTime] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
setCurrentTime(new Date().toLocaleDateString() + ' • ' + new Date().toLocaleTimeString());
|
||||
}, []);
|
||||
|
||||
return <span className="bg-ccfw-beige/50 py-1 px-2 rounded">{currentTime}</span>;
|
||||
};
|
||||
|
||||
const LiveStream: React.FC<LiveStreamProps> = ({ id }) => {
|
||||
// This would be determined by your backend in a real app
|
||||
const isLive = id !== "3"; // Let's assume stream #3 is offline for testing
|
||||
|
||||
const streamInfo = getStreamInfo(id);
|
||||
// Use a fixed viewer count to avoid hydration errors
|
||||
const viewerCount = isLive ? (id === "1" ? 128 : id === "2" ? 86 : 75) : 0;
|
||||
|
||||
return (
|
||||
<Card className="border-ccfw-teal/30 bg-gradient-to-b from-ccfw-beige/20 to-ccfw-beige/5 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2 border-b border-ccfw-teal/20">
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-ccfw-teal">{streamInfo.title}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-3 w-3 rounded-full ${isLive ? 'bg-ccfw-coral animate-pulse' : 'bg-gray-500'}`}></div>
|
||||
<span className="text-sm font-medium text-ccfw-maroon font-semibold">{isLive ? 'LIVE' : 'OFFLINE'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="bg-black aspect-video flex flex-col items-center justify-center rounded-md border border-ccfw-teal/30 relative overflow-hidden">
|
||||
{isLive ? (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-ccfw-teal/5 to-transparent"></div>
|
||||
|
||||
{/* Overlay for wildlife stream info */}
|
||||
<div className="absolute top-0 left-0 w-full bg-black/50 p-2 flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<div className="h-2 w-2 rounded-full bg-ccfw-coral animate-pulse mr-2"></div>
|
||||
<span className="text-xs text-white">CCFW Wildlife Stream</span>
|
||||
</div>
|
||||
<div className="text-xs text-white bg-ccfw-teal/80 px-2 py-1 rounded">
|
||||
HD
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="z-10 flex flex-col items-center">
|
||||
<div className="bg-black/60 px-4 py-2 rounded-lg border border-ccfw-teal/30">
|
||||
<p className="text-white font-medium">{streamInfo.location} • Cape Coral Friends of Wildlife</p>
|
||||
<p className="text-xs text-white mt-2">HD Video • Live from Florida</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 right-4 bg-black/70 text-white text-xs px-2 py-1 rounded flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1 text-ccfw-coral" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
{viewerCount}
|
||||
</div>
|
||||
|
||||
{/* Stream controls overlay */}
|
||||
<div className="absolute bottom-0 left-0 w-full bg-gradient-to-t from-black to-transparent pt-8 pb-2 px-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex space-x-3">
|
||||
<button className="text-white hover:text-ccfw-coral transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15.536a5 5 0 001.414-7.071m-2.829 9.9a9 9 0 010-12.728" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="text-white hover:text-ccfw-coral transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-white">
|
||||
Powered by CCFW
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="bg-black/60 p-4 rounded-lg border border-ccfw-teal/30">
|
||||
<div className="flex items-center mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10 text-gray-600 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-white font-medium">Stream currently offline</p>
|
||||
<p className="text-xs text-white/80">Will return soon. Check back later.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-white text-center">
|
||||
<a
|
||||
href="https://ccfriendsofwildlife.org/events-and-programs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-ccfw-coral transition-colors"
|
||||
>
|
||||
View upcoming livestream schedule
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between items-center">
|
||||
<div className="text-xs text-ccfw-maroon/70">
|
||||
<ClientTimeDisplay />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="text-ccfw-teal hover:text-ccfw-coral transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="text-ccfw-teal hover:text-ccfw-coral transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
href="https://ccfriendsofwildlife.org/events-and-programs/"
|
||||
target="_blank"
|
||||
className="text-ccfw-teal hover:text-ccfw-coral transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wildlife fact */}
|
||||
<div className="mt-3 bg-ccfw-beige/10 p-3 rounded-md border border-ccfw-teal/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-ccfw-gold flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-ccfw-maroon font-medium leading-relaxed">
|
||||
<span className="font-semibold text-ccfw-teal">Wildlife Fact:</span> {streamInfo.fact}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveStream;
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Menu, X, Eye, Tv2, TreePine, Heart, Calendar, Info } from 'lucide-react';
|
||||
|
||||
const links = [
|
||||
{ href: '/streams', label: 'Live Cams', icon: Tv2 },
|
||||
{ href: '/wildlife', label: 'Wildlife', icon: TreePine },
|
||||
{ href: '/events', label: 'Events', icon: Calendar },
|
||||
{ href: '/donate', label: 'Donate', icon: Heart },
|
||||
{ href: '/about', label: 'About', icon: Info },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setScrolled(window.scrollY > 40);
|
||||
window.addEventListener('scroll', handler);
|
||||
return () => window.removeEventListener('scroll', handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-50 w-full transition-all duration-300 ${
|
||||
scrolled
|
||||
? 'bg-deepGreen/95 backdrop-blur-md shadow-lg shadow-black/30 border-b border-white/5'
|
||||
: 'bg-deepGreen border-b border-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between gap-6">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2 shrink-0 group">
|
||||
<div className="w-9 h-9 rounded-full bg-teal/20 flex items-center justify-center border border-teal/30 group-hover:border-teal/60 transition-all">
|
||||
<Eye size={20} className="text-teal" />
|
||||
</div>
|
||||
<div className="flex flex-col leading-none">
|
||||
<span className="text-gold font-bold text-base tracking-tight">Owl Stream</span>
|
||||
<span className="text-stone-400 text-[10px] font-medium tracking-widest uppercase">CCFW</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{links.map(({ href, label }) => {
|
||||
const active = pathname === href || pathname.startsWith(href + '/');
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||
active
|
||||
? 'bg-teal/20 text-teal'
|
||||
: 'text-stone-300 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Donate CTA (desktop) */}
|
||||
<Link
|
||||
href="/donate"
|
||||
className="hidden md:inline-flex items-center gap-2 px-5 py-2 rounded-full bg-gold text-deepGreen font-bold text-sm hover:bg-goldLight transition-all shadow-lg shadow-gold/20 shrink-0"
|
||||
>
|
||||
<Heart size={14} />
|
||||
Support CCFW
|
||||
</Link>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
className="md:hidden p-2 rounded-lg text-stone-300 hover:bg-white/10 transition-all"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{open ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{open && (
|
||||
<div className="md:hidden bg-surfaceGreen border-t border-white/5 px-4 py-4 space-y-1">
|
||||
{links.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname === href;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all ${
|
||||
active ? 'bg-teal/20 text-teal' : 'text-stone-300 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="pt-3 border-t border-white/5">
|
||||
<Link
|
||||
href="/donate"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center justify-center gap-2 w-full py-3 rounded-xl bg-gold text-deepGreen font-bold text-sm"
|
||||
>
|
||||
<Heart size={16} /> Support CCFW
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface OwlInfoProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const OwlInfo: React.FC<OwlInfoProps> = ({ id }) => {
|
||||
const [activeTab, setActiveTab] = useState<'facts' | 'habitat' | 'conservation'>('facts');
|
||||
|
||||
// Get burrowing owl data based on stream ID
|
||||
const getWildlifeData = () => {
|
||||
switch (id) {
|
||||
case "1":
|
||||
return {
|
||||
species: "Burrowing Owl",
|
||||
scientificName: "Athene cunicularia",
|
||||
location: "Cape Coral, FL",
|
||||
facts: [
|
||||
"Burrowing owls are small, long-legged owls that nest underground in burrows",
|
||||
"Unlike most owls, they are active during the day (diurnal)",
|
||||
"They stand about 9 inches tall and have bright yellow eyes",
|
||||
"The City of Cape Coral has designated the burrowing owl as its official city bird"
|
||||
],
|
||||
habitat: "Cape Coral has the largest population of burrowing owls in Florida. They prefer open areas with low vegetation such as prairies, grasslands, and open areas of urban development. CCFW volunteers maintain over 2,500 burrows throughout Cape Coral.",
|
||||
conservation: "Burrowing owls are listed as a state-threatened species in Florida. Development of their habitats is the biggest threat to their survival. CCFW works to protect and maintain burrows, educate the public, and collaborate with local authorities to ensure these birds have safe places to nest.",
|
||||
ccfwLink: "https://ccfriendsofwildlife.org/burrowing-owls/"
|
||||
};
|
||||
case "2":
|
||||
return {
|
||||
species: "Burrowing Owl",
|
||||
scientificName: "Athene cunicularia",
|
||||
location: "Cape Coral, FL",
|
||||
facts: [
|
||||
"Burrowing owls create underground burrows that can be up to 30 feet long",
|
||||
"They often use burrows created by other animals like prairie dogs or armadillos",
|
||||
"These owls are known for their distinctive 'bobblehead' behavior when curious",
|
||||
"They can live up to 9 years in the wild with proper habitat protection"
|
||||
],
|
||||
habitat: "Burrowing owls prefer open, grassy areas with sparse vegetation. They are commonly found in prairies, agricultural fields, and urban areas with suitable open spaces. The owls dig their own burrows or modify existing ones.",
|
||||
conservation: "Habitat loss from urban development is the primary threat to burrowing owls. CCFW's burrow maintenance program helps protect existing burrows and creates artificial burrows to support the owl population in Cape Coral.",
|
||||
ccfwLink: "https://ccfriendsofwildlife.org/burrowing-owls/"
|
||||
};
|
||||
case "3":
|
||||
return {
|
||||
species: "Burrowing Owl",
|
||||
scientificName: "Athene cunicularia",
|
||||
location: "Cape Coral, FL",
|
||||
facts: [
|
||||
"Burrowing owls are Florida's smallest owl species",
|
||||
"They have long legs adapted for walking and running on the ground",
|
||||
"Their diet consists mainly of insects, small mammals, and reptiles",
|
||||
"They are the only owl species that nests exclusively underground"
|
||||
],
|
||||
habitat: "These unique owls inhabit open grasslands, pastures, and urban areas with low vegetation. They are particularly well-adapted to the Florida landscape and have thrived in areas where other wildlife has declined.",
|
||||
conservation: "CCFW volunteers monitor and maintain over 2,500 burrows in Cape Coral. The organization's educational programs help the community understand the importance of protecting these threatened birds and their habitats.",
|
||||
ccfwLink: "https://ccfriendsofwildlife.org/burrowing-owls/"
|
||||
};
|
||||
default:
|
||||
return {
|
||||
species: "Burrowing Owl",
|
||||
scientificName: "Athene cunicularia",
|
||||
location: "Cape Coral, FL",
|
||||
facts: [
|
||||
"Burrowing owls are the official city bird of Cape Coral",
|
||||
"They are diurnal, meaning they are active during the day",
|
||||
"These owls have distinctive long legs and bright yellow eyes",
|
||||
"CCFW maintains over 2,500 burrows to protect this threatened species"
|
||||
],
|
||||
habitat: "Cape Coral provides ideal habitat for burrowing owls with its mix of urban development and open spaces. The city has the largest population of burrowing owls in Florida due to successful conservation efforts.",
|
||||
conservation: "Cape Coral Friends of Wildlife works tirelessly to protect burrowing owls through habitat preservation, burrow maintenance, public education, and collaboration with local authorities.",
|
||||
ccfwLink: "https://ccfriendsofwildlife.org/burrowing-owls/"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const wildlifeData = getWildlifeData();
|
||||
|
||||
return (
|
||||
<Card className="border-ccfw-teal/30 bg-gradient-to-b from-ccfw-beige/20 to-ccfw-beige/5 backdrop-blur-sm">
|
||||
<CardHeader className="border-b border-ccfw-teal/20 bg-ccfw-beige/10">
|
||||
<CardTitle className="text-ccfw-teal">About {wildlifeData.species}</CardTitle>
|
||||
<CardDescription className="text-ccfw-maroon font-medium italic">
|
||||
{wildlifeData.scientificName && (
|
||||
<>{wildlifeData.scientificName} • </>
|
||||
)}
|
||||
{wildlifeData.location}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<div className="flex space-x-1 bg-ccfw-beige/10 p-1 rounded-md">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`flex-1 text-xs h-8 ${activeTab === 'facts' ? 'bg-ccfw-teal/20 text-ccfw-teal' : 'text-ccfw-maroon hover:text-ccfw-teal hover:bg-ccfw-teal/10'}`}
|
||||
onClick={() => setActiveTab('facts')}
|
||||
>
|
||||
Facts
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`flex-1 text-xs h-8 ${activeTab === 'habitat' ? 'bg-ccfw-teal/20 text-ccfw-teal' : 'text-ccfw-maroon hover:text-ccfw-teal hover:bg-ccfw-teal/10'}`}
|
||||
onClick={() => setActiveTab('habitat')}
|
||||
>
|
||||
Habitat
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`flex-1 text-xs h-8 ${activeTab === 'conservation' ? 'bg-ccfw-teal/20 text-ccfw-teal' : 'text-ccfw-maroon hover:text-ccfw-teal hover:bg-ccfw-teal/10'}`}
|
||||
onClick={() => setActiveTab('conservation')}
|
||||
>
|
||||
Conservation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[180px]">
|
||||
{activeTab === 'facts' && (
|
||||
<div className="space-y-2">
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-ccfw-maroon font-medium">
|
||||
{wildlifeData.facts.map((fact, index) => (
|
||||
<li key={index}>{fact}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'habitat' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-ccfw-maroon font-medium">{wildlifeData.habitat}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'conservation' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-ccfw-maroon font-medium">{wildlifeData.conservation}</p>
|
||||
|
||||
<div className="bg-ccfw-beige/10 p-3 rounded-md border border-ccfw-teal/20 flex flex-col sm:flex-row items-center gap-3">
|
||||
<div className="bg-ccfw-gold/20 p-2 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-ccfw-gold" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center sm:text-left">
|
||||
<p className="text-sm text-ccfw-maroon font-medium">
|
||||
<span className="font-medium text-ccfw-teal">How you can help:</span> Join the Cape Coral Friends of Wildlife in their mission to preserve and protect these incredible creatures.
|
||||
</p>
|
||||
<a
|
||||
href="https://ccfriendsofwildlife.org/volunteer/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-ccfw-teal hover:text-ccfw-coral transition-colors"
|
||||
>
|
||||
Learn about volunteer opportunities
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-ccfw-teal/20 flex justify-between items-center">
|
||||
<span className="text-xs text-ccfw-maroon font-medium">Updated daily</span>
|
||||
<a
|
||||
href={wildlifeData.ccfwLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ccfw-teal text-xs hover:text-ccfw-coral transition-colors flex items-center gap-1"
|
||||
>
|
||||
Learn More
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default OwlInfo;
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Eye, Home, Clock, Tv2 } from 'lucide-react';
|
||||
import type { Stat } from '@/lib/api';
|
||||
|
||||
const items = [
|
||||
{ key: 'owlCount', label: 'Owls Tracked', icon: Eye, unit: '' },
|
||||
{ key: 'burrowCount', label: 'Active Burrows', icon: Home, unit: '' },
|
||||
{ key: 'volunteerHours', label: 'Volunteer Hours', icon: Clock, unit: 'hrs' },
|
||||
{ key: 'activeStreams', label: 'Live Cams', icon: Tv2, unit: '' },
|
||||
] as const;
|
||||
|
||||
export default function StatsBar({ stats }: { stats: Stat }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-white/5 rounded-2xl overflow-hidden border border-white/5">
|
||||
{items.map(({ key, label, icon: Icon, unit }) => (
|
||||
<div key={key} className="bg-surfaceGreen px-6 py-7 flex flex-col items-center text-center gap-2">
|
||||
<Icon size={22} className="text-teal opacity-80" />
|
||||
<div className="text-3xl font-black text-gold tabular-nums">
|
||||
{stats[key].toLocaleString()}{unit}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-stone-400 uppercase tracking-widest">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Radio, Users, MapPin } from 'lucide-react';
|
||||
import type { Stream } from '@/lib/api';
|
||||
|
||||
export default function StreamCard({ stream }: { stream: Stream }) {
|
||||
return (
|
||||
<Link href={`/streams/${stream.id}`} className="group block">
|
||||
<div className="bg-surfaceGreen border border-white/5 rounded-2xl overflow-hidden hover:border-teal/30 transition-all hover:shadow-xl hover:shadow-teal/5">
|
||||
{/* Thumbnail area */}
|
||||
<div className="relative aspect-video bg-black/40 overflow-hidden">
|
||||
{stream.thumbnailUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={stream.thumbnailUrl}
|
||||
alt={stream.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Radio size={48} className="text-stone-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<div className={`absolute top-3 left-3 flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${
|
||||
stream.status === 'live'
|
||||
? 'bg-red-500/90 text-white'
|
||||
: 'bg-black/60 text-stone-300'
|
||||
}`}>
|
||||
{stream.status === 'live' && <span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />}
|
||||
{stream.status === 'live' ? 'Live' : 'Offline'}
|
||||
</div>
|
||||
|
||||
{/* Viewer count */}
|
||||
{stream.viewerCount > 0 && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 bg-black/60 px-2 py-1 rounded-full text-xs text-stone-200 font-semibold">
|
||||
<Users size={12} />
|
||||
{stream.viewerCount.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-5 space-y-2">
|
||||
<h3 className="font-bold text-white group-hover:text-teal transition-colors text-base leading-snug">
|
||||
{stream.name}
|
||||
</h3>
|
||||
{stream.location && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-stone-400 font-medium">
|
||||
<MapPin size={12} />
|
||||
{stream.location}
|
||||
</div>
|
||||
)}
|
||||
{stream.description && (
|
||||
<p className="text-sm text-stone-400 line-clamp-2 leading-relaxed">{stream.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Shield } from 'lucide-react';
|
||||
import type { Wildlife } from '@/lib/api';
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
'Threatened': 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
'Protected': 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
'Least Concern': 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
'Endangered': 'bg-red-700/30 text-red-300 border-red-600/30',
|
||||
};
|
||||
|
||||
export default function WildlifeCard({ species }: { species: Wildlife }) {
|
||||
const colors = statusColor[species.status] ?? 'bg-stone-500/20 text-stone-400 border-stone-500/30';
|
||||
return (
|
||||
<div className="bg-surfaceGreen border border-white/5 rounded-2xl overflow-hidden hover:border-gold/20 transition-all group">
|
||||
{/* Image */}
|
||||
<div className="aspect-[4/3] bg-black/40 overflow-hidden">
|
||||
{species.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={species.imageUrl}
|
||||
alt={species.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-6xl">🦉</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
{/* Status */}
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border ${colors}`}>
|
||||
<Shield size={10} />
|
||||
{species.status}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-lg leading-tight">{species.name}</h3>
|
||||
<p className="text-xs text-stone-500 italic mt-0.5">{species.scientificName}</p>
|
||||
</div>
|
||||
<p className="text-sm text-stone-400 leading-relaxed line-clamp-3">{species.description}</p>
|
||||
<div className="pt-2 border-t border-white/5">
|
||||
<p className="text-xs text-stone-500 font-medium">
|
||||
<span className="text-stone-400">Habitat: </span>{species.habitat}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user