diff --git a/.gitignore b/.gitignore index 5ef6a52..0367b47 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.idea diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index cd34ce9..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - 1769761859967 - - - - - - \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index d29b3b5..326ded3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,27 +9,20 @@ type Mode = "freeflow" | "sprint" | "deepwork"; export default function HomePage() { const router = useRouter(); - const startSession = useCallback( - (mode: Mode) => { - try { - localStorage.setItem("last_mode", mode); - } catch {} - router.push(`/session?mode=${mode}`); - }, + const go = useCallback( + (mode: Mode) => router.push(`/session?mode=${mode}`), [router], ); return (
- {/* Top-left logo */}
-
+
hushroom
-
- {/* Section: Freeflow */} +
자유 세션
@@ -39,15 +32,14 @@ export default function HomePage() { - {/* Divider + Section: Timed sessions */}
@@ -61,18 +53,8 @@ export default function HomePage() {
- startSession("sprint")} - /> - startSession("deepwork")} - /> + go("sprint")} /> + go("deepwork")} />
@@ -82,12 +64,10 @@ export default function HomePage() { function ModeTile({ title, meta, - desc, onClick, }: { title: string; meta: string; - desc: string; onClick: () => void; }) { return ( @@ -101,7 +81,6 @@ function ModeTile({
{title}
{meta}
-
{desc}
); } diff --git a/src/app/session/end/page.tsx b/src/app/session/end/page.tsx new file mode 100644 index 0000000..90d7981 --- /dev/null +++ b/src/app/session/end/page.tsx @@ -0,0 +1,93 @@ +// app/session/end/page.tsx +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useMemo } 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 SessionEndPage() { + const router = useRouter(); + const params = useSearchParams(); + + const mode = useMemo(() => clampMode(params.get("mode")), [params]); + const elapsed = useMemo(() => { + const v = Number(params.get("elapsed") ?? "0"); + return Number.isFinite(v) ? v : 0; + }, [params]); + + return ( +
+
+
+ hushroom +
+
+ +
+
+
+ {modeLabel(mode)} +
+
+ {hhmmss(elapsed)} +
+
세션이 종료됐어요
+
+ +
+ + + +
+
+
+ ); +} diff --git a/src/app/session/page.tsx b/src/app/session/page.tsx new file mode 100644 index 0000000..0f04172 --- /dev/null +++ b/src/app/session/page.tsx @@ -0,0 +1,465 @@ +// app/session/page.tsx +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { 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; +}; + +const BG = "#E9EEF6"; +const BORDER = "#C9D7F5"; +const PRIMARY = "#2F6FED"; +const PRIMARY_HOVER = "#295FD1"; +const HOVER = "#F1F5FF"; + +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}`; +} + +/** + * 로컬 Presence (Supabase 교체 전용) + * - 같은 브라우저에서 여러 탭/창을 열면 "인원 점"이 늘어남 + * - BroadcastChannel 미지원 환경은 localStorage 이벤트로 fallback + * + * 교체 포인트: + * const { participants } = useLocalPresence(roomKey, status) + * 를 + * const { participants } = useSupabasePresence(roomKey, status) + * 같은 형태로 바꾸면 UI는 그대로 유지 가능 + */ +function useLocalPresence(roomKey: string, status: PresenceStatus) { + const selfId = useMemo(() => { + // 탭 단위 고유 ID + const key = `hushroom:selfId:${roomKey}`; + const existing = sessionStorage.getItem(key); + if (existing) return existing; + + // 짧고 충돌 확률 낮은 ID + 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(() => [ + { id: selfId, status, lastSeen: Date.now(), isSelf: true }, + ]); + + const participantsRef = useRef>>( + new Map([[selfId, { id: selfId, status, lastSeen: Date.now() }]]), + ); + + const channelRef = useRef(null); + const heartbeatRef = useRef(null); + const cleanupRef = useRef(null); + + const publish = (payload: any) => { + // BroadcastChannel 우선 + if (channelRef.current) { + channelRef.current.postMessage(payload); + return; + } + // fallback: localStorage event + 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; + }); + + // self 상태 최신화(React state 변경 시 반영) + 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 { + // fallback: storage event + 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 {} + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + } + + return () => { + channelRef.current?.close(); + channelRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roomKey]); + + useEffect(() => { + // 내 상태 업데이트 + ping + const now = Date.now(); + participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now }); + publish({ type: "ping", roomKey, from: selfId, status, ts: now }); + syncStateToReact(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status, roomKey, selfId]); + + useEffect(() => { + // heartbeat: 2초마다 ping + 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); + + // cleanup: 4초마다 오래된 참가자 제거(탭 닫힘 대비) + cleanupRef.current = window.setInterval(() => { + const now = Date.now(); + const STALE_MS = 9000; // 9초 이상 ping 없으면 제거 + 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() }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roomKey, selfId, status]); + + return { participants, selfId }; +} + +export default function SessionPage() { + 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 [isAway, setIsAway] = useState(false); + + // presence (로컬) + const presenceStatus: PresenceStatus = isAway ? "away" : "focus"; + const roomKey = "lounge"; // 나중에 방 분리하면 여기만 바꾸면 됨 + const { participants } = useLocalPresence(roomKey, presenceStatus); + + // time + const [elapsed, setElapsed] = useState(0); + const [remaining, setRemaining] = useState(duration ?? 0); + + // toast + const [toast, setToast] = useState(null); + const toastTimerRef = useRef(null); + + // freeflow checkpoint every 60 minutes + const lastCheckpointRef = useRef(0); + + const showToast = (msg: string) => { + setToast(msg); + if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current); + toastTimerRef.current = window.setTimeout(() => setToast(null), 8000); + }; + + useEffect(() => { + setElapsed(0); + if (duration) setRemaining(duration); + lastCheckpointRef.current = 0; + setIsAway(false); + setToast(null); + if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current); + }, [duration]); + + useEffect(() => { + const id = window.setInterval(() => { + setElapsed((prev) => prev + 1); + + if (duration) { + setRemaining((prev) => { + const next = Math.max(0, prev - 1); + if (next === 0 && prev !== 0) { + router.push(`/session/end?mode=${mode}&elapsed=${duration}`); + } + return next; + }); + } + }, 1000); + + return () => window.clearInterval(id); + }, [duration, mode, router]); + + 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]); + + const timeMain = useMemo(() => { + if (mode === "freeflow") return formatHHMMSS(elapsed); + return formatHHMMSS(remaining); + }, [elapsed, remaining, mode]); + + const onCheckIn = () => showToast("체크인 기록됨"); + const onEnd = () => + router.push(`/session/end?mode=${mode}&elapsed=${elapsed}`); + + return ( +
+
+
+ hushroom +
+
+ +
+ {toast && ( +
+ {toast} +
+ )} + +
+
+ {modeLabel(mode)} +
+
+ {mode === "freeflow" + ? "원할 때 종료" + : "한 번 실행되고 끝나면 요약으로 이동"} +
+
+ + {/* Timer */} +
+
+ {timeMain} +
+ +
+ + setGoal(e.target.value)} + placeholder="이번 세션 목표(선택)" + className="mt-2 w-full rounded-2xl border px-4 py-3 text-base text-slate-900 outline-none" + style={{ borderColor: BORDER }} + /> +
+
+ + {/* Presence */} +
+
+
+
{roomKey}
+
+ +
+
인원
+
{participants.length}
+
+ +
+ +
+
+ + {/* Actions */} +
+ + + + + +
+
+
+ ); +} + +function PresenceDots({ participants }: { participants: Participant[] }) { + const MAX = 12; + const visible = participants.slice(0, MAX); + const extra = Math.max(0, participants.length - visible.length); + + return ( +
+ {visible.map((p) => ( + + ))} + {extra > 0 && ( +
+{extra}
+ )} +
+ ); +} + +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 ( +
+ ); +}