Initial Commit
This commit is contained in:
134
components/explorers/step-controls.tsx
Normal file
134
components/explorers/step-controls.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
|
||||
interface StepControlsProps {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
isPlaying: boolean;
|
||||
onStepForward: () => void;
|
||||
onStepBack: () => void;
|
||||
onTogglePlay: () => void;
|
||||
onReset: () => void;
|
||||
canStepForward: boolean;
|
||||
canStepBack: boolean;
|
||||
playIntervalMs?: number;
|
||||
}
|
||||
|
||||
export function StepControls({
|
||||
currentStep,
|
||||
totalSteps,
|
||||
isPlaying,
|
||||
onStepForward,
|
||||
onStepBack,
|
||||
onTogglePlay,
|
||||
onReset,
|
||||
canStepForward,
|
||||
canStepBack,
|
||||
playIntervalMs = 950,
|
||||
}: StepControlsProps) {
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Auto-play timer
|
||||
useEffect(() => {
|
||||
if (isPlaying && canStepForward) {
|
||||
timerRef.current = setInterval(() => {
|
||||
onStepForward();
|
||||
}, playIntervalMs);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [isPlaying, canStepForward, onStepForward, playIntervalMs]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(tag)) return;
|
||||
|
||||
if (e.key === "ArrowRight" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (canStepForward) onStepForward();
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
if (canStepBack) onStepBack();
|
||||
}
|
||||
if (e.key.toLowerCase() === "p") onTogglePlay();
|
||||
if (e.key.toLowerCase() === "r") onReset();
|
||||
},
|
||||
[canStepForward, canStepBack, onStepForward, onStepBack, onTogglePlay, onReset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const progress = totalSteps > 0 ? (currentStep / totalSteps) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Progress bar */}
|
||||
{totalSteps > 0 && (
|
||||
<div className="mx-auto max-w-md">
|
||||
<div className="mb-1.5 flex items-center justify-between text-xs text-muted">
|
||||
<span>Step {currentStep} of {totalSteps}</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-border/60">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-unit-1 to-unit-4 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={onStepBack}
|
||||
disabled={!canStepBack || isPlaying}
|
||||
className="rounded-xl border border-border/60 bg-surface px-4 py-2.5 text-sm font-semibold text-foreground shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)] hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-sm)]"
|
||||
>
|
||||
◀ Back
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
disabled={!canStepForward && !isPlaying}
|
||||
className="min-w-[110px] rounded-xl bg-correct px-5 py-2.5 text-sm font-semibold text-white shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)] hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-sm)]"
|
||||
>
|
||||
{isPlaying ? "⏸ Pause" : "▶ Play"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onStepForward}
|
||||
disabled={!canStepForward || isPlaying}
|
||||
className="rounded-xl bg-foreground px-5 py-2.5 text-sm font-semibold text-background shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)] hover:-translate-y-px disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-sm)]"
|
||||
>
|
||||
Next Step ▶
|
||||
</button>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="rounded-xl border border-incorrect/30 bg-surface px-4 py-2.5 text-sm font-semibold text-incorrect shadow-[var(--shadow-sm)] transition-all duration-200 hover:bg-incorrect hover:text-white hover:shadow-[var(--shadow-md)] hover:-translate-y-px"
|
||||
>
|
||||
↺ Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<p className="text-center text-[0.7rem] text-muted/50">
|
||||
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">Space</kbd>{" / "}
|
||||
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">→</kbd>{" "}
|
||||
next{" · "}
|
||||
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">←</kbd>{" "}
|
||||
back{" · "}
|
||||
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">P</kbd>{" "}
|
||||
play{" · "}
|
||||
<kbd className="rounded border border-border/60 bg-background px-1 py-px font-mono text-[0.6rem]">R</kbd>{" "}
|
||||
reset
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user