feat: 세션, 세션 종료화면 생성
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
.idea
|
||||||
|
|||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
67
.idea/workspace.xml
generated
67
.idea/workspace.xml
generated
@@ -1,67 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AutoImportSettings">
|
|
||||||
<option name="autoReloadType" value="SELECTIVE" />
|
|
||||||
</component>
|
|
||||||
<component name="ChangeListManager">
|
|
||||||
<list default="true" id="9e413d25-a9a8-4d83-9fd6-a4d134df4e1b" name="변경" comment="">
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/app/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/app/page.tsx" afterDir="false" />
|
|
||||||
</list>
|
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
|
||||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
|
||||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
|
||||||
</component>
|
|
||||||
<component name="Git.Settings">
|
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectColorInfo"><![CDATA[{
|
|
||||||
"associatedIndex": 3
|
|
||||||
}]]></component>
|
|
||||||
<component name="ProjectId" id="38yBJdMOvLXJQYiNoTURYc7ZYHL" />
|
|
||||||
<component name="ProjectViewState">
|
|
||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
|
||||||
<option name="showLibraryContents" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PropertiesComponent"><![CDATA[{
|
|
||||||
"keyToString": {
|
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
|
||||||
"dart.analysis.tool.window.visible": "false",
|
|
||||||
"git-widget-placeholder": "main",
|
|
||||||
"last_opened_file_path": "/Users/ijeongmin/Desktop/corpi/hushroom/web",
|
|
||||||
"node.js.detected.package.eslint": "true",
|
|
||||||
"node.js.detected.package.tslint": "true",
|
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
|
||||||
"nodejs_package_manager_path": "npm",
|
|
||||||
"project.structure.last.edited": "프로젝트",
|
|
||||||
"project.structure.proportion": "0.0",
|
|
||||||
"project.structure.side.proportion": "0.0",
|
|
||||||
"ts.external.directory.path": "/Users/ijeongmin/Desktop/corpi/hushroom/web/node_modules/typescript/lib",
|
|
||||||
"vue.rearranger.settings.migration": "true"
|
|
||||||
}
|
|
||||||
}]]></component>
|
|
||||||
<component name="SharedIndexes">
|
|
||||||
<attachedChunks>
|
|
||||||
<set>
|
|
||||||
<option value="bundled-jdk-9823dce3aa75-fbdcb00ec9e3-intellij.indexing.shared.core-IU-251.25410.129" />
|
|
||||||
<option value="bundled-js-predefined-d6986cc7102b-6a121458b545-JavaScript-IU-251.25410.129" />
|
|
||||||
</set>
|
|
||||||
</attachedChunks>
|
|
||||||
</component>
|
|
||||||
<component name="TaskManager">
|
|
||||||
<task active="true" id="Default" summary="디폴트 작업">
|
|
||||||
<changelist id="9e413d25-a9a8-4d83-9fd6-a4d134df4e1b" name="변경" comment="" />
|
|
||||||
<created>1769761859967</created>
|
|
||||||
<option name="number" value="Default" />
|
|
||||||
<option name="presentableId" value="Default" />
|
|
||||||
<updated>1769761859967</updated>
|
|
||||||
<workItem from="1769761861354" duration="1935000" />
|
|
||||||
</task>
|
|
||||||
<servers />
|
|
||||||
</component>
|
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
|
||||||
<option name="version" value="3" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -9,27 +9,20 @@ type Mode = "freeflow" | "sprint" | "deepwork";
|
|||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const startSession = useCallback(
|
const go = useCallback(
|
||||||
(mode: Mode) => {
|
(mode: Mode) => router.push(`/session?mode=${mode}`),
|
||||||
try {
|
|
||||||
localStorage.setItem("last_mode", mode);
|
|
||||||
} catch {}
|
|
||||||
router.push(`/session?mode=${mode}`);
|
|
||||||
},
|
|
||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen w-full bg-[#E9EEF6]">
|
<main className="min-h-screen w-full bg-[#E9EEF6]">
|
||||||
{/* Top-left logo */}
|
|
||||||
<header className="px-5 pt-6">
|
<header className="px-5 pt-6">
|
||||||
<div className="select-none text-lg font-bold tracking-tight leading-none text-slate-900">
|
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
||||||
hushroom
|
hushroom
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="mx-auto flex min-h-[calc(100vh-56px)] 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">
|
||||||
{/* Section: Freeflow */}
|
|
||||||
<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">
|
||||||
@@ -39,15 +32,14 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => startSession("freeflow")}
|
onClick={() => go("freeflow")}
|
||||||
className="w-full rounded-3xl bg-[#2F6FED] px-8 py-6 text-left text-white shadow-sm transition active:scale-[0.99] hover:bg-[#295FD1]"
|
className="w-full rounded-3xl bg-[#2F6FED] px-8 py-6 text-left text-white shadow-sm transition active:scale-[0.99] hover:bg-[#295FD1]"
|
||||||
aria-label="프리플로우 세션"
|
aria-label="프리플로우"
|
||||||
>
|
>
|
||||||
<div className="text-2xl font-semibold">프리플로우</div>
|
<div className="text-2xl font-semibold">프리플로우</div>
|
||||||
<div className="mt-2 text-lg opacity-90">무제한</div>
|
<div className="mt-2 text-lg opacity-90">무제한</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Divider + Section: Timed sessions */}
|
|
||||||
<div className="my-8 flex items-center gap-3">
|
<div className="my-8 flex items-center gap-3">
|
||||||
<div className="h-px flex-1 bg-[#D7E0EE]" />
|
<div className="h-px flex-1 bg-[#D7E0EE]" />
|
||||||
<div className="text-sm font-semibold text-slate-600">
|
<div className="text-sm font-semibold text-slate-600">
|
||||||
@@ -61,18 +53,8 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<ModeTile
|
<ModeTile title="스프린트" meta="25분" onClick={() => go("sprint")} />
|
||||||
title="스프린트"
|
<ModeTile title="딥워크" meta="90분" onClick={() => go("deepwork")} />
|
||||||
meta="25분"
|
|
||||||
desc="짧게 한 번"
|
|
||||||
onClick={() => startSession("sprint")}
|
|
||||||
/>
|
|
||||||
<ModeTile
|
|
||||||
title="딥워크"
|
|
||||||
meta="90분"
|
|
||||||
desc="길게 한 번"
|
|
||||||
onClick={() => startSession("deepwork")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -82,12 +64,10 @@ export default function HomePage() {
|
|||||||
function ModeTile({
|
function ModeTile({
|
||||||
title,
|
title,
|
||||||
meta,
|
meta,
|
||||||
desc,
|
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
meta: string;
|
meta: string;
|
||||||
desc: string;
|
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -101,7 +81,6 @@ function ModeTile({
|
|||||||
<div className="text-xl font-semibold text-slate-900">{title}</div>
|
<div className="text-xl font-semibold text-slate-900">{title}</div>
|
||||||
<div className="text-lg font-semibold text-blue-700">{meta}</div>
|
<div className="text-lg font-semibold text-blue-700">{meta}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-base text-slate-700">{desc}</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/app/session/end/page.tsx
Normal file
93
src/app/session/end/page.tsx
Normal file
@@ -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 (
|
||||||
|
<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">
|
||||||
|
hushroom
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
465
src/app/session/page.tsx
Normal file
465
src/app/session/page.tsx
Normal file
@@ -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<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) => {
|
||||||
|
// 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<string | null>(null);
|
||||||
|
const toastTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// freeflow checkpoint every 60 minutes
|
||||||
|
const lastCheckpointRef = useRef<number>(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 (
|
||||||
|
<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">
|
||||||
|
hushroom
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Timer */}
|
||||||
|
<div
|
||||||
|
className="rounded-3xl border bg-white px-6 py-6 shadow-sm"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
>
|
||||||
|
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
||||||
|
{timeMain}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<label className="text-sm font-semibold text-slate-600">
|
||||||
|
목표(선택)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={goal}
|
||||||
|
onChange={(e) => 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 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presence */}
|
||||||
|
<div
|
||||||
|
className="mt-4 rounded-3xl border bg-white px-6 py-5 shadow-sm"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-semibold text-slate-700">방</div>
|
||||||
|
<div className="text-sm text-slate-600">{roomKey}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm font-semibold text-slate-700">인원</div>
|
||||||
|
<div className="text-sm text-slate-600">{participants.length}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<PresenceDots participants={participants} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAway((v) => !v)}
|
||||||
|
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={onCheckIn}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
체크인
|
||||||
|
</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" ? "자리비움" : "집중"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user