diff --git a/src/assets/icons/pencil.svg b/src/assets/icons/pencil.svg new file mode 100644 index 00000000..4a8bb09a --- /dev/null +++ b/src/assets/icons/pencil.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/book-cover-placeholder.png b/src/assets/images/book-cover-placeholder.png new file mode 100644 index 00000000..3e11393c Binary files /dev/null and b/src/assets/images/book-cover-placeholder.png differ diff --git a/src/components/action/Button/FAB.tsx b/src/components/action/Button/FAB.tsx index d6a21b8d..48719f5a 100644 --- a/src/components/action/Button/FAB.tsx +++ b/src/components/action/Button/FAB.tsx @@ -1,25 +1,45 @@ -import { type ReactNode } from "react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +type FabSize = "m" | "l"; +type FabVariant = "light" | "dark"; type Props = { icon: ReactNode; - onClick?: () => void; - className?: string; + size?: FabSize; + variant?: FabVariant; +} & Omit, "children">; + +const sizeClassMap: Record = { + m: "h-10 w-10", + l: "h-11 w-11", }; -export default function FAB({ icon, onClick, className }: Props) { - const clickable = Boolean(onClick); +const variantClassMap: Record = { + light: "bg-gray-90", + dark: "bg-gray-25 shadow-elevation-20", +}; +export default function FAB({ + icon, + size = "m", + variant = "light", + className = "", + type = "button", + ...props +}: Props) { return ( -
{icon} -
+ ); } diff --git a/src/components/action/Button/Icon.tsx b/src/components/action/Button/Icon.tsx index f4df8c8e..a3eb0b8e 100644 --- a/src/components/action/Button/Icon.tsx +++ b/src/components/action/Button/Icon.tsx @@ -14,7 +14,7 @@ const base = [ ].join(" "); const sizeClassMap: Record = { - xs: "h-[18px] w-[18px] p-0.5", + xs: "h-4.5 w-4.5 p-0.5", s: "h-6 w-6 p-0.5", m: "h-10 w-10 p-2", }; diff --git a/src/components/action/Button/Solid.tsx b/src/components/action/Button/Solid.tsx index 84d16f1d..65672213 100644 --- a/src/components/action/Button/Solid.tsx +++ b/src/components/action/Button/Solid.tsx @@ -13,8 +13,7 @@ type ButtonProps = { className?: string; } & ButtonHTMLAttributes; -const base = - "inline-flex h-12 items-center justify-center whitespace-nowrap px-6 py-4 rounded-lg "; +const base = "inline-flex items-center justify-center whitespace-nowrap"; const variantClassMap: Record = { primary: "bg-mint-60 text-gray-10", @@ -24,7 +23,7 @@ const variantClassMap: Record = { }; const sizeClassMap: Record = { - s: "h-[38px] text-btn-14-sb rounded-sm px-8 py-3", + s: "h-9.5 text-btn-14-sb rounded-sm px-8 py-3", m: "h-12 text-btn-16-sb rounded-lg px-6 py-4", }; diff --git a/src/components/atomic/BookCover.tsx b/src/components/atomic/BookCover.tsx index 28780a48..3c899bf3 100644 --- a/src/components/atomic/BookCover.tsx +++ b/src/components/atomic/BookCover.tsx @@ -27,7 +27,7 @@ export default function BookCover({ }: BookCoverProps) { const sizeClasses = { XS: "w-11 h-16 rounded-xs", - S: "w-14 h-[82px] rounded-xs", + S: "w-14 h-20.5 rounded-xs", M: "w-25 h-36 rounded-xs", XL: "w-40 h-56 rounded-sm", }; diff --git a/src/components/presentation/modal/bottomsheet/Origin.tsx b/src/components/presentation/modal/bottomsheet/Origin.tsx index a8fc816a..51ceff40 100644 --- a/src/components/presentation/modal/bottomsheet/Origin.tsx +++ b/src/components/presentation/modal/bottomsheet/Origin.tsx @@ -102,7 +102,7 @@ function BottomSheetFooter({ "flex items-center justify-center", "h-12", // 버튼 2개 케이스 왼쪽 버튼 48px 고정 "px-6 py-4", - "rounded-[8px]", + "rounded-lg", "text-btn-16-sb", ].join(" "); @@ -208,9 +208,10 @@ export default function BottomSheet({ className={[ "absolute inset-x-0 bottom-0 mx-auto", "pointer-events-auto", - "w-93.75", + // AppShell 너비를 상한으로 두되 375px 미만 화면에서는 overflow를 막는다. + "w-full max-w-93.75", "flex flex-col items-start", - "px-4 pt-4 pb-8", // 16 16 32 + "px-4 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom))]", "rounded-t-2xl", "bg-gray-15", className, diff --git a/src/components/section/checkbox/Checkbox.tsx b/src/components/section/checkbox/Checkbox.tsx index 5fbbfebf..7ac5873b 100644 --- a/src/components/section/checkbox/Checkbox.tsx +++ b/src/components/section/checkbox/Checkbox.tsx @@ -2,32 +2,39 @@ import * as CheckboxLib from "@radix-ui/react-checkbox"; import { CheckIcon } from "@radix-ui/react-icons"; import React from "react"; - type CheckboxProps = { - text: string; -} + text: string; + /** 부모가 체크 상태를 직접 관리해야 할 때(예: 폼 제출값)만 전달. 없으면 내부 state로 동작 */ + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; +}; -export default function Checkbox ({text} : CheckboxProps) { - //상태 관리 +export default function Checkbox({ + text, + checked, + onCheckedChange, +}: CheckboxProps) { + const [uncontrolledChecked, setUncontrolledChecked] = React.useState(false); + const isControlled = checked !== undefined; + const resolvedChecked = isControlled ? checked : uncontrolledChecked; - const [checked, setChecked] = React.useState(false); + const handleCheckedChange = (value: boolean) => { + if (!isControlled) setUncontrolledChecked(value); + onCheckedChange?.(value); + }; - return( -
- setChecked(value === true)} - className="w-[18px] h-[18px] border border-gray-90 rounded-[2px] - data-[state=checked]:bg-gray-90"> - - - - - -
- - ); -} \ No newline at end of file + return ( +
+ handleCheckedChange(value === true)} + className="h-4.5 w-4.5 rounded-xs border border-gray-90 data-[state=checked]:bg-gray-90" + > + + + + + +
+ ); +} diff --git a/src/mocks/focus/focus.ts b/src/mocks/focus/focus.ts index 70ed1d8e..548c3be1 100644 --- a/src/mocks/focus/focus.ts +++ b/src/mocks/focus/focus.ts @@ -1,14 +1,18 @@ import themeGrass from "../../assets/focus/themes/theme-grass-343x304.png"; import themeGrass80 from "../../assets/focus/themes/theme-grass-80x80.png"; import themeGrass684 from "../../assets/focus/themes/theme-grass-375x684.png"; +import themeGrass812 from "../../assets/focus/themes/theme-grass-375x812.png"; import themeLibrary from "../../assets/focus/themes/theme-library-343x304.png"; import themeLibrary80 from "../../assets/focus/themes/theme-library-80x80.png"; import themeLibrary684 from "../../assets/focus/themes/theme-library-375x684.png"; +import themeLibrary812 from "../../assets/focus/themes/theme-library-375x812.png"; import themeSpace from "../../assets/focus/themes/theme-space-343x304.png"; import themeSpace80 from "../../assets/focus/themes/theme-space-80x80.png"; import themeSpace684 from "../../assets/focus/themes/theme-space-375x684.png"; +import themeSpace812 from "../../assets/focus/themes/theme-space-375x812.png"; import mockBookCover from "../../assets/search/mock_bookcover.svg"; import type { + ActiveFocusSession, FocusBookItem, FocusMainSummaryResponse, FocusTheme, @@ -45,6 +49,23 @@ export const mockFocusThemeSelectOptions: FocusThemeSelectOption[] = [ { themeId: 3, name: "서재", thumbnailUrl: themeLibrary80, backgroundUrl: themeLibrary684 }, ]; +// 세션 화면은 테마 선택 화면(375x684)과 달리 전체 화면용 375x812 에셋을 사용한다. +export const mockFocusSessionBackgroundByThemeId: Record = { + 1: themeGrass812, + 2: themeSpace812, + 3: themeLibrary812, +}; + +// 도서 선택부터 세션까지 libraryId 전달이 연결되면 실제 세션 데이터로 교체한다. +export const mockActiveFocusSession: ActiveFocusSession = { + focusId: 9001, + libraryId: 1, + bookId: 101, + bookTitle: "첫사랑의 침공", + author: "권혁일", + coverUrl: mockBookCover, +}; + const beforeBooks: FocusBookItem[] = [ { libraryId: 5, diff --git a/src/pages/focus/FocusMainPage.tsx b/src/pages/focus/FocusMainPage.tsx index ba0f8439..6fb2dda7 100644 --- a/src/pages/focus/FocusMainPage.tsx +++ b/src/pages/focus/FocusMainPage.tsx @@ -1,15 +1,17 @@ -import { useCallback } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useCallback, useEffect, useState } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import searchIcon from "../../assets/icons/search.svg"; import Icon from "../../components/action/Button/Icon"; import { Focus as FocusBookRow } from "../../components/content/card/Book/List/Focus"; import SectionHeader from "../../components/content/InformationText/SectionHeader"; +import Toast from "../../components/feedback/toast"; import Dim from "../../components/layout/Dim"; import MaskGradient from "../../components/layout/MaskGradient"; import TabBar from "../../components/navigation/tabs/TabBar"; import { mockFocusMainSummaryResponse } from "../../mocks/focus/focus"; import type { FocusBookStatus } from "../../types/focus/focus"; +import { formatDurationHms } from "./utils/formatDurationHms"; const STATUS_TABS: { value: FocusBookStatus; @@ -25,16 +27,18 @@ function isFocusStatus(value: string): value is FocusBookStatus { return value === "BEFORE" || value === "READING" || value === "FINISHED"; } -function formatHms(totalSeconds: number) { - const pad = (n: number) => String(n).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)}`; -} - export default function FocusMainPage() { const navigate = useNavigate(); + const location = useLocation(); + const navigationState = location.state as { + showFocusEndToast?: boolean; + } | null; + const [focusEndToastOpen, setFocusEndToastOpen] = useState( + navigationState?.showFocusEndToast === true, + ); + const handleFocusEndToastClose = useCallback(() => { + setFocusEndToastOpen(false); + }, []); const [searchParams, setSearchParams] = useSearchParams(); const statusParam = searchParams.get("status"); @@ -62,6 +66,22 @@ export default function FocusMainPage() { const activeTab = STATUS_TABS.find((tab) => tab.value === activeStatus)!; const visibleBooks = books.filter((book) => book.status === activeStatus); + // 새로고침이나 뒤로가기로 완료 Toast가 다시 뜨지 않도록 일회성 navigation state를 지운다. + // 로컬 state의 open 값은 유지되므로 현재 진입에서는 Toast의 4초 노출이 정상 진행된다. + useEffect(() => { + if (!navigationState?.showFocusEndToast) return; + + navigate(`${location.pathname}${location.search}`, { + replace: true, + state: null, + }); + }, [ + location.pathname, + location.search, + navigate, + navigationState?.showFocusEndToast, + ]); + return (
{/* Figma node 2621:27492 (focus : 메인/이미지) 기준 정확한 스펙 반영, 2026-08-10 */} @@ -88,7 +108,7 @@ export default function FocusMainPage() {

오늘 독서한 시간

- {formatHms(todayTotalFocusSeconds)} + {formatDurationHms(todayTotalFocusSeconds)}

@@ -133,13 +153,21 @@ export default function FocusMainPage() { imageUrl={book.coverUrl} title={book.title} author={book.author} - timeText={formatHms(book.todayFocusSeconds)} + timeText={formatDurationHms(book.todayFocusSeconds)} onClick={() => navigate("/focus/theme")} /> ))}
)} + +
+ +
); } diff --git a/src/pages/focus/FocusSessionPage.tsx b/src/pages/focus/FocusSessionPage.tsx index 105629be..44facaa6 100644 --- a/src/pages/focus/FocusSessionPage.tsx +++ b/src/pages/focus/FocusSessionPage.tsx @@ -1,8 +1,159 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import pencilIcon from "../../assets/icons/pencil.svg"; +import FAB from "../../components/action/Button/FAB"; +import Solid from "../../components/action/Button/Solid"; +import BookCover from "../../components/atomic/BookCover"; +import MaskGradient from "../../components/layout/MaskGradient"; +import { + mockActiveFocusSession, + mockFocusSessionBackgroundByThemeId, +} from "../../mocks/focus/focus"; +import FocusEndSheet from "./component/FocusEndSheet"; +import { formatDurationHms } from "./utils/formatDurationHms"; +import { readStoredFocusThemeId } from "./utils/focusThemeStorage"; +import { + clearFocusSessionTimer, + getFocusElapsedSeconds, + pauseFocusSessionTimer, + readOrCreateFocusSessionTimer, + resumeFocusSessionTimer, + type FocusSessionTimerState, +} from "./utils/focusSessionTimer"; + export default function FocusSessionPage() { + const navigate = useNavigate(); + const session = mockActiveFocusSession; + + // 테마 선택 화면과 같은 키를 읽어 마지막으로 시작한 테마 배경을 이어서 보여준다. + const [themeId] = useState(readStoredFocusThemeId); + const [imageError, setImageError] = useState(false); + + const [timerState, setTimerState] = useState( + readOrCreateFocusSessionTimer, + ); + const [elapsedSeconds, setElapsedSeconds] = useState(() => + getFocusElapsedSeconds(timerState), + ); + const [sheetOpen, setSheetOpen] = useState(false); + const [pageInput, setPageInput] = useState(""); + const [isFinished, setIsFinished] = useState(false); + + // setInterval 횟수가 아니라 저장한 시작 시각과 현재 시각의 차이로 계산한다. + // 따라서 기록 작성 화면으로 이동해 컴포넌트가 unmount되어도 포커스 시간은 계속 흐른다. + // 종료 시트가 열린 동안만 pausedAtMs를 기록한다. + useEffect(() => { + if (timerState.pausedAtMs !== null) return; + + const timerId = window.setInterval(() => { + setElapsedSeconds(getFocusElapsedSeconds(timerState)); + }, 1000); + return () => window.clearInterval(timerId); + }, [timerState]); + + const backgroundUrl = + themeId !== null ? mockFocusSessionBackgroundByThemeId[themeId] : undefined; + + const handleCloseSheet = () => { + const resumedTimer = resumeFocusSessionTimer(timerState); + setTimerState(resumedTimer); + setElapsedSeconds(getFocusElapsedSeconds(resumedTimer)); + setSheetOpen(false); + }; + + const handleOpenSheet = () => { + const pausedTimer = pauseFocusSessionTimer(timerState); + setTimerState(pausedTimer); + setElapsedSeconds(getFocusElapsedSeconds(pausedTimer)); + setSheetOpen(true); + }; + + const handleSubmitEnd = () => { + // TODO: 종료 API 연동 시 focusId, pageInput, isFinished를 mutation으로 전달한다. + clearFocusSessionTimer(); + navigate("/focus", { state: { showFocusEndToast: true } }); + }; + return ( -
- {/* 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() { ))}
} onClick={() => navigate(`/report/${id}/create`, { state: { bookTitle, bookId } }) diff --git a/src/types/focus/focus.ts b/src/types/focus/focus.ts index e1b39d24..62249278 100644 --- a/src/types/focus/focus.ts +++ b/src/types/focus/focus.ts @@ -26,3 +26,14 @@ export type FocusMainSummary = { }; export type FocusMainSummaryResponse = BaseApiResponse; + +// 진행 중인 포커스 세션. libraryId→테마 선택→세션 화면 간 전달 로직이 아직 없어 +// 이 화면은 당분간 mock으로 채운다. +export type ActiveFocusSession = { + focusId: number; + libraryId: number; + bookId: number; + bookTitle: string; + author: string; + coverUrl: string; +};