Boopster's picture
initial commit
af9cde9
"use client";
import { useState, useCallback } from "react";
import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Circle, X, Video } from "lucide-react";
type Direction = "up" | "down" | "left" | "right" | "front";
// --- Shared D-Pad + Camera Image ---
interface CameraFeedProps {
base64: string;
}
/**
* Pure camera feed with D-pad controls — no positioning wrapper.
* Used by DashboardGrid's bento flip and can be embedded anywhere.
*/
export function CameraFeed({ base64 }: CameraFeedProps) {
const [isMoving, setIsMoving] = useState(false);
const [lastDirection, setLastDirection] = useState<Direction | null>(null);
const moveHead = useCallback(async (direction: Direction) => {
if (isMoving) return;
setIsMoving(true);
setLastDirection(direction);
try {
const response = await fetch("http://localhost:7860/api/move-head", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ direction }),
});
if (!response.ok) {
console.error("Move head failed:", await response.text());
}
} catch (error) {
console.error("Move head error:", error);
} finally {
setTimeout(() => {
setIsMoving(false);
setLastDirection(null);
}, 300);
}
}, [isMoving]);
return (
<div className="relative w-full h-full flex flex-col" style={{
background: 'linear-gradient(135deg, rgba(54,54,54,0.8) 0%, rgba(36,36,36,0.9) 100%)',
}}>
{/* Header bar — muted, consistent with bento cards */}
<div className="flex items-center gap-2 px-4 py-2.5" style={{
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}>
<Video className="w-3.5 h-3.5 text-gray-400" />
<span className="text-[10px] font-semibold text-gray-400 tracking-widest uppercase">Live View</span>
<span className="w-1.5 h-1.5 rounded-full bg-red-400 animate-pulse ml-auto" />
</div>
{/* Video feed — fills remaining space */}
<div className="relative flex-1 min-h-0">
<img
src={`data:image/jpeg;base64,${base64}`}
alt="Robot camera view"
className="w-full h-full object-cover"
/>
{/* D-pad overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="relative w-28 h-28 pointer-events-auto">
<DPadButton
direction="up"
icon={<ChevronUp className="w-4 h-4" />}
position="top-0 left-1/2 -translate-x-1/2"
onClick={() => moveHead("up")}
isActive={lastDirection === "up"}
disabled={isMoving && lastDirection !== "up"}
/>
<DPadButton
direction="down"
icon={<ChevronDown className="w-4 h-4" />}
position="bottom-0 left-1/2 -translate-x-1/2"
onClick={() => moveHead("down")}
isActive={lastDirection === "down"}
disabled={isMoving && lastDirection !== "down"}
/>
<DPadButton
direction="left"
icon={<ChevronLeft className="w-4 h-4" />}
position="left-0 top-1/2 -translate-y-1/2"
onClick={() => moveHead("left")}
isActive={lastDirection === "left"}
disabled={isMoving && lastDirection !== "left"}
/>
<DPadButton
direction="right"
icon={<ChevronRight className="w-4 h-4" />}
position="right-0 top-1/2 -translate-y-1/2"
onClick={() => moveHead("right")}
isActive={lastDirection === "right"}
disabled={isMoving && lastDirection !== "right"}
/>
{/* Center button (front) */}
<button
onClick={() => moveHead("front")}
disabled={isMoving && lastDirection !== "front"}
className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full
${lastDirection === "front"
? "bg-white/30 ring-2 ring-white/40"
: "bg-white/10 hover:bg-white/20"
}
backdrop-blur transition-all duration-150 flex items-center justify-center
disabled:opacity-50`}
aria-label="Center robot view"
>
<Circle className="w-3 h-3 text-white/70" />
</button>
</div>
</div>
</div>
{/* Footer hint */}
<div className="px-4 py-2 text-center" style={{
borderTop: '1px solid rgba(255,255,255,0.06)',
}}>
<span className="text-[10px] text-gray-500">Tap arrows to move robot&apos;s view</span>
</div>
</div>
);
}
// --- Legacy floating overlay (kept for backwards compat) ---
interface CameraViewProps {
base64: string;
onClose: () => void;
}
export function CameraView({ base64, onClose }: CameraViewProps) {
return (
<div className="absolute bottom-20 right-6 z-50 animate-in slide-in-from-right-4 duration-300">
<div className="relative w-64 rounded-2xl overflow-hidden border border-white/20 shadow-2xl bg-black/80 backdrop-blur-xl">
{/* Close button on the old overlay */}
<div className="absolute top-2 right-2 z-10">
<button
onClick={onClose}
className="p-1 rounded-full hover:bg-white/20 transition-colors bg-black/40"
aria-label="Close camera view"
>
<X className="w-4 h-4 text-white" />
</button>
</div>
<CameraFeed base64={base64} />
</div>
</div>
);
}
// --- D-Pad Button ---
interface DPadButtonProps {
direction: Direction;
icon: React.ReactNode;
position: string;
onClick: () => void;
isActive: boolean;
disabled: boolean;
}
function DPadButton({ direction, icon, position, onClick, isActive, disabled }: DPadButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`absolute ${position} w-7 h-7 rounded-lg
${isActive
? "bg-white/25 ring-2 ring-white/30 scale-110"
: "bg-white/10 hover:bg-white/20"
}
backdrop-blur transition-all duration-150 flex items-center justify-center
disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-white/30`}
aria-label={`Look ${direction}`}
>
<span className="text-white">{icon}</span>
</button>
);
}