Add rate limiting, security headers, fix CORS for production
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Radio } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
hlsUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
export default function VideoPlayer({ hlsUrl, thumbnailUrl }: Props) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsUrl || !videoRef.current) return;
|
||||
const video = videoRef.current;
|
||||
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = hlsUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
import('hls.js').then(({ default: Hls }) => {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(hlsUrl);
|
||||
hls.attachMedia(video);
|
||||
return () => hls.destroy();
|
||||
}
|
||||
});
|
||||
}, [hlsUrl]);
|
||||
|
||||
if (!hlsUrl) {
|
||||
return (
|
||||
<div className="relative aspect-video bg-black/40 rounded-2xl border border-white/5 flex flex-col items-center justify-center gap-4 text-stone-500">
|
||||
<Radio size={48} className="text-stone-600" />
|
||||
<p className="text-sm font-medium">Stream offline — no source available</p>
|
||||
{thumbnailUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={thumbnailUrl} alt="Stream thumbnail" className="absolute inset-0 w-full h-full object-cover rounded-2xl opacity-20" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative aspect-video rounded-2xl overflow-hidden bg-black shadow-2xl shadow-black/60">
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full"
|
||||
poster={thumbnailUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { MapPin, Users, ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { api, Campaign } from '@/lib/api';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import DonationCard from '../../components/DonationCard';
|
||||
|
||||
export default async function StreamDetailPage({ params }: { params: { id: string } }) {
|
||||
let stream;
|
||||
let campaigns: Campaign[] = [];
|
||||
try {
|
||||
[stream, campaigns] = await Promise.all([
|
||||
api.getStream(params.id),
|
||||
api.getCampaigns(),
|
||||
]);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-10 space-y-8">
|
||||
{/* Back */}
|
||||
<Link href="/streams" className="inline-flex items-center gap-2 text-stone-400 hover:text-white text-sm font-medium transition-colors">
|
||||
<ArrowLeft size={16} /> Back to all cameras
|
||||
</Link>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className={`flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold uppercase ${
|
||||
stream.status === 'live' ? 'bg-red-500/20 text-red-400' : 'bg-stone-700/40 text-stone-400'
|
||||
}`}>
|
||||
{stream.status === 'live' && <span className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />}
|
||||
{stream.status === 'live' ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
{stream.location && (
|
||||
<span className="flex items-center gap-1.5 text-stone-400 text-sm">
|
||||
<MapPin size={14} /> {stream.location}
|
||||
</span>
|
||||
)}
|
||||
{stream.viewerCount > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-stone-400 text-sm">
|
||||
<Users size={14} /> {stream.viewerCount.toLocaleString()} watching
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||
{/* Main video + info */}
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
<VideoPlayer hlsUrl={stream.hlsUrl} thumbnailUrl={stream.thumbnailUrl} />
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl font-black text-white">{stream.name}</h1>
|
||||
{stream.description && (
|
||||
<p className="text-stone-400 leading-relaxed">{stream.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wildlife facts panel */}
|
||||
<div className="bg-surfaceGreen border border-white/5 rounded-2xl p-6 space-y-4">
|
||||
<h3 className="font-bold text-gold text-sm uppercase tracking-widest">Burrowing Owl Facts</h3>
|
||||
<ul className="space-y-3 text-sm text-stone-300 leading-relaxed">
|
||||
<li className="flex gap-3"><span className="text-teal font-bold shrink-0">•</span>Burrowing owls are the official city bird of Cape Coral, FL.</li>
|
||||
<li className="flex gap-3"><span className="text-teal font-bold shrink-0">•</span>Unlike most owls, they are active during the day and nest underground.</li>
|
||||
<li className="flex gap-3"><span className="text-teal font-bold shrink-0">•</span>They are a Species of Special Concern in Florida — development threatens their habitat.</li>
|
||||
<li className="flex gap-3"><span className="text-teal font-bold shrink-0">•</span>CCFW monitors over 2,000 active burrow sites across Cape Coral.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar: Donate */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-surfaceGreen border border-white/5 rounded-2xl p-6 space-y-4">
|
||||
<h3 className="font-bold text-white">Support the Cameras</h3>
|
||||
<p className="text-stone-400 text-sm leading-relaxed">
|
||||
These cameras run 24/7 thanks to donations from wildlife lovers like you.
|
||||
</p>
|
||||
</div>
|
||||
{campaigns.slice(0, 1).map((c) => (
|
||||
<DonationCard key={c.id} campaign={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Radio } from 'lucide-react';
|
||||
import { api, Stream } from '@/lib/api';
|
||||
import StreamCard from '../components/StreamCard';
|
||||
|
||||
async function getStreams(): Promise<Stream[]> {
|
||||
try { return await api.getStreams(); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
export default async function StreamsPage() {
|
||||
const streams = await getStreams();
|
||||
const live = streams.filter((s) => s.status === 'live');
|
||||
const offline = streams.filter((s) => s.status !== 'live');
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-16 space-y-16">
|
||||
<header className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm font-bold uppercase tracking-widest">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
{live.length} camera{live.length !== 1 ? 's' : ''} live now
|
||||
</div>
|
||||
<h1 className="text-4xl font-black text-white">Wildlife Cameras</h1>
|
||||
<p className="text-stone-400 text-lg max-w-2xl">
|
||||
Free, 24/7 livestreams from Cape Coral's burrowing owl habitats and wildlife areas.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{streams.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-32 space-y-4 text-stone-500">
|
||||
<Radio size={48} className="text-stone-600" />
|
||||
<p className="text-lg font-semibold">No cameras available</p>
|
||||
<p className="text-sm">Check back soon or ensure the backend is running.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{live.length > 0 && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
Live Now
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{live.map((s) => <StreamCard key={s.id} stream={s} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{offline.length > 0 && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-xl font-bold text-stone-400">Offline Cameras</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 opacity-70">
|
||||
{offline.map((s) => <StreamCard key={s.id} stream={s} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user