Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/assets/icons/pencil.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/images/book-cover-placeholder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 31 additions & 11 deletions src/components/action/Button/FAB.tsx
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 props를 제거하셨는데, 이 부분에서 생기는 오류는 없었나요 ??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

답변이 늦었습니다 죄송합니다...!!
기존 props를 삭제한 게 아니라 직접 하나씩 선언하던 방식을
React의 표준 button props 타입을 재사용하는 방식으로 확장한 것입니다!
(React에서 정의해둔 보편적인 HTML button 속성 타입을 FAB Props에 합쳐서 사용하는 방식)

오히려 더 확장했다고 봐주시면 됩니다! 그래서 오류 사항은 발견되지 않았어요~

@p1001q p1001q Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

불친절 했던 것 같아 설명을 추가해봤습니다! 보시고 건의사항 있으시면 말씀해주세요!

기존에는 FAB에서 사용하는 속성을 아래처럼 하나씩 직접 선언하고 있었습니다

type Props = {
  icon: ReactNode;
  onClick?: () => void;
  className?: string;
};

이번에 FAB의 루트 요소를 div에서 실제 button으로 변경하면서
React에서 기본으로(TypeScript 타입 정의에서) 제공하는 <button> 속성 타입인
ButtonHTMLAttributes<HTMLButtonElement>를 Props에 합성하도록 수정한 겁니당

type Props = {
  icon: ReactNode;
  size?: FabSize;
  variant?: FabVariant;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children">;

따라서 기존의 onClick, className도 계속 사용할 수 있어요!
추가로 aria-label, disabled, type, onKeyDown
실제 HTML button이 지원하는 속성도 별도 선언 없이 전달할 수 있습니다. 확장 된거에요!

Omit<..., "children">은 기본 button 속성 중 children만 제외한다는 의미입니다.
FAB 내부 콘텐츠는 children이 아니라 필수 icon prop으로 받도록 사용 방식을 통일하기 위해 제외했습니다.

  • ButtonHTMLAttributes는 별도 라이브러리가 아니라 React에서 제공하는 TypeScript 타입
  • Omit은 TypeScript 기본 유틸리티 타입

런타임 동작이나 번들 크기에는 영향을 주지 않고 타입 검사에만 사용됩니다
기존 기록 화면의 onClick, className 사용처도 그대로 유지되고
전체 TypeScript 빌드와 관련 ESLint 검사로 오류 없는 것 확인했어요!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

허걱 이런 방법이 있는지 몰랐네요! 새로운 거 알려주셔서 감사합니다🥹🥹 레전드 친절하셔요...

size?: FabSize;
variant?: FabVariant;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children">;

const sizeClassMap: Record<FabSize, string> = {
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<FabVariant, string> = {
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 (
<div
onClick={onClick}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
<button
type={type}
className={[
"inline-flex items-center rounded-[32px] select-none p-2 bg-gray-90 ", //select-none : svg 가 파란색으로 선택되지 않도록 방지하는 용도
"inline-flex shrink-0 select-none items-center justify-center rounded-full",
"disabled:cursor-not-allowed disabled:opacity-50",
sizeClassMap[size],
variantClassMap[variant],
className,
].join(" ")}
{...props}
>
<span className="flex h-6 w-6 justify-center items-center">{icon}</span>
</div>
</button>
);
}
2 changes: 1 addition & 1 deletion src/components/action/Button/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const base = [
].join(" ");

const sizeClassMap: Record<IconSize, string> = {
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",
};
Expand Down
5 changes: 2 additions & 3 deletions src/components/action/Button/Solid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ type ButtonProps = {
className?: string;
} & ButtonHTMLAttributes<HTMLButtonElement>;

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<Variant, string> = {
primary: "bg-mint-60 text-gray-10",
Expand All @@ -24,7 +23,7 @@ const variantClassMap: Record<Variant, string> = {
};

const sizeClassMap: Record<Size, string> = {
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",
};

Expand Down
2 changes: 1 addition & 1 deletion src/components/atomic/BookCover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
Expand Down
7 changes: 4 additions & 3 deletions src/components/presentation/modal/bottomsheet/Origin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ");

Expand Down Expand Up @@ -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,
Expand Down
57 changes: 32 additions & 25 deletions src/components/section/checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<div className="flex items-center py-1 h-[26px] gap-2">
<CheckboxLib.Root
defaultChecked={checked}
onCheckedChange={(value) => setChecked(value === true)}
className="w-[18px] h-[18px] border border-gray-90 rounded-[2px]
data-[state=checked]:bg-gray-90">
<CheckboxLib.Indicator className="flex w-full items-center justify-center">
<CheckIcon className="text-gray-25"/>
</CheckboxLib.Indicator>
</CheckboxLib.Root>
<label
className="flex-1 label-14-sb text-gray-90 truncate">
{text}
</label>
</div>

);
}
return (
<div className="flex h-6.5 items-center gap-2 py-1">
<CheckboxLib.Root
checked={resolvedChecked}
onCheckedChange={(value) => handleCheckedChange(value === true)}
className="h-4.5 w-4.5 rounded-xs border border-gray-90 data-[state=checked]:bg-gray-90"
>
<CheckboxLib.Indicator className="flex w-full items-center justify-center">
<CheckIcon className="text-gray-25" />
</CheckboxLib.Indicator>
</CheckboxLib.Root>
<label className="text-label-14-sb flex-1 truncate text-gray-90">{text}</label>
</div>
);
}
21 changes: 21 additions & 0 deletions src/mocks/focus/focus.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -45,6 +49,23 @@ export const mockFocusThemeSelectOptions: FocusThemeSelectOption[] = [
{ themeId: 3, name: "서재", thumbnailUrl: themeLibrary80, backgroundUrl: themeLibrary684 },
];

// 세션 화면은 테마 선택 화면(375x684)과 달리 전체 화면용 375x812 에셋을 사용한다.
export const mockFocusSessionBackgroundByThemeId: Record<number, string> = {
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,
Expand Down
52 changes: 40 additions & 12 deletions src/pages/focus/FocusMainPage.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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 (
<div className="flex flex-col pb-8">
{/* Figma node 2621:27492 (focus : 메인/이미지) 기준 정확한 스펙 반영, 2026-08-10 */}
Expand All @@ -88,7 +108,7 @@ export default function FocusMainPage() {
<div className="relative flex h-full flex-col items-center justify-center gap-2">
<p className="text-body-16-b text-gray-90">오늘 독서한 시간</p>
<p className="text-title-40-b text-gray-90 tabular-nums">
{formatHms(todayTotalFocusSeconds)}
{formatDurationHms(todayTotalFocusSeconds)}
</p>
</div>
</section>
Expand Down Expand Up @@ -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")}
/>
))}
</div>
)}
</section>

<div className="fixed inset-x-0 bottom-[calc(16px+env(safe-area-inset-bottom))] z-50 mx-auto flex w-full max-w-93.75 justify-center px-4">
<Toast
text="포커스를 종료했어요."
isOpen={focusEndToastOpen}
onClose={handleFocusEndToastClose}
/>
</div>
</div>
);
}
Loading