refactor: 항해일지 작성 모달로 변경 및 꾹 눌러서 종료
This commit is contained in:
@@ -11,7 +11,6 @@ export default function DebriefPage() {
|
||||
|
||||
const [status, setStatus] = useState<VoyageStatus | null>(null);
|
||||
const [progress, setProgress] = useState('');
|
||||
const [nextAction, setNextAction] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const current = getCurrentVoyage();
|
||||
@@ -29,7 +28,6 @@ export default function DebriefPage() {
|
||||
...voyage,
|
||||
status: status,
|
||||
debriefProgress: progress,
|
||||
nextAction: nextAction,
|
||||
endedAt: voyage.endedAt || Date.now(), // Fallback if missed in flight
|
||||
};
|
||||
|
||||
@@ -46,9 +44,9 @@ export default function DebriefPage() {
|
||||
if (!voyage) return null;
|
||||
|
||||
const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [
|
||||
{ value: 'completed', label: '✅ 계획대로', desc: '목표를 달성했습니다' },
|
||||
{ value: 'partial', label: '🌓 부분 진행', desc: '절반의 성공입니다' },
|
||||
{ value: 'reoriented', label: '🧭 방향 재설정', desc: '새로운 발견을 했습니다' },
|
||||
{ value: 'completed', label: '계획대로', desc: '목표를 달성했습니다' },
|
||||
{ value: 'partial', label: '부분 진행', desc: '절반의 성공입니다' },
|
||||
{ value: 'reoriented', label: '방향 재설정', desc: '새로운 발견을 했습니다' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -97,19 +95,6 @@ export default function DebriefPage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Question 3: Next Action */}
|
||||
<section>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
다음 항해의 첫 행동 (Next)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nextAction}
|
||||
onChange={(e) => setNextAction(e.target.value)}
|
||||
placeholder="예: 본문 1챕터 초안 쓰기"
|
||||
className="w-full bg-slate-900/30 border border-slate-800 rounded-lg px-4 py-3 text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all"
|
||||
/>
|
||||
</section>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!status}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
// app/session/end/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
|
||||
type Mode = "freeflow" | "sprint" | "deepwork";
|
||||
|
||||
const BG = "#E9EEF6";
|
||||
const BORDER = "#C9D7F5";
|
||||
const PRIMARY = "#2F6FED";
|
||||
const PRIMARY_HOVER = "#295FD1";
|
||||
|
||||
function clampMode(v: string | null): Mode {
|
||||
if (v === "sprint" || v === "deepwork" || v === "freeflow") return v;
|
||||
return "freeflow";
|
||||
}
|
||||
|
||||
function modeLabel(mode: Mode) {
|
||||
if (mode === "sprint") return "스프린트";
|
||||
if (mode === "deepwork") return "딥워크";
|
||||
return "프리플로우";
|
||||
}
|
||||
|
||||
function hhmmss(total: number) {
|
||||
const s = Math.max(0, Math.floor(total));
|
||||
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
|
||||
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
|
||||
const ss = String(s % 60).padStart(2, "0");
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }} />
|
||||
}
|
||||
>
|
||||
<SessionEndInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionEndInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const [elapsed, setElapsed] = useState(() => {
|
||||
const v = Number(localStorage.getItem("hushroom:session-elapsed") ?? "0");
|
||||
return Number.isFinite(v) ? v : 0;
|
||||
});
|
||||
|
||||
const [goal, setGoal] = useState(() => {
|
||||
const goal = localStorage.getItem("hushroom:session-goal");
|
||||
return goal;
|
||||
});
|
||||
|
||||
const mode = useMemo(() => clampMode(params.get("mode")), [params]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
|
||||
<header className="px-5 pt-6">
|
||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
||||
QuietSprint
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10 pt-6">
|
||||
<div
|
||||
className="rounded-3xl border bg-white px-6 py-6 shadow-sm"
|
||||
style={{ borderColor: BORDER }}
|
||||
>
|
||||
<div className="text-sm font-semibold text-slate-600">
|
||||
{modeLabel(mode)}
|
||||
</div>
|
||||
<div className="mt-3 text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
||||
{hhmmss(elapsed)}
|
||||
</div>
|
||||
|
||||
{/* Goal */}
|
||||
{goal && (
|
||||
<div
|
||||
className="mt-4 rounded-2xl border bg-[#F1F5FF] px-4 py-3"
|
||||
style={{ borderColor: BORDER }}
|
||||
>
|
||||
<div className="text-xs font-semibold text-slate-600">
|
||||
이번 목표
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-slate-900 line-clamp-2">
|
||||
{goal}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-base text-slate-700">세션이 종료됐어요</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
className="rounded-3xl border bg-white px-5 py-4 text-base font-semibold text-slate-800 shadow-sm transition active:scale-[0.99]"
|
||||
style={{ borderColor: BORDER }}
|
||||
>
|
||||
홈
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/session?mode=${mode}`)}
|
||||
className="rounded-3xl px-5 py-4 text-base font-semibold text-white shadow-sm transition active:scale-[0.99]"
|
||||
style={{ backgroundColor: PRIMARY }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = PRIMARY)
|
||||
}
|
||||
>
|
||||
다시
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// app/session/layout.tsx
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Session",
|
||||
description: "Focus session in progress.",
|
||||
};
|
||||
|
||||
export default function SessionLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,648 +0,0 @@
|
||||
// app/session/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Mode = "freeflow" | "sprint" | "deepwork";
|
||||
type PresenceStatus = "focus" | "away";
|
||||
|
||||
type Participant = {
|
||||
id: string;
|
||||
status: PresenceStatus;
|
||||
lastSeen: number; // ms
|
||||
isSelf: boolean;
|
||||
};
|
||||
|
||||
type RecenterReason = "distracted" | "stuck" | "tired" | "overwhelmed";
|
||||
|
||||
type RecenterOption = {
|
||||
id: string;
|
||||
label: string; // 화면 문구(2분 미션)
|
||||
pinText: string; // "다음 단계로 고정" 시 저장될 짧은 문구
|
||||
};
|
||||
|
||||
const BG = "#E9EEF6";
|
||||
const BORDER = "#C9D7F5";
|
||||
const PRIMARY = "#2F6FED";
|
||||
const PRIMARY_HOVER = "#295FD1";
|
||||
const HOVER = "#F1F5FF";
|
||||
|
||||
// localStorage keys (세션 시간/자리비움 복구용)
|
||||
const LS = {
|
||||
active: "hushroom:session-active",
|
||||
mode: "hushroom:session-mode",
|
||||
id: "hushroom:session-id",
|
||||
startedAt: "hushroom:session-startedAt",
|
||||
awayTotalMs: "hushroom:session-awayTotalMs",
|
||||
awayStartedAt: "hushroom:session-awayStartedAt",
|
||||
isAway: "hushroom:session-isAway",
|
||||
|
||||
goal: "hushroom:session-goal",
|
||||
nextAction: "hushroom:session-nextAction",
|
||||
|
||||
endElapsed: "hushroom:session-elapsed",
|
||||
endAway: "hushroom:session-away",
|
||||
endReason: "hushroom:session-end-reason",
|
||||
} as const;
|
||||
|
||||
function clampMode(v: string | null): Mode {
|
||||
if (v === "sprint" || v === "deepwork" || v === "freeflow") return v;
|
||||
return "freeflow";
|
||||
}
|
||||
|
||||
function modeLabel(mode: Mode) {
|
||||
if (mode === "sprint") return "스프린트";
|
||||
if (mode === "deepwork") return "딥워크";
|
||||
return "프리플로우";
|
||||
}
|
||||
|
||||
function modeDurationSeconds(mode: Mode) {
|
||||
if (mode === "sprint") return 25 * 60;
|
||||
if (mode === "deepwork") return 90 * 60;
|
||||
return null; // freeflow
|
||||
}
|
||||
|
||||
function formatHHMMSS(totalSeconds: number) {
|
||||
const s = Math.max(0, Math.floor(totalSeconds));
|
||||
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
|
||||
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
|
||||
const ss = String(s % 60).padStart(2, "0");
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function safeNumber(v: string | null, fallback = 0) {
|
||||
const n = Number(v ?? "");
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function newId() {
|
||||
return (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
||||
const selfId = useMemo(() => {
|
||||
const key = `hushroom:selfId:${roomKey}`;
|
||||
const existing = sessionStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const id = (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.slice(0, 16);
|
||||
|
||||
sessionStorage.setItem(key, id);
|
||||
return id;
|
||||
}, [roomKey]);
|
||||
|
||||
const [participants, setParticipants] = useState<Participant[]>(() => [
|
||||
{ id: selfId, status, lastSeen: Date.now(), isSelf: true },
|
||||
]);
|
||||
|
||||
const participantsRef = useRef<Map<string, Omit<Participant, "isSelf">>>(
|
||||
new Map([[selfId, { id: selfId, status, lastSeen: Date.now() }]]),
|
||||
);
|
||||
|
||||
const channelRef = useRef<BroadcastChannel | null>(null);
|
||||
const heartbeatRef = useRef<number | null>(null);
|
||||
const cleanupRef = useRef<number | null>(null);
|
||||
|
||||
const publish = (payload: any) => {
|
||||
if (channelRef.current) {
|
||||
channelRef.current.postMessage(payload);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`hushroom:presence:${roomKey}`,
|
||||
JSON.stringify({ ...payload, _nonce: Math.random() }),
|
||||
);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const syncStateToReact = () => {
|
||||
const now = Date.now();
|
||||
const arr: Participant[] = [];
|
||||
participantsRef.current.forEach((p, id) => {
|
||||
arr.push({
|
||||
id,
|
||||
status: p.status,
|
||||
lastSeen: p.lastSeen,
|
||||
isSelf: id === selfId,
|
||||
});
|
||||
});
|
||||
|
||||
arr.sort((a, b) => {
|
||||
if (a.isSelf && !b.isSelf) return -1;
|
||||
if (!a.isSelf && b.isSelf) return 1;
|
||||
return b.lastSeen - a.lastSeen;
|
||||
});
|
||||
|
||||
const self = participantsRef.current.get(selfId);
|
||||
if (self) self.lastSeen = now;
|
||||
|
||||
setParticipants(arr);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ("BroadcastChannel" in window) {
|
||||
const bc = new BroadcastChannel(`hushroom-presence:${roomKey}`);
|
||||
channelRef.current = bc;
|
||||
bc.onmessage = (ev) => {
|
||||
const msg = ev.data;
|
||||
if (!msg || msg.roomKey !== roomKey) return;
|
||||
if (!msg.from) return;
|
||||
|
||||
if (msg.type === "ping") {
|
||||
participantsRef.current.set(msg.from, {
|
||||
id: msg.from,
|
||||
status: msg.status as PresenceStatus,
|
||||
lastSeen: msg.ts ?? Date.now(),
|
||||
});
|
||||
syncStateToReact();
|
||||
}
|
||||
|
||||
if (msg.type === "leave") {
|
||||
participantsRef.current.delete(msg.from);
|
||||
syncStateToReact();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== `hushroom:presence:${roomKey}` || !e.newValue) return;
|
||||
try {
|
||||
const msg = JSON.parse(e.newValue);
|
||||
if (!msg || msg.roomKey !== roomKey) return;
|
||||
|
||||
if (msg.type === "ping") {
|
||||
participantsRef.current.set(msg.from, {
|
||||
id: msg.from,
|
||||
status: msg.status as PresenceStatus,
|
||||
lastSeen: msg.ts ?? Date.now(),
|
||||
});
|
||||
syncStateToReact();
|
||||
}
|
||||
if (msg.type === "leave") {
|
||||
participantsRef.current.delete(msg.from);
|
||||
syncStateToReact();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}
|
||||
|
||||
return () => {
|
||||
channelRef.current?.close();
|
||||
channelRef.current = null;
|
||||
};
|
||||
}, [roomKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now });
|
||||
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
|
||||
syncStateToReact();
|
||||
}, [status, roomKey, selfId]);
|
||||
|
||||
useEffect(() => {
|
||||
heartbeatRef.current = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
participantsRef.current.set(selfId, {
|
||||
id: selfId,
|
||||
status,
|
||||
lastSeen: now,
|
||||
});
|
||||
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
|
||||
syncStateToReact();
|
||||
}, 2000);
|
||||
|
||||
cleanupRef.current = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
const STALE_MS = 9000;
|
||||
let changed = false;
|
||||
|
||||
participantsRef.current.forEach((p, id) => {
|
||||
if (id === selfId) return;
|
||||
if (now - p.lastSeen > STALE_MS) {
|
||||
participantsRef.current.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) syncStateToReact();
|
||||
}, 4000);
|
||||
|
||||
const onBeforeUnload = () => {
|
||||
publish({ type: "leave", roomKey, from: selfId, ts: Date.now() });
|
||||
};
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
|
||||
return () => {
|
||||
if (heartbeatRef.current) window.clearInterval(heartbeatRef.current);
|
||||
if (cleanupRef.current) window.clearInterval(cleanupRef.current);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
publish({ type: "leave", roomKey, from: selfId, ts: Date.now() });
|
||||
};
|
||||
}, [roomKey, selfId, status]);
|
||||
|
||||
return { participants, selfId };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }} />
|
||||
}
|
||||
>
|
||||
<SessionInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const mode = useMemo(() => clampMode(params.get("mode")), [params]);
|
||||
const duration = useMemo(() => modeDurationSeconds(mode), [mode]);
|
||||
|
||||
const [goal, setGoal] = useState("");
|
||||
const [nextAction, setNextAction] = useState("");
|
||||
|
||||
// startedAt/now 기반 시간
|
||||
const [startedAt, setStartedAt] = useState<number>(() => Date.now());
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
const endedRef = useRef(false);
|
||||
|
||||
// away: 누적 ms + (현재 away면 now-startedAt)
|
||||
const [isAway, setIsAway] = useState(false);
|
||||
const awayTotalMsRef = useRef(0);
|
||||
const awayStartedAtRef = useRef<number | null>(null);
|
||||
|
||||
// presence (로컬)
|
||||
const presenceStatus: PresenceStatus = isAway ? "away" : "focus";
|
||||
const roomKey = "lounge";
|
||||
const { participants } = useLocalPresence(roomKey, presenceStatus);
|
||||
|
||||
// toast
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg);
|
||||
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
||||
toastTimerRef.current = window.setTimeout(() => setToast(null), 8000);
|
||||
};
|
||||
|
||||
// freeflow checkpoint every 60 minutes
|
||||
const lastCheckpointRef = useRef<number>(0);
|
||||
|
||||
// check-in (배너)
|
||||
const [checkinOpen, setCheckinOpen] = useState(false);
|
||||
const [checkinStep, setCheckinStep] = useState<
|
||||
"ask" | "reason" | "pick" | "running" | "done"
|
||||
>("ask");
|
||||
const shownCheckinsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const [recenterReason, setRecenterReason] = useState<RecenterReason | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [mission, setMission] = useState<{
|
||||
option: RecenterOption;
|
||||
endsAt: number;
|
||||
} | null>(null);
|
||||
|
||||
const [missionLeft, setMissionLeft] = useState<number>(0);
|
||||
|
||||
const checkinTimes = useMemo(() => {
|
||||
if (mode === "sprint") return [12 * 60];
|
||||
if (mode === "deepwork") return [30 * 60, 60 * 60];
|
||||
return [];
|
||||
}, [mode]);
|
||||
|
||||
const resetRecenter = () => {
|
||||
setRecenterReason(null);
|
||||
setMission(null);
|
||||
setMissionLeft(0);
|
||||
setCheckinStep("ask");
|
||||
setCheckinOpen(false);
|
||||
};
|
||||
|
||||
// 세션 시작/복구 (mode가 바뀌면 새 세션)
|
||||
useEffect(() => {
|
||||
endedRef.current = false;
|
||||
|
||||
const active = localStorage.getItem(LS.active) === "1";
|
||||
const storedMode = localStorage.getItem(LS.mode);
|
||||
const storedStartedAt = safeNumber(localStorage.getItem(LS.startedAt), 0);
|
||||
|
||||
const shouldResume = active && storedMode === mode && storedStartedAt > 0;
|
||||
|
||||
if (shouldResume) {
|
||||
setStartedAt(storedStartedAt);
|
||||
setNow(Date.now());
|
||||
|
||||
const isAwayStored = localStorage.getItem(LS.isAway) === "1";
|
||||
const awayTotal = safeNumber(localStorage.getItem(LS.awayTotalMs), 0);
|
||||
const awayStartedAt = safeNumber(
|
||||
localStorage.getItem(LS.awayStartedAt),
|
||||
0,
|
||||
);
|
||||
|
||||
awayTotalMsRef.current = Math.max(0, awayTotal);
|
||||
awayStartedAtRef.current =
|
||||
isAwayStored && awayStartedAt > 0 ? awayStartedAt : null;
|
||||
setIsAway(isAwayStored);
|
||||
} else {
|
||||
const t = Date.now();
|
||||
localStorage.setItem(LS.active, "1");
|
||||
localStorage.setItem(LS.mode, mode);
|
||||
localStorage.setItem(LS.id, newId());
|
||||
localStorage.setItem(LS.startedAt, String(t));
|
||||
localStorage.setItem(LS.awayTotalMs, "0");
|
||||
localStorage.removeItem(LS.awayStartedAt);
|
||||
localStorage.setItem(LS.isAway, "0");
|
||||
|
||||
setStartedAt(t);
|
||||
setNow(t);
|
||||
|
||||
awayTotalMsRef.current = 0;
|
||||
awayStartedAtRef.current = null;
|
||||
setIsAway(false);
|
||||
}
|
||||
|
||||
// UI 관련 리셋(기존 로직 유지)
|
||||
setToast(null);
|
||||
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
||||
lastCheckpointRef.current = 0;
|
||||
shownCheckinsRef.current = new Set();
|
||||
resetRecenter();
|
||||
setCheckinOpen(false);
|
||||
}, [mode, duration]);
|
||||
|
||||
// 목표/다음 단계(고정) 로드
|
||||
useEffect(() => {
|
||||
setGoal(localStorage.getItem(LS.goal) ?? "");
|
||||
setNextAction(localStorage.getItem(LS.nextAction) ?? "");
|
||||
}, []);
|
||||
|
||||
// now 갱신(탭 전환/잠자기 복귀 시에도 보정)
|
||||
useEffect(() => {
|
||||
const tick = () => setNow(Date.now());
|
||||
|
||||
tick();
|
||||
const id = window.setInterval(tick, 250);
|
||||
|
||||
const onVis = () => tick();
|
||||
const onFocus = () => tick();
|
||||
const onPageShow = () => tick();
|
||||
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
window.addEventListener("focus", onFocus);
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const elapsed = useMemo(() => {
|
||||
const d = now - startedAt;
|
||||
return Math.max(0, Math.floor(d / 1000));
|
||||
}, [now, startedAt]);
|
||||
|
||||
const remaining = useMemo(() => {
|
||||
if (!duration) return 0;
|
||||
return Math.max(0, duration - elapsed);
|
||||
}, [duration, elapsed]);
|
||||
|
||||
const awayMs = useMemo(() => {
|
||||
const base = awayTotalMsRef.current;
|
||||
const extra = awayStartedAtRef.current ? now - awayStartedAtRef.current : 0;
|
||||
return Math.max(0, base + extra);
|
||||
}, [now, isAway]); // isAway 변화 시 즉시 반영
|
||||
|
||||
const awaySeconds = useMemo(() => Math.floor(awayMs / 1000), [awayMs]);
|
||||
|
||||
const focusSeconds = Math.max(0, elapsed - awaySeconds);
|
||||
|
||||
const timeMain = useMemo(() => {
|
||||
if (mode === "freeflow") return formatHHMMSS(elapsed);
|
||||
return formatHHMMSS(remaining);
|
||||
}, [elapsed, remaining, mode]);
|
||||
|
||||
// 타임아웃 종료(점프 포함)
|
||||
useEffect(() => {
|
||||
if (!duration) return;
|
||||
if (endedRef.current) return;
|
||||
if (elapsed < duration) return;
|
||||
|
||||
endedRef.current = true;
|
||||
|
||||
localStorage.setItem(LS.endElapsed, String(duration));
|
||||
localStorage.setItem(LS.endAway, String(awaySeconds));
|
||||
localStorage.setItem(LS.endReason, "timed_out");
|
||||
|
||||
localStorage.setItem(LS.active, "0");
|
||||
router.push(`/session/end?mode=${mode}`);
|
||||
}, [elapsed, duration, mode, router, awaySeconds]);
|
||||
|
||||
// freeflow 60분 토스트(점프 시에도 “현재 배수” 기준으로 1회만)
|
||||
useEffect(() => {
|
||||
if (mode !== "freeflow") return;
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
if (mins > 0 && mins % 60 === 0 && mins !== lastCheckpointRef.current) {
|
||||
lastCheckpointRef.current = mins;
|
||||
showToast(`${mins}분 지났어요`);
|
||||
}
|
||||
}, [elapsed, mode]);
|
||||
|
||||
// 체크인(ask) 트리거: freeflow 제외 / 자리비움 제외 / 이미 열림 제외 / 미션 중 제외
|
||||
useEffect(() => {
|
||||
if (mode === "freeflow") return;
|
||||
if (isAway) return;
|
||||
if (checkinOpen) return;
|
||||
if (mission) return;
|
||||
|
||||
for (const t of checkinTimes) {
|
||||
if (elapsed >= t && !shownCheckinsRef.current.has(t)) {
|
||||
shownCheckinsRef.current.add(t);
|
||||
setCheckinStep("ask");
|
||||
setCheckinOpen(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [elapsed, mode, isAway, checkinOpen, mission, checkinTimes]);
|
||||
|
||||
// ✅ 자리비움 토글(누적 ms 기반)
|
||||
const toggleAway = () => {
|
||||
const t = Date.now();
|
||||
const next = !isAway;
|
||||
|
||||
if (next) {
|
||||
// away 시작
|
||||
awayStartedAtRef.current = t;
|
||||
localStorage.setItem(LS.awayStartedAt, String(t));
|
||||
localStorage.setItem(LS.isAway, "1");
|
||||
setIsAway(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// away 종료
|
||||
if (awayStartedAtRef.current) {
|
||||
awayTotalMsRef.current += t - awayStartedAtRef.current;
|
||||
localStorage.setItem(LS.awayTotalMs, String(awayTotalMsRef.current));
|
||||
}
|
||||
awayStartedAtRef.current = null;
|
||||
localStorage.removeItem(LS.awayStartedAt);
|
||||
localStorage.setItem(LS.isAway, "0");
|
||||
setIsAway(false);
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
if (endedRef.current) return;
|
||||
endedRef.current = true;
|
||||
|
||||
localStorage.setItem(LS.endElapsed, String(elapsed));
|
||||
localStorage.setItem(LS.endAway, String(awaySeconds));
|
||||
localStorage.setItem(LS.endReason, "manual");
|
||||
|
||||
localStorage.setItem(LS.active, "0");
|
||||
router.push(`/session/end?mode=${mode}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
|
||||
<header className="px-5 pt-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
||||
hushroom
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-semibold text-slate-600">
|
||||
집중 {Math.floor(focusSeconds / 60)}분 · 자리비움{" "}
|
||||
{Math.floor(awaySeconds / 60)}분
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PresenceDots participants={participants} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10 pt-6">
|
||||
{toast && (
|
||||
<div className="mb-4 rounded-2xl border border-[#D7E0EE] bg-white px-4 py-3 text-sm text-slate-700 shadow-sm">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timer */}
|
||||
<div
|
||||
className="rounded-3xl border bg-white px-6 py-6 shadow-sm"
|
||||
style={{ borderColor: BORDER }}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-600">
|
||||
{modeLabel(mode)}
|
||||
</div>
|
||||
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
||||
{mode === "freeflow"
|
||||
? "원할 때 종료"
|
||||
: "한 번 실행되고 끝나면 요약으로 이동"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Goal */}
|
||||
{goal && (
|
||||
<div
|
||||
className="mt-4 rounded-2xl border bg-[#F1F5FF] px-4 py-3"
|
||||
style={{ borderColor: BORDER }}
|
||||
>
|
||||
<div className="text-xs font-semibold text-slate-600">
|
||||
이번 목표
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-slate-900 line-clamp-2 break-words">
|
||||
{goal}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
||||
{timeMain}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAway}
|
||||
className="rounded-3xl border bg-white px-4 py-4 text-base font-semibold text-slate-800 shadow-sm transition active:scale-[0.99]"
|
||||
style={{ borderColor: BORDER }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = HOVER)
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = "#FFFFFF")
|
||||
}
|
||||
>
|
||||
{isAway ? "복귀" : "자리비움"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEnd}
|
||||
className="rounded-3xl px-4 py-4 text-base font-semibold text-white shadow-sm transition active:scale-[0.99]"
|
||||
style={{ backgroundColor: PRIMARY }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = PRIMARY)
|
||||
}
|
||||
>
|
||||
종료
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PresenceDots({ participants }: { participants: Participant[] }) {
|
||||
const MAX = 12;
|
||||
const visible = participants.slice(0, MAX);
|
||||
const extra = Math.max(0, participants.length - visible.length);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{visible.map((p) => (
|
||||
<Dot key={p.id} status={p.status} isSelf={p.isSelf} />
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<div className="text-sm font-semibold text-slate-600">+{extra}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ status, isSelf }: { status: PresenceStatus; isSelf: boolean }) {
|
||||
const base = status === "away" ? "bg-slate-900/20" : "bg-slate-900/60";
|
||||
const ring = isSelf ? "ring-2 ring-[#2F6FED]" : "ring-0";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${base} ${ring}`}
|
||||
aria-label={isSelf ? "나" : status === "away" ? "자리비움" : "집중"}
|
||||
title={isSelf ? "나" : status === "away" ? "자리비움" : "집중"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { getCurrentVoyage, getPreferences, saveCurrentVoyage } from '@/shared/lib/store';
|
||||
import { getCurrentVoyage, saveCurrentVoyage } from '@/shared/lib/store';
|
||||
import { Voyage } from '@/shared/types';
|
||||
|
||||
const getVoyageFromStore = () => {
|
||||
@@ -17,18 +17,38 @@ const getEndTime = (voyage: Voyage | null) => {
|
||||
return voyage.startedAt + voyage.durationMinutes * 60 * 1000;
|
||||
};
|
||||
|
||||
const getInitialTimerSeconds = (voyage: Voyage | null) => {
|
||||
if (!voyage) return 0;
|
||||
|
||||
if (voyage.durationMinutes === 0) {
|
||||
return Math.max(0, Math.floor((Date.now() - voyage.startedAt) / 1000));
|
||||
}
|
||||
|
||||
const endTime = getEndTime(voyage);
|
||||
if (!endTime) return 0;
|
||||
return Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
|
||||
};
|
||||
|
||||
const formatHHMMSS = (totalSeconds: number) => {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes
|
||||
.toString()
|
||||
.padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export function useFlightSession() {
|
||||
const router = useRouter();
|
||||
const [voyage] = useState<Voyage | null>(() => getVoyageFromStore());
|
||||
const [timeLeft, setTimeLeft] = useState<number>(() => {
|
||||
const current = getVoyageFromStore();
|
||||
const endTime = getEndTime(current);
|
||||
if (!endTime) return 0;
|
||||
return Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
|
||||
});
|
||||
const [timeLeft, setTimeLeft] = useState<number>(() =>
|
||||
getInitialTimerSeconds(getVoyageFromStore()),
|
||||
);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [hideSeconds] = useState(() => getPreferences().hideSeconds);
|
||||
const endTimeRef = useRef<number>(getEndTime(getVoyageFromStore()));
|
||||
const pausedElapsedMsRef = useRef<number>(0);
|
||||
const pausedAtMsRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (voyage) return;
|
||||
@@ -40,6 +60,12 @@ export function useFlightSession() {
|
||||
if (!voyage || isPaused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (voyage.durationMinutes === 0) {
|
||||
const elapsedMs = Date.now() - voyage.startedAt - pausedElapsedMsRef.current;
|
||||
setTimeLeft(Math.max(0, Math.floor(elapsedMs / 1000)));
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = endTimeRef.current - Date.now();
|
||||
|
||||
if (diff <= 0) {
|
||||
@@ -55,6 +81,21 @@ export function useFlightSession() {
|
||||
}, [voyage, isPaused]);
|
||||
|
||||
const handlePauseToggle = () => {
|
||||
if (voyage?.durationMinutes === 0) {
|
||||
if (isPaused) {
|
||||
if (pausedAtMsRef.current !== null) {
|
||||
pausedElapsedMsRef.current += Date.now() - pausedAtMsRef.current;
|
||||
}
|
||||
pausedAtMsRef.current = null;
|
||||
setIsPaused(false);
|
||||
return;
|
||||
}
|
||||
|
||||
pausedAtMsRef.current = Date.now();
|
||||
setIsPaused(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaused) {
|
||||
endTimeRef.current = Date.now() + timeLeft * 1000;
|
||||
setIsPaused(false);
|
||||
@@ -65,33 +106,23 @@ export function useFlightSession() {
|
||||
};
|
||||
|
||||
const handleFinish = () => {
|
||||
if (!voyage) return;
|
||||
if (!voyage) return null;
|
||||
|
||||
const endedVoyage: Voyage = {
|
||||
...voyage,
|
||||
endedAt: Date.now(),
|
||||
endedAt: voyage.endedAt || Date.now(),
|
||||
};
|
||||
|
||||
saveCurrentVoyage(endedVoyage);
|
||||
router.push('/debrief');
|
||||
return endedVoyage;
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (hideSeconds) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
};
|
||||
const formatTime = (seconds: number) => formatHHMMSS(seconds);
|
||||
|
||||
return {
|
||||
voyage,
|
||||
timeLeft,
|
||||
isCountdownCompleted: Boolean(voyage && voyage.durationMinutes > 0 && timeLeft === 0),
|
||||
isPaused,
|
||||
formattedTime: formatTime(timeLeft),
|
||||
handlePauseToggle,
|
||||
|
||||
@@ -1,14 +1,125 @@
|
||||
import { useFlightSession } from '@/features/flight-session/model/useFlightSession';
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useFlightSession } from "@/features/flight-session/model/useFlightSession";
|
||||
import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store";
|
||||
import { Voyage, VoyageStatus } from "@/shared/types";
|
||||
|
||||
const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [
|
||||
{ value: "completed", label: "계획대로", desc: "목표를 달성했습니다" },
|
||||
{ value: "partial", label: "부분 진행", desc: "절반의 성과를 만들었습니다" },
|
||||
{
|
||||
value: "reoriented",
|
||||
label: "방향 재설정",
|
||||
desc: "우선순위를 새로 정했습니다",
|
||||
},
|
||||
];
|
||||
const FINISH_HOLD_MS = 1000;
|
||||
|
||||
export function FlightHudWidget() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
voyage,
|
||||
isPaused,
|
||||
formattedTime,
|
||||
timeLeft,
|
||||
isCountdownCompleted,
|
||||
handlePauseToggle,
|
||||
handleFinish,
|
||||
} = useFlightSession();
|
||||
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 resetHold = () => {
|
||||
if (holdRafRef.current !== null) {
|
||||
cancelAnimationFrame(holdRafRef.current);
|
||||
holdRafRef.current = null;
|
||||
}
|
||||
holdStartAtRef.current = null;
|
||||
isHoldCompletedRef.current = false;
|
||||
setHoldProgress(0);
|
||||
};
|
||||
|
||||
const openDebriefByHold = () => {
|
||||
isHoldCompletedRef.current = true;
|
||||
setHoldProgress(1);
|
||||
openDebriefModal();
|
||||
resetHold();
|
||||
};
|
||||
|
||||
const tickHoldProgress = (timestamp: number) => {
|
||||
if (holdStartAtRef.current === null) return;
|
||||
|
||||
const elapsed = timestamp - holdStartAtRef.current;
|
||||
const nextProgress = Math.min(1, elapsed / FINISH_HOLD_MS);
|
||||
setHoldProgress(nextProgress);
|
||||
|
||||
if (nextProgress >= 1) {
|
||||
openDebriefByHold();
|
||||
return;
|
||||
}
|
||||
|
||||
holdRafRef.current = requestAnimationFrame(tickHoldProgress);
|
||||
};
|
||||
|
||||
const startHoldToFinish = () => {
|
||||
if (isDebriefOpen) return;
|
||||
|
||||
resetHold();
|
||||
holdStartAtRef.current = performance.now();
|
||||
holdRafRef.current = requestAnimationFrame(tickHoldProgress);
|
||||
};
|
||||
|
||||
const cancelHoldToFinish = () => {
|
||||
if (isHoldCompletedRef.current) return;
|
||||
resetHold();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (holdRafRef.current !== null) {
|
||||
cancelAnimationFrame(holdRafRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!voyage) return null;
|
||||
|
||||
@@ -16,18 +127,25 @@ export function FlightHudWidget() {
|
||||
<>
|
||||
<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">
|
||||
{voyage.routeName} · {isPaused ? '일시정지' : '순항 중'}
|
||||
{voyage.routeName} · {isPaused ? "일시정지" : "순항 중"}
|
||||
</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'}`}
|
||||
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 max-w-2xl px-4 text-center text-xl font-medium leading-relaxed text-slate-200 drop-shadow-md md:text-2xl">
|
||||
“{voyage.missionText}”
|
||||
<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">
|
||||
이번 항해 목표
|
||||
</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">
|
||||
@@ -35,15 +153,111 @@ export function FlightHudWidget() {
|
||||
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 ? '다시 시작' : '일시정지'}
|
||||
{isPaused ? "다시 시작" : "일시정지"}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
className="rounded-full 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:bg-indigo-500 hover:text-white"
|
||||
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"
|
||||
>
|
||||
{timeLeft === 0 ? '도착 (회고)' : '항해 종료'}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 origin-left rounded-full bg-indigo-400/45 transition-transform duration-75"
|
||||
style={{ transform: `scaleX(${holdProgress})` }}
|
||||
/>
|
||||
<span className="relative z-10">
|
||||
{isCountdownCompleted ? "도착 (회고)" : "항해 종료"}
|
||||
</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">
|
||||
이번 항해를 정리하세요
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
짧게 기록하고 항해일지에 저장합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleDebriefSubmit} className="space-y-6">
|
||||
<section>
|
||||
<label className="mb-3 block text-sm font-medium text-slate-300">
|
||||
항해 결과
|
||||
</label>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{statusOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setStatus(opt.value)}
|
||||
className={`rounded-xl border p-4 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="mb-1 font-bold text-slate-200">
|
||||
{opt.label}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">{opt.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-300">
|
||||
이번 항해에서 확보한 것
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={progress}
|
||||
onChange={(event) => setProgress(event.target.value)}
|
||||
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"
|
||||
>
|
||||
취소
|
||||
</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"
|
||||
>
|
||||
항해일지 저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user