307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import { useRouter } from "next/navigation";
|
|
import { FormEvent, useEffect, useRef, useState } from "react";
|
|
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { useI18n } from "@/features/i18n/model/useI18n";
|
|
import { DEBRIEF_STATUS_OPTIONS } from "@/shared/config/i18n";
|
|
import { findRouteById } from "@/shared/config/routes";
|
|
import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store";
|
|
import { Voyage, VoyageStatus } from "@/shared/types";
|
|
|
|
const FINISH_HOLD_MS = 1000;
|
|
const HOLD_STAGE_ONE_MS = 100;
|
|
const HOLD_STAGE_ONE_PROGRESS = 0.2;
|
|
|
|
type FlightHudWidgetProps = {
|
|
voyage: Voyage | null;
|
|
isPaused: boolean;
|
|
formattedTime: string;
|
|
isCountdownCompleted: boolean;
|
|
handlePauseToggle: () => void;
|
|
handleFinish: () => Voyage | null;
|
|
};
|
|
|
|
export function FlightHudWidget({
|
|
voyage,
|
|
isPaused,
|
|
formattedTime,
|
|
isCountdownCompleted,
|
|
handlePauseToggle,
|
|
handleFinish,
|
|
}: FlightHudWidgetProps) {
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const [isDebriefOpen, setIsDebriefOpen] = useState(false);
|
|
const [finishedVoyage, setFinishedVoyage] = useState<Voyage | null>(null);
|
|
const [status, setStatus] = useState<VoyageStatus | null>(null);
|
|
const [progress, setProgress] = useState("");
|
|
const [holdProgress, setHoldProgress] = useState(0);
|
|
const holdStartAtRef = useRef<number | null>(null);
|
|
const holdRafRef = useRef<number | null>(null);
|
|
const isHoldCompletedRef = useRef(false);
|
|
|
|
const openDebriefModal = () => {
|
|
const endedVoyage = handleFinish();
|
|
if (!endedVoyage) return;
|
|
|
|
setFinishedVoyage(endedVoyage);
|
|
setStatus(null);
|
|
setProgress("");
|
|
setIsDebriefOpen(true);
|
|
};
|
|
|
|
const handleDebriefSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
if (!status || !finishedVoyage) return;
|
|
|
|
const finalVoyage: Voyage = {
|
|
...finishedVoyage,
|
|
status,
|
|
debriefProgress: progress,
|
|
};
|
|
|
|
saveToHistory(finalVoyage);
|
|
saveCurrentVoyage(null);
|
|
setIsDebriefOpen(false);
|
|
router.push("/log");
|
|
};
|
|
|
|
const stopHoldLoop = () => {
|
|
if (holdRafRef.current !== null) {
|
|
cancelAnimationFrame(holdRafRef.current);
|
|
holdRafRef.current = null;
|
|
}
|
|
holdStartAtRef.current = null;
|
|
};
|
|
|
|
const resetHold = () => {
|
|
stopHoldLoop();
|
|
isHoldCompletedRef.current = false;
|
|
setHoldProgress(0);
|
|
};
|
|
|
|
const openDebriefByHold = () => {
|
|
isHoldCompletedRef.current = true;
|
|
stopHoldLoop();
|
|
setHoldProgress(1);
|
|
requestAnimationFrame(() => {
|
|
resetHold();
|
|
openDebriefModal();
|
|
});
|
|
};
|
|
|
|
const tickHoldProgress = (timestamp: number) => {
|
|
if (holdStartAtRef.current === null) return;
|
|
|
|
const elapsed = timestamp - holdStartAtRef.current;
|
|
const nextProgress = (() => {
|
|
if (elapsed <= HOLD_STAGE_ONE_MS) {
|
|
return Math.min(
|
|
HOLD_STAGE_ONE_PROGRESS,
|
|
(elapsed / HOLD_STAGE_ONE_MS) * HOLD_STAGE_ONE_PROGRESS,
|
|
);
|
|
}
|
|
|
|
const stageTwoElapsed = elapsed - HOLD_STAGE_ONE_MS;
|
|
const stageTwoDuration = FINISH_HOLD_MS - HOLD_STAGE_ONE_MS;
|
|
const stageTwoProgressRatio = Math.min(1, stageTwoElapsed / stageTwoDuration);
|
|
|
|
return Math.min(
|
|
1,
|
|
HOLD_STAGE_ONE_PROGRESS +
|
|
stageTwoProgressRatio * (1 - HOLD_STAGE_ONE_PROGRESS),
|
|
);
|
|
})();
|
|
setHoldProgress(nextProgress);
|
|
|
|
if (nextProgress >= 1) {
|
|
openDebriefByHold();
|
|
return;
|
|
}
|
|
|
|
holdRafRef.current = requestAnimationFrame(tickHoldProgress);
|
|
};
|
|
|
|
const startHoldToFinish = () => {
|
|
if (isDebriefOpen) return;
|
|
|
|
resetHold();
|
|
holdStartAtRef.current = performance.now();
|
|
setHoldProgress(0);
|
|
holdRafRef.current = requestAnimationFrame(tickHoldProgress);
|
|
};
|
|
|
|
const cancelHoldToFinish = () => {
|
|
if (isHoldCompletedRef.current) return;
|
|
resetHold();
|
|
};
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (holdRafRef.current !== null) {
|
|
cancelAnimationFrame(holdRafRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
if (!voyage) return null;
|
|
|
|
const route = findRouteById(voyage.routeId);
|
|
const routeName = route
|
|
? t(route.nameKey, undefined, voyage.routeName)
|
|
: voyage.routeName;
|
|
const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
|
|
value: option.value as VoyageStatus,
|
|
label: t(option.labelKey),
|
|
desc: t(option.descKey),
|
|
}));
|
|
|
|
return (
|
|
<>
|
|
<div className="absolute top-8 z-10 text-center">
|
|
<span className="rounded-full border border-indigo-500/30 bg-indigo-950/50 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-indigo-300 shadow-[0_0_15px_rgba(99,102,241,0.3)] backdrop-blur">
|
|
{routeName} · {isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className={`relative z-10 my-12 font-mono text-7xl font-light tracking-tighter tabular-nums drop-shadow-2xl transition-opacity duration-300 md:text-9xl ${isPaused ? "opacity-50" : "opacity-100"}`}
|
|
>
|
|
{formattedTime}
|
|
</div>
|
|
|
|
<div className="relative z-10 mb-24 w-full max-w-2xl px-4">
|
|
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400 md:text-xs">
|
|
{t("flight.missionLabel")}
|
|
</p>
|
|
<p className="mt-2 max-h-32 overflow-y-auto break-words whitespace-pre-wrap pr-1 text-base leading-relaxed text-slate-100 md:text-lg">
|
|
{voyage.missionText}
|
|
</p>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="absolute bottom-12 z-10 flex gap-6">
|
|
<button
|
|
onClick={handlePauseToggle}
|
|
className="rounded-full border border-slate-600 bg-slate-900/50 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-300 backdrop-blur transition-all hover:border-slate-400 hover:bg-slate-800/80 hover:text-white"
|
|
>
|
|
{isPaused ? t("flight.resume") : t("flight.pause")}
|
|
</button>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onPointerDown={startHoldToFinish}
|
|
onPointerUp={cancelHoldToFinish}
|
|
onPointerLeave={cancelHoldToFinish}
|
|
onPointerCancel={cancelHoldToFinish}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
startHoldToFinish();
|
|
}
|
|
}}
|
|
onKeyUp={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
cancelHoldToFinish();
|
|
}
|
|
}}
|
|
onBlur={cancelHoldToFinish}
|
|
className="relative overflow-hidden rounded-full border border-slate-200 bg-slate-100 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-900 shadow-lg shadow-white/10 transition-all hover:border-indigo-300 hover:text-slate-950"
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className="absolute inset-0 origin-left bg-indigo-400/45"
|
|
style={{ transform: `scaleX(${holdProgress})` }}
|
|
/>
|
|
<span className="relative z-10">
|
|
{isCountdownCompleted
|
|
? t("flight.finish.debrief")
|
|
: t("flight.finish.end")}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={isDebriefOpen} onOpenChange={setIsDebriefOpen}>
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto border-slate-700/80 bg-slate-950 text-slate-100">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-white">
|
|
{t("flight.debrief.title")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-slate-400">
|
|
{t("flight.debrief.description")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleDebriefSubmit} className="space-y-6">
|
|
<section>
|
|
<label className="mb-3 block text-sm font-medium text-slate-300">
|
|
{t("debrief.status.label")}
|
|
</label>
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
|
{statusOptions.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => setStatus(opt.value)}
|
|
className={`flex min-h-[116px] flex-col justify-between rounded-xl border px-4 py-3.5 text-left transition-all ${
|
|
status === opt.value
|
|
? "border-indigo-500 bg-indigo-900/40 ring-1 ring-indigo-500"
|
|
: "border-slate-800 bg-slate-900/50 hover:bg-slate-800"
|
|
}`}
|
|
>
|
|
<div className="text-sm leading-snug font-bold text-slate-200 break-keep">
|
|
{opt.label}
|
|
</div>
|
|
<div className="mt-2 text-[11px] leading-relaxed text-slate-500">
|
|
{opt.desc}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<label className="mb-2 block text-sm font-medium text-slate-300">
|
|
{t("debrief.reflection.label")}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={progress}
|
|
onChange={(event) => setProgress(event.target.value)}
|
|
placeholder={t("debrief.reflection.placeholder")}
|
|
className="w-full rounded-lg border border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-200 outline-none transition-all focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
|
/>
|
|
</section>
|
|
|
|
<div className="grid grid-cols-2 gap-3 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsDebriefOpen(false)}
|
|
className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 font-semibold text-slate-300 transition-colors hover:border-slate-500 hover:text-white"
|
|
>
|
|
{t("boarding.cancel")}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!status}
|
|
className="rounded-xl bg-indigo-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500"
|
|
>
|
|
{t("debrief.save")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|