refactor: 흔들림 기능 삭제
This commit is contained in:
103
src/app/page.tsx
103
src/app/page.tsx
@@ -18,6 +18,9 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
|
|
||||||
type Mode = "freeflow" | "sprint" | "deepwork";
|
type Mode = "freeflow" | "sprint" | "deepwork";
|
||||||
|
|
||||||
|
const PRIMARY = "#2F6FED";
|
||||||
|
const PRIMARY_HOVER = "#295FD1";
|
||||||
|
|
||||||
function modeLabel(mode: Mode) {
|
function modeLabel(mode: Mode) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case "freeflow":
|
case "freeflow":
|
||||||
@@ -29,6 +32,17 @@ function modeLabel(mode: Mode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modeMeta(mode: Mode) {
|
||||||
|
if (mode === "freeflow") return "무제한";
|
||||||
|
if (mode === "sprint") return "25분";
|
||||||
|
return "90분";
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLabel(mode: Mode) {
|
||||||
|
if (mode === "freeflow") return "집중 시작";
|
||||||
|
return `집중 시작 (${modeMeta(mode)})`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -36,29 +50,28 @@ export default function HomePage() {
|
|||||||
const [mode, setMode] = useState<Mode | null>(null);
|
const [mode, setMode] = useState<Mode | null>(null);
|
||||||
const [goal, setGoal] = useState("");
|
const [goal, setGoal] = useState("");
|
||||||
|
|
||||||
const meta = useMemo(() => {
|
const meta = useMemo(() => (mode ? modeMeta(mode) : ""), [mode]);
|
||||||
if (!mode) return "";
|
|
||||||
if (mode === "freeflow") return "무제한";
|
|
||||||
if (mode === "sprint") return "25분";
|
|
||||||
return "90분";
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
const go = useCallback(
|
const go = useCallback(
|
||||||
(mode: Mode, goal?: string) => {
|
(m: Mode, g?: string) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("mode", mode);
|
params.set("mode", m);
|
||||||
|
|
||||||
if (goal && goal.trim().length > 0) {
|
if (g && g.trim())
|
||||||
localStorage.setItem("hushroom:session-goal", goal.trim());
|
localStorage.setItem("hushroom:session-goal", g.trim());
|
||||||
}
|
else localStorage.removeItem("hushroom:session-goal");
|
||||||
|
|
||||||
|
// nextAction은 사용하지 않음
|
||||||
|
localStorage.removeItem("hushroom:session-nextAction");
|
||||||
|
|
||||||
|
localStorage.setItem("hushroom:last-mode", m);
|
||||||
router.push(`/session?${params.toString()}`);
|
router.push(`/session?${params.toString()}`);
|
||||||
},
|
},
|
||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openDialog = (mode: Mode) => {
|
const openDialog = (m: Mode) => {
|
||||||
setMode(mode);
|
setMode(m);
|
||||||
setGoal("");
|
setGoal("");
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
@@ -75,13 +88,17 @@ export default function HomePage() {
|
|||||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
||||||
hushroom
|
hushroom
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 text-sm font-semibold text-slate-600">
|
||||||
|
딱 한 가지 목표. 바로 시작.
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10">
|
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10">
|
||||||
|
{/* ✅ 파란 CTA = 프리플로우 */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="text-sm font-semibold text-slate-600">자유 세션</div>
|
<div className="text-sm font-semibold text-slate-600">자유 세션</div>
|
||||||
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
||||||
시간 제한 없이, 원할 때 종료 (60분 마다 노크합니다.)
|
시간 제한 없이, 원할 때 종료 (60분마다 가볍게 노크)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,17 +118,21 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<div className="my-8 flex items-center gap-3">
|
<div className="my-8 flex items-center gap-3">
|
||||||
<Separator className="flex-1 bg-[#D7E0EE]" />
|
<Separator className="flex-1 bg-[#D7E0EE]" />
|
||||||
<div className="text-sm font-semibold text-slate-600">
|
<div className="text-sm font-semibold text-slate-600">몰입 블록</div>
|
||||||
시간 고정 세션
|
|
||||||
</div>
|
|
||||||
<Separator className="flex-1 bg-[#D7E0EE]" />
|
<Separator className="flex-1 bg-[#D7E0EE]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="-mt-2 mb-5 text-base leading-relaxed text-slate-700">
|
{/* ✅ 버튼 위에 설명 (버튼 안에 설명 X) */}
|
||||||
|
<div className="-mt-2 mb-5">
|
||||||
|
<div className="text-sm font-semibold text-slate-600">
|
||||||
|
시간 고정 세션
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
||||||
한 번 실행되고 끝나면 요약으로 이동
|
한 번 실행되고 끝나면 요약으로 이동
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sprint / Deepwork */}
|
{/* ✅ row(2열) */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<ModeTile
|
<ModeTile
|
||||||
title="스프린트"
|
title="스프린트"
|
||||||
@@ -154,7 +175,7 @@ function ModeTile({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="h-auto w-full rounded-3xl px-7 text-left active:scale-[0.99]"
|
className="h-auto w-full rounded-3xl px-7 py-5 text-left active:scale-[0.99] hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-baseline justify-between">
|
<div className="flex w-full items-baseline justify-between">
|
||||||
<div className="text-xl font-semibold text-slate-900">{title}</div>
|
<div className="text-xl font-semibold text-slate-900">{title}</div>
|
||||||
@@ -187,32 +208,58 @@ function SessionGoalDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md rounded-2xl">
|
<DialogContent className="sm:max-w-md rounded-2xl">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onStart();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl">세션 목표 설정</DialogTitle>
|
<DialogTitle className="text-xl">세션 목표 설정</DialogTitle>
|
||||||
<div className="text-sm text-slate-600">{title}</div>
|
<div className="text-sm text-slate-600">{title}</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
<Input
|
<Input
|
||||||
value={goal}
|
value={goal}
|
||||||
onChange={(e) => setGoal(e.target.value)}
|
onChange={(e) => setGoal(e.target.value)}
|
||||||
placeholder="목표를 입력하세요"
|
placeholder="지금 할 한 가지를 한 줄로 적어주세요 (선택)"
|
||||||
className="text-lg"
|
className="text-lg focus-visible:ring-2"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.nativeEvent as any).isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">
|
||||||
짧게 적을수록 좋아요. 예: “이력서 1페이지”, “문제 10개”
|
짧게 적을수록 좋아요. 끝이 보이게.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-2">
|
<DialogFooter className="mt-6 gap-2 sm:gap-2">
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onStart} className="rounded-xl" disabled={!mode}>
|
<Button
|
||||||
시작하기
|
type="submit"
|
||||||
|
className="rounded-xl"
|
||||||
|
style={{ backgroundColor: PRIMARY }}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.backgroundColor = PRIMARY)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mode ? startLabel(mode) : "집중 시작"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ type Participant = {
|
|||||||
isSelf: boolean;
|
isSelf: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RecenterReason = "distracted" | "stuck" | "tired" | "overwhelmed";
|
||||||
|
|
||||||
|
type RecenterOption = {
|
||||||
|
id: string;
|
||||||
|
label: string; // 화면 문구(2분 미션)
|
||||||
|
pinText: string; // "다음 단계로 고정" 시 저장될 짧은 문구
|
||||||
|
};
|
||||||
|
|
||||||
const BG = "#E9EEF6";
|
const BG = "#E9EEF6";
|
||||||
const BORDER = "#C9D7F5";
|
const BORDER = "#C9D7F5";
|
||||||
const PRIMARY = "#2F6FED";
|
const PRIMARY = "#2F6FED";
|
||||||
@@ -45,14 +53,120 @@ function formatHHMMSS(totalSeconds: number) {
|
|||||||
return `${hh}:${mm}:${ss}`;
|
return `${hh}:${mm}:${ss}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reasonLabel(r: RecenterReason) {
|
||||||
|
switch (r) {
|
||||||
|
case "distracted":
|
||||||
|
return "산만함";
|
||||||
|
case "stuck":
|
||||||
|
return "막힘";
|
||||||
|
case "tired":
|
||||||
|
return "피로";
|
||||||
|
case "overwhelmed":
|
||||||
|
return "부담";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ 리라이트 원칙(목적/이유 반영)
|
||||||
|
* - 3초 안에 “뭘 하면 되는지” 떠오르는 행동 문구
|
||||||
|
* - 목표 변경/재설정처럼 읽히는 표현 금지
|
||||||
|
* - 작성/입력 강제 금지(원클릭 → 즉시 실행)
|
||||||
|
* - 도구/도메인 중립
|
||||||
|
* - “2분 동안만”이라는 임시성을 기본으로 내포
|
||||||
|
*/
|
||||||
|
function recenterOptions(
|
||||||
|
goal: string,
|
||||||
|
reason: RecenterReason,
|
||||||
|
): RecenterOption[] {
|
||||||
|
const g = goal.trim();
|
||||||
|
const goalHint = g ? `“${g}”로 복귀` : "목표로 복귀";
|
||||||
|
|
||||||
|
if (reason === "distracted") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "dist_1",
|
||||||
|
label: "2분 미션: 방해 요소 1개만 끄기(알림/탭/폰)",
|
||||||
|
pinText: "방해 요소 1개 끄기",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dist_2",
|
||||||
|
label: "2분 미션: 화면을 ‘하나’만 남기기(지금 필요한 것만)",
|
||||||
|
pinText: "화면 하나만 남기기",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dist_3",
|
||||||
|
label: `2분 미션: ${goalHint} — 가장 쉬운 첫 행동 1개`,
|
||||||
|
pinText: g ? `“${g}” 첫 행동 1개` : "첫 행동 1개",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason === "stuck") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "stuck_1",
|
||||||
|
label: "2분 미션: 막히는 건 잠깐 두고, 확실한 것 1개만 진행",
|
||||||
|
pinText: "확실한 것 1개 진행",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stuck_2",
|
||||||
|
label: "2분 미션: 가장 작은 결과물 1개만 만들기(한 줄/한 칸/한 개)",
|
||||||
|
pinText: "가장 작은 결과물 1개",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stuck_3",
|
||||||
|
label: "2분 미션: 60초 정리(다음 한 조각) + 60초 착수(그 조각)",
|
||||||
|
pinText: "정리 60초 + 착수 60초",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason === "tired") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "tired_1",
|
||||||
|
label: "2분 미션: 물 한 모금/자세 정리 후 1분만 손 움직이기",
|
||||||
|
pinText: "물/자세 정리 후 1분",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tired_2",
|
||||||
|
label: "2분 미션: 30초 숨 고르기 + 90초 아주 쉬운 행동",
|
||||||
|
pinText: "30초 숨 + 90초 쉬운 행동",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tired_3",
|
||||||
|
label: `2분 미션: ${goalHint} — 가장 가벼운 한 조각만`,
|
||||||
|
pinText: "가장 가벼운 한 조각",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// overwhelmed
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "ovr_1",
|
||||||
|
label: "2분 미션: (2분만) 범위를 최소로 줄여 ‘첫 행동 1개’만",
|
||||||
|
pinText: "첫 행동 1개만",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ovr_2",
|
||||||
|
label: "2분 미션: 완성 말고 ‘전진’만(아주 작은 1개)",
|
||||||
|
pinText: "아주 작은 1개 전진",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ovr_3",
|
||||||
|
label: `2분 미션: ${goalHint} — 지금 당장 가능한 것 1개`,
|
||||||
|
pinText: "지금 가능한 것 1개",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
||||||
const selfId = useMemo(() => {
|
const selfId = useMemo(() => {
|
||||||
// 탭 단위 고유 ID
|
|
||||||
const key = `hushroom:selfId:${roomKey}`;
|
const key = `hushroom:selfId:${roomKey}`;
|
||||||
const existing = sessionStorage.getItem(key);
|
const existing = sessionStorage.getItem(key);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
// 짧고 충돌 확률 낮은 ID
|
|
||||||
const id = (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
|
const id = (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
|
||||||
.replace(/[^a-zA-Z0-9]/g, "")
|
.replace(/[^a-zA-Z0-9]/g, "")
|
||||||
.slice(0, 16);
|
.slice(0, 16);
|
||||||
@@ -74,12 +188,10 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
const cleanupRef = useRef<number | null>(null);
|
const cleanupRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const publish = (payload: any) => {
|
const publish = (payload: any) => {
|
||||||
// BroadcastChannel 우선
|
|
||||||
if (channelRef.current) {
|
if (channelRef.current) {
|
||||||
channelRef.current.postMessage(payload);
|
channelRef.current.postMessage(payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// fallback: localStorage event
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
`hushroom:presence:${roomKey}`,
|
`hushroom:presence:${roomKey}`,
|
||||||
@@ -99,14 +211,13 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
isSelf: id === selfId,
|
isSelf: id === selfId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// 안정적으로 정렬(자기 자신 먼저, 그 다음 최근 순)
|
|
||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
if (a.isSelf && !b.isSelf) return -1;
|
if (a.isSelf && !b.isSelf) return -1;
|
||||||
if (!a.isSelf && b.isSelf) return 1;
|
if (!a.isSelf && b.isSelf) return 1;
|
||||||
return b.lastSeen - a.lastSeen;
|
return b.lastSeen - a.lastSeen;
|
||||||
});
|
});
|
||||||
|
|
||||||
// self 상태 최신화(React state 변경 시 반영)
|
|
||||||
const self = participantsRef.current.get(selfId);
|
const self = participantsRef.current.get(selfId);
|
||||||
if (self) self.lastSeen = now;
|
if (self) self.lastSeen = now;
|
||||||
|
|
||||||
@@ -114,7 +225,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 채널 세팅
|
|
||||||
if ("BroadcastChannel" in window) {
|
if ("BroadcastChannel" in window) {
|
||||||
const bc = new BroadcastChannel(`hushroom-presence:${roomKey}`);
|
const bc = new BroadcastChannel(`hushroom-presence:${roomKey}`);
|
||||||
channelRef.current = bc;
|
channelRef.current = bc;
|
||||||
@@ -138,7 +248,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// fallback: storage event
|
|
||||||
const onStorage = (e: StorageEvent) => {
|
const onStorage = (e: StorageEvent) => {
|
||||||
if (e.key !== `hushroom:presence:${roomKey}` || !e.newValue) return;
|
if (e.key !== `hushroom:presence:${roomKey}` || !e.newValue) return;
|
||||||
try {
|
try {
|
||||||
@@ -171,7 +280,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
}, [roomKey]);
|
}, [roomKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 내 상태 업데이트 + ping
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now });
|
participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now });
|
||||||
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
|
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
|
||||||
@@ -180,7 +288,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
}, [status, roomKey, selfId]);
|
}, [status, roomKey, selfId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// heartbeat: 2초마다 ping
|
|
||||||
heartbeatRef.current = window.setInterval(() => {
|
heartbeatRef.current = window.setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
participantsRef.current.set(selfId, {
|
participantsRef.current.set(selfId, {
|
||||||
@@ -192,10 +299,9 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
syncStateToReact();
|
syncStateToReact();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
// cleanup: 4초마다 오래된 참가자 제거(탭 닫힘 대비)
|
|
||||||
cleanupRef.current = window.setInterval(() => {
|
cleanupRef.current = window.setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const STALE_MS = 9000; // 9초 이상 ping 없으면 제거
|
const STALE_MS = 9000;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
participantsRef.current.forEach((p, id) => {
|
participantsRef.current.forEach((p, id) => {
|
||||||
@@ -233,48 +339,130 @@ export default function SessionPage() {
|
|||||||
const duration = useMemo(() => modeDurationSeconds(mode), [mode]);
|
const duration = useMemo(() => modeDurationSeconds(mode), [mode]);
|
||||||
|
|
||||||
const [goal, setGoal] = useState("");
|
const [goal, setGoal] = useState("");
|
||||||
|
const [nextAction, setNextAction] = useState("");
|
||||||
|
|
||||||
const [isAway, setIsAway] = useState(false);
|
const [isAway, setIsAway] = useState(false);
|
||||||
|
const [awaySeconds, setAwaySeconds] = useState(0);
|
||||||
|
|
||||||
// presence (로컬)
|
// presence (로컬)
|
||||||
const presenceStatus: PresenceStatus = isAway ? "away" : "focus";
|
const presenceStatus: PresenceStatus = isAway ? "away" : "focus";
|
||||||
const roomKey = "lounge"; // 나중에 방 분리하면 여기만 바꾸면 됨
|
const roomKey = "lounge";
|
||||||
const { participants } = useLocalPresence(roomKey, presenceStatus);
|
const { participants } = useLocalPresence(roomKey, presenceStatus);
|
||||||
|
|
||||||
// time
|
// time
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const [remaining, setRemaining] = useState(duration ?? 0);
|
const [remaining, setRemaining] = useState(duration ?? 0);
|
||||||
|
|
||||||
|
// refs (interval에서 최신값 쓰기)
|
||||||
|
const elapsedRef = useRef(0);
|
||||||
|
const awayRef = useRef(0);
|
||||||
|
const isAwayRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isAwayRef.current = isAway;
|
||||||
|
}, [isAway]);
|
||||||
|
|
||||||
// toast
|
// toast
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
const toastTimerRef = useRef<number | null>(null);
|
const toastTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// freeflow checkpoint every 60 minutes
|
|
||||||
const lastCheckpointRef = useRef<number>(0);
|
|
||||||
|
|
||||||
const showToast = (msg: string) => {
|
const showToast = (msg: string) => {
|
||||||
setToast(msg);
|
setToast(msg);
|
||||||
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
||||||
toastTimerRef.current = window.setTimeout(() => setToast(null), 8000);
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 세션 초기화(모드/길이 바뀔 때)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setElapsed(0);
|
setElapsed(0);
|
||||||
|
elapsedRef.current = 0;
|
||||||
|
|
||||||
|
setAwaySeconds(0);
|
||||||
|
awayRef.current = 0;
|
||||||
|
|
||||||
if (duration) setRemaining(duration);
|
if (duration) setRemaining(duration);
|
||||||
lastCheckpointRef.current = 0;
|
lastCheckpointRef.current = 0;
|
||||||
|
|
||||||
setIsAway(false);
|
setIsAway(false);
|
||||||
|
isAwayRef.current = false;
|
||||||
|
|
||||||
setToast(null);
|
setToast(null);
|
||||||
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
||||||
|
|
||||||
|
shownCheckinsRef.current = new Set();
|
||||||
|
resetRecenter();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [duration]);
|
}, [duration]);
|
||||||
|
|
||||||
|
// 목표/다음 단계(고정) 로드
|
||||||
|
useEffect(() => {
|
||||||
|
setGoal(localStorage.getItem("hushroom:session-goal") ?? "");
|
||||||
|
setNextAction(localStorage.getItem("hushroom:session-nextAction") ?? "");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 메인 타이머
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
setElapsed((prev) => prev + 1);
|
setElapsed((prev) => {
|
||||||
|
const next = prev + 1;
|
||||||
|
elapsedRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAwayRef.current) {
|
||||||
|
setAwaySeconds((prev) => {
|
||||||
|
const next = prev + 1;
|
||||||
|
awayRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (duration) {
|
if (duration) {
|
||||||
setRemaining((prev) => {
|
setRemaining((prev) => {
|
||||||
const next = Math.max(0, prev - 1);
|
const next = Math.max(0, prev - 1);
|
||||||
if (next === 0 && prev !== 0) {
|
if (next === 0 && prev !== 0) {
|
||||||
router.push(`/session/end?mode=${mode}&elapsed=${duration}`);
|
localStorage.setItem("hushroom:session-elapsed", String(duration));
|
||||||
|
localStorage.setItem(
|
||||||
|
"hushroom:session-away",
|
||||||
|
String(awayRef.current),
|
||||||
|
);
|
||||||
|
localStorage.setItem("hushroom:session-end-reason", "timed_out");
|
||||||
|
router.push(`/session/end?mode=${mode}`);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -284,6 +472,7 @@ export default function SessionPage() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [duration, mode, router]);
|
}, [duration, mode, router]);
|
||||||
|
|
||||||
|
// freeflow 60분 토스트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "freeflow") return;
|
if (mode !== "freeflow") return;
|
||||||
const mins = Math.floor(elapsed / 60);
|
const mins = Math.floor(elapsed / 60);
|
||||||
@@ -293,30 +482,115 @@ export default function SessionPage() {
|
|||||||
}
|
}
|
||||||
}, [elapsed, mode]);
|
}, [elapsed, mode]);
|
||||||
|
|
||||||
|
// 체크인(ask) 트리거: freeflow 제외 / 자리비움 제외 / 이미 열림 제외 / 미션 중 제외
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const goal = localStorage.getItem("hushroom:session-goal") ?? "";
|
if (mode === "freeflow") return;
|
||||||
setGoal(goal);
|
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]);
|
||||||
|
|
||||||
|
// 2분 미션 카운트다운
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mission) return;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const left = Math.max(0, Math.ceil((mission.endsAt - Date.now()) / 1000));
|
||||||
|
setMissionLeft(left);
|
||||||
|
if (left === 0) setCheckinStep("done");
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 250);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [mission]);
|
||||||
|
|
||||||
const timeMain = useMemo(() => {
|
const timeMain = useMemo(() => {
|
||||||
if (mode === "freeflow") return formatHHMMSS(elapsed);
|
if (mode === "freeflow") return formatHHMMSS(elapsed);
|
||||||
return formatHHMMSS(remaining);
|
return formatHHMMSS(remaining);
|
||||||
}, [elapsed, remaining, mode]);
|
}, [elapsed, remaining, mode]);
|
||||||
|
|
||||||
|
const focusSeconds = Math.max(0, elapsed - awaySeconds);
|
||||||
|
|
||||||
|
const openRecenter = () => {
|
||||||
|
setRecenterReason(null);
|
||||||
|
setMission(null);
|
||||||
|
setMissionLeft(0);
|
||||||
|
setCheckinStep("reason");
|
||||||
|
setCheckinOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startMission = (opt: RecenterOption) => {
|
||||||
|
const endsAt = Date.now() + 2 * 60 * 1000;
|
||||||
|
setMission({ option: opt, endsAt });
|
||||||
|
setMissionLeft(120);
|
||||||
|
setCheckinStep("running");
|
||||||
|
setCheckinOpen(true);
|
||||||
|
showToast("좋아요. 딱 2분만.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const endMissionEarly = () => {
|
||||||
|
if (!mission) return;
|
||||||
|
setMission({ ...mission, endsAt: Date.now() });
|
||||||
|
setMissionLeft(0);
|
||||||
|
setCheckinStep("done");
|
||||||
|
};
|
||||||
|
|
||||||
|
const pinMissionAsNext = () => {
|
||||||
|
if (!mission) return;
|
||||||
|
const text = mission.option.pinText.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
setNextAction(text);
|
||||||
|
localStorage.setItem("hushroom:session-nextAction", text);
|
||||||
|
showToast("다음 단계로 고정했어요.");
|
||||||
|
resetRecenter();
|
||||||
|
};
|
||||||
|
|
||||||
|
const unpinNext = () => {
|
||||||
|
setNextAction("");
|
||||||
|
localStorage.removeItem("hushroom:session-nextAction");
|
||||||
|
showToast("고정을 해제했어요.");
|
||||||
|
};
|
||||||
|
|
||||||
const onEnd = () => {
|
const onEnd = () => {
|
||||||
localStorage.setItem("hushroom:session-elapsed", elapsed.toString());
|
localStorage.setItem(
|
||||||
|
"hushroom:session-elapsed",
|
||||||
|
String(elapsedRef.current),
|
||||||
|
);
|
||||||
|
localStorage.setItem("hushroom:session-away", String(awayRef.current));
|
||||||
|
localStorage.setItem("hushroom:session-end-reason", "manual");
|
||||||
router.push(`/session/end?mode=${mode}`);
|
router.push(`/session/end?mode=${mode}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
|
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
|
||||||
<header className="px-5 pt-6">
|
<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">
|
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
||||||
hushroom
|
hushroom
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<section className="mx-auto justify-center flex min-h-[calc(100vh-64px)] max-w-lg flex-col px-5 pb-10 pt-6">
|
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10 pt-6">
|
||||||
{toast && (
|
{toast && (
|
||||||
<div className="mb-4 rounded-2xl border border-[#D7E0EE] bg-white px-4 py-3 text-sm text-slate-700 shadow-sm">
|
<div className="mb-4 rounded-2xl border border-[#D7E0EE] bg-white px-4 py-3 text-sm text-slate-700 shadow-sm">
|
||||||
{toast}
|
{toast}
|
||||||
@@ -329,6 +603,8 @@ export default function SessionPage() {
|
|||||||
style={{ borderColor: BORDER }}
|
style={{ borderColor: BORDER }}
|
||||||
>
|
>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
<div className="text-sm font-semibold text-slate-600">
|
<div className="text-sm font-semibold text-slate-600">
|
||||||
{modeLabel(mode)}
|
{modeLabel(mode)}
|
||||||
</div>
|
</div>
|
||||||
@@ -337,6 +613,8 @@ export default function SessionPage() {
|
|||||||
? "원할 때 종료"
|
? "원할 때 종료"
|
||||||
: "한 번 실행되고 끝나면 요약으로 이동"}
|
: "한 번 실행되고 끝나면 요약으로 이동"}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Goal */}
|
{/* Goal */}
|
||||||
{goal && (
|
{goal && (
|
||||||
@@ -347,17 +625,235 @@ export default function SessionPage() {
|
|||||||
<div className="text-xs font-semibold text-slate-600">
|
<div className="text-xs font-semibold text-slate-600">
|
||||||
이번 목표
|
이번 목표
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xl font-semibold text-slate-900 line-clamp-2">
|
<div className="mt-1 text-xl font-semibold text-slate-900 line-clamp-2 break-words">
|
||||||
{goal}
|
{goal}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Next action (고정) */}
|
||||||
|
{nextAction && (
|
||||||
|
<div
|
||||||
|
className="mt-3 rounded-2xl border bg-white px-4 py-3"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs font-semibold text-slate-600">
|
||||||
|
다음 단계(고정)
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border px-2 py-1 text-xs font-semibold text-slate-700 transition hover:bg-[#F1F5FF] active:scale-[0.99]"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
onClick={unpinNext}
|
||||||
|
>
|
||||||
|
해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 text-xl font-semibold text-blue-700 line-clamp-2 break-words">
|
||||||
|
{nextAction}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
||||||
{timeMain}
|
{timeMain}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Check-in / Recenter banner (임시 미션) */}
|
||||||
|
{checkinOpen && (
|
||||||
|
<div
|
||||||
|
className="mt-4 rounded-2xl border bg-white px-4 py-3 text-sm text-slate-800 shadow-sm"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
>
|
||||||
|
{/* ASK */}
|
||||||
|
{checkinStep === "ask" && (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-semibold">지금 목표에 붙어있나요?</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
onClick={() => {
|
||||||
|
setCheckinOpen(false);
|
||||||
|
setCheckinStep("ask");
|
||||||
|
showToast("좋아요. 이어가요.");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
붙어있음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* REASON */}
|
||||||
|
{checkinStep === "reason" && (
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">어느 쪽에 가까워요?</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
"distracted",
|
||||||
|
"stuck",
|
||||||
|
"tired",
|
||||||
|
"overwhelmed",
|
||||||
|
] as RecenterReason[]
|
||||||
|
).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
className="rounded-2xl border bg-white px-3 py-2 text-left font-semibold transition hover:bg-[#F1F5FF] active:scale-[0.99]"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
onClick={() => {
|
||||||
|
setRecenterReason(r);
|
||||||
|
setCheckinStep("pick");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reasonLabel(r)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
onClick={() => resetRecenter()}
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PICK */}
|
||||||
|
{checkinStep === "pick" && recenterReason && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-semibold">
|
||||||
|
그럼 2분만 ({reasonLabel(recenterReason)})
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
onClick={() => setCheckinStep("reason")}
|
||||||
|
>
|
||||||
|
뒤로
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid gap-2">
|
||||||
|
{recenterOptions(goal, recenterReason).map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
className="rounded-2xl border px-3 py-2 text-left font-semibold transition hover:bg-[#F1F5FF] active:scale-[0.99]"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
onClick={() => startMission(opt)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-slate-500">
|
||||||
|
선택해도 “고정”되지 않습니다. 2분 미션만 실행해요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RUNNING */}
|
||||||
|
{checkinStep === "running" && mission && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">
|
||||||
|
복구 중 · {missionLeft}s
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-slate-700">
|
||||||
|
{mission.option.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
onClick={endMissionEarly}
|
||||||
|
>
|
||||||
|
완료
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-slate-500">
|
||||||
|
2분만 행동에 붙이면 다시 돌아옵니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DONE */}
|
||||||
|
{checkinStep === "done" && mission && (
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">
|
||||||
|
좋아요. 다시 목표로 돌아가요.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
onClick={() => {
|
||||||
|
resetRecenter(); // 기본: 임시 미션은 사라짐
|
||||||
|
showToast("좋아요. 목표로 돌아가요.");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
계속
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
onClick={() => {
|
||||||
|
setMission(null);
|
||||||
|
setMissionLeft(0);
|
||||||
|
setCheckinStep(recenterReason ? "pick" : "reason");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
다시 2분
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl px-3 py-2 font-semibold text-white transition active:scale-[0.99]"
|
||||||
|
style={{ backgroundColor: PRIMARY }}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.backgroundColor = PRIMARY)
|
||||||
|
}
|
||||||
|
onClick={pinMissionAsNext}
|
||||||
|
>
|
||||||
|
다음 단계로 고정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-slate-500">
|
||||||
|
고정은 선택 사항이에요. 기본은 사라집니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user