- {/* TODO(담당자): 포커스 진입/진행 UI 구현 (포커스 종료 Bottom Sheet 포함) */}
-
포커스 진행 (TODO)
+ // AppShell 전역 padding은 유지하고 배경형 세션 화면만 상단으로 확장한다.
+ // margin box 높이는 기존 main 영역과 같아서 문서 전체 높이나 다른 라우트에는 영향을 주지 않는다.
+
+ {backgroundUrl && !imageError && (
+
+
setImageError(true)}
+ />
+ {/* 상단 검은 바의 원인은 오버레이가 아니라 AppShell padding 노출이었다.
+ rotate 시 자동 너비가 깨졌던 이력이 있어 w-full을 명시한다. */}
+
+
+
+ {/* 둥근 트레이 모서리 아래로 밝은 배경이 비치지 않도록 마스크를 16px 겹친다. */}
+
+
+
+
+ )}
+ {backgroundUrl && imageError && (
+
+ )}
+
+
+
+
+
+
{session.bookTitle}
+
{session.author}
+
+
+
+ {formatDurationHms(elapsedSeconds)}
+
+
+
+ {/* 이 바깥 wrapper엔 좌우 padding을 주지 않는다 — wrapper에 padding을 걸면 트레이의
+ w-full이 그 padding만큼 좁아진 영역 기준 100%가 돼서 화면 끝까지 안 채워진다.
+ 여백이 필요한 요소(연필 버튼)에만 개별로 px-4를 준다. */}
+
+
+ }
+ onClick={() =>
+ navigate(`/report/${session.bookId}/create`, {
+ state: { bookTitle: session.bookTitle, bookId: session.bookId },
+ })
+ }
+ />
+
+
+
+
+
+
+
+
);
}
diff --git a/src/pages/focus/FocusThemePage.tsx b/src/pages/focus/FocusThemePage.tsx
index c31400a2..f940a981 100644
--- a/src/pages/focus/FocusThemePage.tsx
+++ b/src/pages/focus/FocusThemePage.tsx
@@ -8,22 +8,18 @@ import SectionHeader from "../../components/content/InformationText/SectionHeade
import MaskGradient from "../../components/layout/MaskGradient";
import TopNavigation from "../../components/navigation/topnavigation/TopNavigation";
import { mockFocusThemeSelectOptions } from "../../mocks/focus/focus";
-
-const RECENT_FOCUS_THEME_ID_KEY = "recentFocusThemeId";
-
-function readStoredThemeId(): number | null {
- const raw = localStorage.getItem(RECENT_FOCUS_THEME_ID_KEY);
- if (raw === null) return null;
- const parsed = Number(raw);
- return Number.isNaN(parsed) ? null : parsed;
-}
+import { resetFocusSessionTimer } from "./utils/focusSessionTimer";
+import {
+ readStoredFocusThemeId,
+ saveStoredFocusThemeId,
+} from "./utils/focusThemeStorage";
export default function FocusThemePage() {
const navigate = useNavigate();
// 최근 선택한 테마를 로컬(localStorage)에서 읽어와 기본 선택한다. 서버에는 저장하지 않는다.
const [selectedThemeId, setSelectedThemeId] = useState
(
- readStoredThemeId,
+ readStoredFocusThemeId,
);
const [imageError, setImageError] = useState(false);
@@ -41,11 +37,9 @@ export default function FocusThemePage() {
);
const handleStart = useCallback(() => {
- if (selectedThemeId === null) {
- localStorage.removeItem(RECENT_FOCUS_THEME_ID_KEY);
- } else {
- localStorage.setItem(RECENT_FOCUS_THEME_ID_KEY, String(selectedThemeId));
- }
+ saveStoredFocusThemeId(selectedThemeId);
+ // 실제 API 연동 시 저장값 대신 start 응답의 startedAt을 타이머 기준으로 사용한다.
+ resetFocusSessionTimer();
// TODO: POST /api/v1/focuses/start 연동. 선택한 책(libraryId)이 아직 이 화면까지 전달되지 않아 이동만 처리.
navigate("/focus/session");
}, [navigate, selectedThemeId]);
diff --git a/src/pages/focus/component/FocusEndSheet.tsx b/src/pages/focus/component/FocusEndSheet.tsx
new file mode 100644
index 00000000..be8d5f66
--- /dev/null
+++ b/src/pages/focus/component/FocusEndSheet.tsx
@@ -0,0 +1,101 @@
+import { useRef } from "react";
+
+import Checkbox from "../../../components/section/checkbox/Checkbox";
+import BottomSheet from "../../../components/presentation/modal/bottomsheet/Origin";
+import { formatDurationHms } from "../utils/formatDurationHms";
+
+type FocusEndSheetProps = {
+ open: boolean;
+ elapsedSeconds: number;
+ pageInput: string;
+ isFinished: boolean;
+ onPageInputChange: (value: string) => void;
+ onFinishedChange: (checked: boolean) => void;
+ onClose: () => void;
+ onSubmit: () => void;
+};
+
+export default function FocusEndSheet({
+ open,
+ elapsedSeconds,
+ pageInput,
+ isFinished,
+ onPageInputChange,
+ onFinishedChange,
+ onClose,
+ onSubmit,
+}: FocusEndSheetProps) {
+ const pageInputRef = useRef(null);
+
+ return (
+
+
+ {/* 좌하단 취소 버튼과 기능이 겹쳐서 X는 뺐다(디자이너 확인, Figma 미반영).
+ title prop 대신 여기서 직접 렌더링해 공용 BottomSheet 헤더(X 포함)는 건드리지 않는다. */}
+
+ 포커스 종료
+
+
+
+ {/* 공용 TextField와 배경색·단위 표현이 달라 종료 시트 전용 필드로 구성한다. */}
+
+
독서 시간
+
+
+ {formatDurationHms(elapsedSeconds)}
+
+
+
+
+ {/* 숫자 삭제를 방해하지 않도록 "쪽" 단위를 input 값과 분리한다. */}
+
+
읽은 분량
+
pageInputRef.current?.focus()}
+ >
+
+ onPageInputChange(event.target.value.replace(/\D/g, ""))
+ }
+ placeholder={
+ pageInput ? undefined : "몇 쪽까지 읽었는지 입력해주세요."
+ }
+ inputMode="numeric"
+ size={pageInput ? pageInput.length : undefined}
+ className={[
+ "bg-transparent text-gray-90 text-body-14-r placeholder:text-gray-50",
+ "caret-gray-50 outline-none",
+ pageInput ? "w-auto min-w-0" : "w-full",
+ ].join(" ")}
+ />
+ {pageInput && (
+ 쪽
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/focus/utils/focusSessionTimer.ts b/src/pages/focus/utils/focusSessionTimer.ts
new file mode 100644
index 00000000..9c1f2b4f
--- /dev/null
+++ b/src/pages/focus/utils/focusSessionTimer.ts
@@ -0,0 +1,83 @@
+export type FocusSessionTimerState = {
+ startedAtMs: number;
+ pausedAtMs: number | null;
+ totalPausedMs: number;
+};
+
+const FOCUS_SESSION_TIMER_KEY = "focusSessionTimer";
+
+function saveFocusSessionTimer(state: FocusSessionTimerState) {
+ sessionStorage.setItem(FOCUS_SESSION_TIMER_KEY, JSON.stringify(state));
+ return state;
+}
+
+function isFocusSessionTimerState(
+ value: unknown,
+): value is FocusSessionTimerState {
+ if (typeof value !== "object" || value === null) return false;
+
+ const state = value as Partial;
+ return (
+ typeof state.startedAtMs === "number" &&
+ (state.pausedAtMs === null || typeof state.pausedAtMs === "number") &&
+ typeof state.totalPausedMs === "number"
+ );
+}
+
+export function resetFocusSessionTimer(now = Date.now()) {
+ return saveFocusSessionTimer({
+ startedAtMs: now,
+ pausedAtMs: null,
+ totalPausedMs: 0,
+ });
+}
+
+export function readOrCreateFocusSessionTimer() {
+ const stored = sessionStorage.getItem(FOCUS_SESSION_TIMER_KEY);
+
+ if (stored !== null) {
+ try {
+ const parsed: unknown = JSON.parse(stored);
+ if (isFocusSessionTimerState(parsed)) return parsed;
+ } catch {
+ // 잘못된 임시 값은 새 세션으로 교체한다.
+ }
+ }
+
+ return resetFocusSessionTimer();
+}
+
+export function pauseFocusSessionTimer(
+ state: FocusSessionTimerState,
+ now = Date.now(),
+) {
+ if (state.pausedAtMs !== null) return state;
+ return saveFocusSessionTimer({ ...state, pausedAtMs: now });
+}
+
+export function resumeFocusSessionTimer(
+ state: FocusSessionTimerState,
+ now = Date.now(),
+) {
+ if (state.pausedAtMs === null) return state;
+
+ return saveFocusSessionTimer({
+ ...state,
+ pausedAtMs: null,
+ totalPausedMs:
+ state.totalPausedMs + Math.max(0, now - state.pausedAtMs),
+ });
+}
+
+export function getFocusElapsedSeconds(
+ state: FocusSessionTimerState,
+ now = Date.now(),
+) {
+ const endAtMs = state.pausedAtMs ?? now;
+ const elapsedMs = endAtMs - state.startedAtMs - state.totalPausedMs;
+ return Math.max(0, Math.floor(elapsedMs / 1000));
+}
+
+export function clearFocusSessionTimer() {
+ sessionStorage.removeItem(FOCUS_SESSION_TIMER_KEY);
+}
diff --git a/src/pages/focus/utils/focusThemeStorage.ts b/src/pages/focus/utils/focusThemeStorage.ts
new file mode 100644
index 00000000..657518f5
--- /dev/null
+++ b/src/pages/focus/utils/focusThemeStorage.ts
@@ -0,0 +1,18 @@
+const RECENT_FOCUS_THEME_ID_KEY = "recentFocusThemeId";
+
+export function readStoredFocusThemeId(): number | null {
+ const raw = localStorage.getItem(RECENT_FOCUS_THEME_ID_KEY);
+ if (raw === null) return null;
+
+ const parsed = Number(raw);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function saveStoredFocusThemeId(themeId: number | null) {
+ if (themeId === null) {
+ localStorage.removeItem(RECENT_FOCUS_THEME_ID_KEY);
+ return;
+ }
+
+ localStorage.setItem(RECENT_FOCUS_THEME_ID_KEY, String(themeId));
+}
diff --git a/src/pages/focus/utils/formatDurationHms.ts b/src/pages/focus/utils/formatDurationHms.ts
new file mode 100644
index 00000000..62b7c3d9
--- /dev/null
+++ b/src/pages/focus/utils/formatDurationHms.ts
@@ -0,0 +1,8 @@
+export function formatDurationHms(totalSeconds: number) {
+ const pad = (value: number) => String(value).padStart(2, "0");
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
+}
diff --git a/src/pages/report/EmotionRecordsPage.tsx b/src/pages/report/EmotionRecordsPage.tsx
index e456d958..e5a63453 100644
--- a/src/pages/report/EmotionRecordsPage.tsx
+++ b/src/pages/report/EmotionRecordsPage.tsx
@@ -124,6 +124,7 @@ export default function IndividueleReportPage() {
))}