feat: implement authentication control with support for BYO API keys, episode deduplication, and a task queue for async processing.

This commit is contained in:
2026-09-08 23:01:07 -07:00
parent f033a6045c
commit b3dd9c6933
40 changed files with 3393 additions and 827 deletions
+110 -236
View File
@@ -1,251 +1,125 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import {
Play,
Pause,
RotateCcw,
RotateCw,
Volume2,
VolumeX,
Gauge,
Radio,
} from "lucide-react";
import React from "react";
import { useAudio } from "@/contexts/AudioContext";
import { Maximize2, Play, Pause } from "lucide-react";
interface AudioPlayerProps {
title: string;
audioUrl: string;
durationSeconds?: number | null;
onTimeUpdate?: (currentTime: number) => void;
onSeek?: (seekTo: number) => void;
seekTime?: number | null;
}
export function AudioPlayer() {
const {
activeEpisode,
isPlaying,
currentTime,
duration,
togglePlay,
seekTo,
openZen,
} = useAudio();
export function AudioPlayer({
title,
audioUrl,
durationSeconds,
onTimeUpdate,
seekTime,
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(durationSeconds || 0);
const [playbackRate, setPlaybackRate] = useState(1);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
// Handle external seek requests (e.g. clicking transcript)
useEffect(() => {
if (
seekTime !== undefined &&
seekTime !== null &&
audioRef.current &&
Math.abs(audioRef.current.currentTime - seekTime) > 0.5
) {
audioRef.current.currentTime = seekTime;
setCurrentTime(seekTime);
if (!isPlaying) {
audioRef.current.play().catch(console.error);
setIsPlaying(true);
}
}
}, [seekTime]);
// Handle source changes
useEffect(() => {
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
setCurrentTime(0);
audioRef.current.load();
}
}, [audioUrl]);
const togglePlay = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
audioRef.current.play().then(() => setIsPlaying(true)).catch(console.error);
}
};
const jump = (seconds: number) => {
if (!audioRef.current) return;
const newTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration || 999999));
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
};
const handleTimeUpdate = () => {
if (!audioRef.current) return;
const time = audioRef.current.currentTime;
setCurrentTime(time);
onTimeUpdate?.(time);
};
const handleLoadedMetadata = () => {
if (!audioRef.current) return;
if (audioRef.current.duration && isFinite(audioRef.current.duration)) {
setDuration(audioRef.current.duration);
}
};
const handleScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = parseFloat(e.target.value);
setCurrentTime(time);
if (audioRef.current) {
audioRef.current.currentTime = time;
}
};
const togglePlaybackRate = () => {
const rates = [0.8, 1.0, 1.25, 1.5, 2.0];
const nextIdx = (rates.indexOf(playbackRate) + 1) % rates.length;
const newRate = rates[nextIdx];
setPlaybackRate(newRate);
if (audioRef.current) {
audioRef.current.playbackRate = newRate;
}
};
const toggleMute = () => {
if (!audioRef.current) return;
const newMuted = !isMuted;
setIsMuted(newMuted);
audioRef.current.muted = newMuted;
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (audioRef.current) {
audioRef.current.volume = val;
if (val === 0) {
setIsMuted(true);
} else if (isMuted) {
setIsMuted(false);
}
}
};
if (!activeEpisode) return null;
const formatTime = (secs: number) => {
if (isNaN(secs) || !isFinite(secs)) return "00:00";
const minutes = Math.floor(secs / 60);
const seconds = Math.floor(secs % 60);
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
if (!secs || isNaN(secs)) return "0:00";
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-slate-900/95 backdrop-blur-md border-t border-slate-800 shadow-2xl px-4 py-3 sm:px-6">
<audio
ref={audioRef}
src={audioUrl}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
preload="metadata"
/>
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
<div className="max-w-6xl mx-auto flex flex-col gap-2">
{/* Scrubber and Timestamps */}
<div className="flex items-center gap-3 w-full">
<span className="text-xs font-mono text-slate-400 w-12 text-right">
{formatTime(currentTime)}
</span>
<div className="relative flex-1 group">
<input
type="range"
min="0"
max={duration || 100}
step="0.1"
value={currentTime}
onChange={handleScrub}
className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500 hover:h-2 transition-all"
/>
return (
<div className="fixed bottom-0 left-0 right-0 z-40 bg-white/95 dark:bg-slate-950/95 backdrop-blur-2xl border-t border-slate-200 dark:border-slate-900 text-slate-900 dark:text-slate-100 shadow-2xl safe-bottom transition-colors">
{/* Interactive Top Mini Scrubber */}
<div
className="w-full h-1 bg-slate-200 dark:bg-slate-900 hover:h-2 cursor-pointer transition-all group relative"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const ratio = Math.max(0, Math.min(clickX / rect.width, 1));
seekTo(ratio * duration);
}}
>
<div
className="h-full bg-blue-500 group-hover:bg-blue-400 transition-all relative"
style={{ width: `${progressPercent}%` }}
>
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2.5 h-2.5 bg-white rounded-full shadow opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
<div className="max-w-5xl mx-auto px-4 sm:px-6 h-18 py-2.5 flex items-center justify-between gap-4">
{/* Left: Spinning Vinyl Album (Tap to toggle play/pause) + Track Info */}
<div className="flex items-center gap-3.5 min-w-0 flex-1">
{/* Vinyl Record */}
<button
onClick={togglePlay}
className="group relative w-12 h-12 rounded-full shrink-0 flex items-center justify-center select-none shadow-md shadow-black/30 active:scale-95 transition-transform"
title={isPlaying ? "Click to Pause" : "Click to Play"}
aria-label={isPlaying ? "Pause audio" : "Play audio"}
>
{/* Outer Vinyl Body with spinning animation */}
<div
className={`w-full h-full rounded-full bg-gradient-to-tr from-slate-900 via-slate-950 to-slate-900 border border-slate-700/80 p-1 flex items-center justify-center shadow-inner ${
isPlaying ? "animate-[spin_5s_linear_infinite]" : ""
}`}
>
{/* Vinyl grooves styling */}
<div className="w-full h-full rounded-full border border-slate-800/80 flex items-center justify-center">
<div className="w-3/4 h-3/4 rounded-full border border-slate-800/60 flex items-center justify-center">
{/* Center Album Art / Label */}
<div className="w-5 h-5 rounded-full bg-gradient-to-tr from-blue-500 via-indigo-500 to-purple-600 flex items-center justify-center border border-slate-900 shadow-sm">
{/* Center spindle hole */}
<div className="w-1.5 h-1.5 rounded-full bg-slate-950" />
</div>
</div>
</div>
</div>
{/* Hover / Status Overlay Icon */}
<div
className={`absolute inset-0 rounded-full flex items-center justify-center bg-black/40 backdrop-blur-[1px] transition-opacity ${
isPlaying ? "opacity-0 group-hover:opacity-100" : "opacity-90"
}`}
>
{isPlaying ? (
<Pause className="w-4 h-4 text-white fill-current drop-shadow" />
) : (
<Play className="w-4 h-4 text-white fill-current translate-x-0.5 drop-shadow" />
)}
</div>
</button>
{/* Track Info (Click to open Zen Player) */}
<div
onClick={openZen}
className="min-w-0 flex-1 cursor-pointer group py-1"
title="Click to open Full Screen Zen Mode"
>
<h4 className="text-sm font-medium truncate text-slate-800 dark:text-slate-200 group-hover:text-blue-600 dark:group-hover:text-blue-300 transition-colors">
{activeEpisode.title}
</h4>
<div className="text-xs font-mono text-slate-500 flex items-center gap-2 mt-0.5">
<span className={isPlaying ? "text-blue-500 dark:text-blue-400 font-medium" : "text-slate-400 dark:text-slate-500"}>
{isPlaying ? "Playing" : "Paused"}
</span>
<span className="text-slate-300 dark:text-slate-600">•</span>
<span className="text-slate-500 dark:text-slate-400">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
</div>
<span className="text-xs font-mono text-slate-400 w-12">
{formatTime(duration)}
</span>
</div>
{/* Controls Bar */}
<div className="flex items-center justify-between">
{/* Track Info */}
<div className="flex items-center gap-3 w-1/3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
<Radio className="w-5 h-5 animate-pulse" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{title}</p>
<p className="text-xs text-slate-400 truncate">Be My Anchor • Episode</p>
</div>
</div>
{/* Main Playback Buttons */}
<div className="flex items-center gap-3 sm:gap-4 justify-center">
<button
onClick={() => jump(-15)}
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
title="Jump backward 15s"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
className="p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-full shadow-lg shadow-blue-600/30 transition-transform active:scale-95"
title={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
</button>
<button
onClick={() => jump(15)}
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
title="Jump forward 15s"
>
<RotateCw className="w-4 h-4" />
</button>
</div>
{/* Aux Controls: Speed & Volume */}
<div className="flex items-center gap-3 w-1/3 justify-end">
<button
onClick={togglePlaybackRate}
className="flex items-center gap-1 text-xs font-medium px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded border border-slate-700 transition"
title="Adjust Speed"
>
<Gauge className="w-3.5 h-3.5 text-blue-400" />
<span>{playbackRate}x</span>
</button>
<div className="hidden sm:flex items-center gap-2">
<button
onClick={toggleMute}
className="text-slate-400 hover:text-slate-200 transition"
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted || volume === 0 ? (
<VolumeX className="w-4 h-4 text-red-400" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-slate-700 rounded appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
{/* Right: Full Screen Zen Button */}
<div className="flex items-center shrink-0">
<button
onClick={openZen}
className="flex items-center gap-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-xl bg-slate-100 dark:bg-slate-900 hover:bg-blue-50 dark:hover:bg-blue-600/15 border border-slate-200 dark:border-slate-800 text-slate-700 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-300 text-xs sm:text-sm font-medium transition active:scale-95 shadow-sm"
title="Open Full Screen Zen Mode"
aria-label="Open Full Screen Zen Mode"
>
<Maximize2 className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-500 dark:text-blue-400" />
<span className="hidden sm:inline">Zen Mode</span>
</button>
</div>
</div>
</div>
+36
View File
@@ -0,0 +1,36 @@
"use client";
import React, { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
export function ThemeToggle({ className = "" }: { className?: string }) {
const { theme, toggleTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<div className={`w-9 h-9 rounded-xl border border-transparent ${className}`} />
);
}
return (
<button
type="button"
onClick={toggleTheme}
className={`p-2 rounded-xl border transition-all duration-200 flex items-center justify-center text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 border-slate-200 dark:border-slate-800 ${className}`}
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
>
{theme === "dark" ? (
<Sun className="w-4 h-4 text-amber-400 animate-in fade-in duration-200" />
) : (
<Moon className="w-4 h-4 text-slate-700 animate-in fade-in duration-200" />
)}
</button>
);
}
+280
View File
@@ -0,0 +1,280 @@
"use client";
import React, { useEffect, useRef } from "react";
import { useAudio } from "@/contexts/AudioContext";
import {
ChevronDown,
Play,
Pause,
RotateCcw,
RotateCw,
Volume2,
VolumeX,
FileText,
} from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
export function ZenPlayer() {
const {
activeEpisode,
isPlaying,
currentTime,
duration,
playbackRate,
isMuted,
isZenOpen,
timings,
togglePlay,
seekTo,
jump,
setSpeed,
toggleMute,
closeZen,
} = useAudio();
const activeCueRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const touchStartY = useRef<number | null>(null);
const speakerNameMap = React.useMemo(() => {
const map: Record<string, string> = {
hostA: "Host A",
hostB: "Host B",
};
if (activeEpisode?.scriptContent) {
try {
const parsed = JSON.parse(activeEpisode.scriptContent);
if (Array.isArray(parsed.speakers)) {
for (const s of parsed.speakers) {
if (s.id && s.name) {
map[s.id] = s.name;
}
}
}
} catch {}
}
return map;
}, [activeEpisode?.scriptContent]);
// Auto-scroll active cue into center view
useEffect(() => {
if (activeCueRef.current && isZenOpen) {
activeCueRef.current.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}, [currentTime, isZenOpen]);
if (!isZenOpen || !activeEpisode) return null;
const formatTime = (secs: number) => {
if (!secs || isNaN(secs)) return "0:00";
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
const handleScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
seekTo(val);
};
const cycleSpeed = () => {
const rates = [0.8, 1.0, 1.25, 1.5, 2.0];
const nextIdx = (rates.indexOf(playbackRate) + 1) % rates.length;
setSpeed(rates[nextIdx]);
};
// Swipe-down to minimize gesture on mobile
const handleTouchStart = (e: React.TouchEvent) => {
touchStartY.current = e.touches[0].clientY;
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartY.current !== null) {
const deltaY = e.changedTouches[0].clientY - touchStartY.current;
if (deltaY > 100) {
closeZen();
}
touchStartY.current = null;
}
};
return (
<div
className="fixed inset-0 z-50 bg-slate-50 dark:bg-slate-950 flex flex-col text-slate-900 dark:text-slate-100 select-none animate-in fade-in duration-200 transition-colors"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Top Header / Drag Handle */}
<header className="px-6 py-4 flex items-center justify-between border-b border-slate-200 dark:border-slate-900 bg-white/80 dark:bg-slate-950/80 backdrop-blur-md shrink-0">
<button
onClick={closeZen}
className="p-2 -ml-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800/80 active:bg-slate-200 dark:active:bg-slate-700 text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 transition"
aria-label="Minimize Zen Player"
>
<ChevronDown className="w-6 h-6" />
</button>
<div className="flex-1 text-center px-4">
<span className="text-[11px] uppercase tracking-widest text-blue-600 dark:text-blue-400 font-semibold font-mono">
Zen Listening Mode
</span>
<h2 className="text-sm font-semibold truncate text-slate-800 dark:text-slate-200 max-w-md mx-auto">
{activeEpisode.title}
</h2>
</div>
{/* Header Right: Theme Toggle */}
<div className="flex items-center">
<ThemeToggle />
</div>
</header>
{/* Synchronized Transcript View (No visible scrollbar) */}
<main
ref={containerRef}
className="flex-1 overflow-y-auto px-6 sm:px-12 py-8 max-w-3xl mx-auto w-full space-y-4 scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
>
{timings && timings.length > 0 ? (
timings.map((cue, idx) => {
const isActive = currentTime >= cue.startTime && currentTime <= cue.endTime;
const speakerName = cue.speaker ? speakerNameMap[cue.speaker] || cue.speaker : null;
const isHostB = cue.speaker === "hostB";
return (
<div
key={idx}
ref={isActive ? activeCueRef : null}
onClick={() => seekTo(cue.startTime)}
className={`p-4 rounded-2xl cursor-pointer transition-all duration-300 ${
isActive
? "bg-blue-50 dark:bg-blue-600/15 border border-blue-400 dark:border-blue-500/30 text-slate-900 dark:text-white shadow-md shadow-blue-500/5 scale-[1.01]"
: "text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-100/70 dark:hover:bg-slate-900/40 border border-transparent"
}`}
>
<div className="flex items-center gap-2.5 mb-2 text-xs font-mono text-slate-400 dark:text-slate-500">
<span className={isActive ? "text-blue-600 dark:text-blue-400 font-semibold" : ""}>
{formatTime(cue.startTime)}
</span>
{speakerName && (
<span
className={`px-2 py-0.5 rounded-md text-[11px] font-sans font-medium tracking-wide ${
isHostB
? "bg-purple-100 dark:bg-purple-950/80 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800/60"
: "bg-blue-100 dark:bg-blue-950/80 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800/60"
}`}
>
{speakerName}
</span>
)}
{isActive && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 dark:bg-blue-400 animate-pulse" />
)}
</div>
<p className="text-base sm:text-lg leading-relaxed font-sans font-normal">
{cue.text}
</p>
</div>
);
})
) : (
<div className="flex flex-col items-center justify-center h-full text-center text-slate-400 dark:text-slate-500 py-16">
<FileText className="w-12 h-12 mb-3 opacity-30 text-blue-500 dark:text-blue-400" />
<p className="text-base text-slate-700 dark:text-slate-300 font-medium mb-1">
Synchronized Transcript Not Available
</p>
<p className="text-xs text-slate-400 dark:text-slate-500 max-w-sm">
Enjoy listening to the episode audio using the controls below.
</p>
</div>
)}
</main>
{/* Bottom Control Bar */}
<footer className="px-6 py-6 border-t border-slate-200 dark:border-slate-900 bg-white/90 dark:bg-slate-950/90 backdrop-blur-xl shrink-0 safe-bottom">
<div className="max-w-xl mx-auto space-y-4">
{/* Scrubber Slider */}
<div className="space-y-1.5">
<input
type="range"
min={0}
max={duration || 100}
step={0.1}
value={currentTime}
onChange={handleScrub}
className="w-full h-1.5 bg-slate-200 dark:bg-slate-800 rounded-lg appearance-none cursor-pointer accent-blue-600 dark:accent-blue-500"
/>
<div className="flex justify-between text-xs font-mono text-slate-400 dark:text-slate-500">
<span>{formatTime(currentTime)}</span>
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
</div>
{/* The 5 Bottom Buttons: Speed, Back, Play/Pause, Forward, Mute */}
<div className="flex items-center justify-between sm:justify-center gap-4 sm:gap-8 pt-1">
{/* 1. Speed Toggle */}
<button
onClick={cycleSpeed}
className="w-11 h-11 rounded-full text-xs font-mono font-semibold text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white bg-slate-100 dark:bg-slate-900 hover:bg-slate-200 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-800 flex items-center justify-center active:scale-95 transition"
title="Playback Speed"
aria-label="Toggle playback speed"
>
{playbackRate}x
</button>
{/* 2. Jump Back 15s */}
<button
onClick={() => jump(-15)}
className="p-3 rounded-full text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 active:scale-95 transition"
title="Rewind 15 seconds"
aria-label="Rewind 15 seconds"
>
<RotateCcw className="w-6 h-6" />
</button>
{/* 3. Play / Pause (Large touch target) */}
<button
onClick={togglePlay}
className="w-16 h-16 rounded-full bg-blue-600 hover:bg-blue-500 active:scale-95 text-white flex items-center justify-center shadow-lg shadow-blue-600/30 transition"
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<Pause className="w-7 h-7 fill-current" />
) : (
<Play className="w-7 h-7 fill-current translate-x-0.5" />
)}
</button>
{/* 4. Jump Forward 15s */}
<button
onClick={() => jump(15)}
className="p-3 rounded-full text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 active:scale-95 transition"
title="Skip 15 seconds"
aria-label="Skip 15 seconds"
>
<RotateCw className="w-6 h-6" />
</button>
{/* 5. Mute Toggle */}
<button
onClick={toggleMute}
className="p-3 rounded-full text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 active:scale-95 transition"
title={isMuted ? "Unmute" : "Mute"}
aria-label={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? (
<VolumeX className="w-6 h-6 text-red-500 dark:text-red-400" />
) : (
<Volume2 className="w-6 h-6" />
)}
</button>
</div>
</div>
</footer>
</div>
);
}