diff --git a/app/globals.css b/app/globals.css index 907c90f..0ec1946 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,36 +2,74 @@ @tailwind components; @tailwind utilities; +/* ============================================================ + Pretendard 폰트 (한국어 최적화) + CDN 방식 - next/font 대신 사용 가능 + 또는 layout.tsx에서 next/font/local로 로드해도 됩니다. +============================================================ */ +@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css'); + +/* ============================================================ + CSS 변수 (라이트 모드) +============================================================ */ :root { - --background: #ffffff; - --foreground: #171717; + /* 페이지 배경 - 크림톤 유지 */ + --color-page-bg: #faf9f6; + + /* 스크롤바 */ + --scrollbar-thumb: #e4e4e7; +} + +/* ============================================================ + 다크모드 CSS 변수 +============================================================ */ +.dark { + --color-page-bg: #0f0f10; + --scrollbar-thumb: #3f3f46; +} + +/* ============================================================ + Base Styles +============================================================ */ +*, +*::before, +*::after { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html { + /* 부드러운 스크롤 */ + scroll-behavior: smooth; + /* 다크모드 색상 테마 */ + color-scheme: light; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +html.dark { + color-scheme: dark; } body { - color: var(--foreground); - background: var(--background); - font-family: Arial, Helvetica, sans-serif; - overflow-y: scroll; - -ms-overflow-style: none; + font-family: + 'Pretendard Variable', + -apple-system, + BlinkMacSystemFont, + system-ui, + sans-serif; + background-color: var(--color-page-bg); + color: #18181b; /* label.DEFAULT */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; + overflow-y: scroll; + /* 단어 단위 줄바꿈 (한국어) */ + word-break: keep-all; + overflow-wrap: break-word; } -body::-webkit-scrollbar { - display: none; -} - -* { - box-sizing: border-box; - padding: 0; - margin: 0; +.dark body { + color: #fafafa; /* label.invert */ } a { @@ -39,12 +77,65 @@ a { text-decoration: none; } -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; - } -} - li { list-style-type: none; } + +/* ============================================================ + 스크롤바 커스텀 (웹킷) +============================================================ */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 9999px; +} + +::-webkit-scrollbar-thumb:hover { + background: #a1a1aa; +} + +/* Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) transparent; +} + +/* ============================================================ + 선택 영역 색상 +============================================================ */ +::selection { + background-color: #bfdbfe; /* tint-muted */ + color: #1e3a5f; +} + +/* ============================================================ + 포커스 스타일 (키보드 접근성) +============================================================ */ +:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; + border-radius: 4px; +} + +/* ============================================================ + 공통 유틸리티 +============================================================ */ + +/* 바텀시트/모달 열릴 때 body 스크롤 막기 */ +body.overflow-hidden { + overflow: hidden; +} + +/* 이미지 드래그 방지 */ +img { + -webkit-user-drag: none; + user-select: none; +} diff --git a/app/layout.tsx b/app/layout.tsx index 4a3c8c8..9bee198 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,26 +1,21 @@ -import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from 'next/font/google'; +import type { Metadata, Viewport } from 'next'; import './globals.css'; import { Toaster } from 'sonner'; import Header from '@/components/Header'; import Navbar from '@/components/Navbar'; -const geistSans = Geist({ - variable: '--font-geist-sans', - subsets: ['latin'], -}); - -const geistMono = Geist_Mono({ - variable: '--font-geist-mono', - subsets: ['latin'], -}); - export const metadata: Metadata = { title: 'Readiary | 당신의 독서 일기', description: '하루하루의 독서를 기록하고, 친구들과 공유하세요.', manifest: '/manifest.json', }; +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + themeColor: '#FAF9F6', +}; + export default function RootLayout({ children, }: Readonly<{ @@ -31,17 +26,13 @@ export default function RootLayout({ - - - - - + + + - +
@@ -54,7 +45,7 @@ export default function RootLayout({ closeButton toastOptions={{ className: - 'text-sm font-sans text-label dark:text-white bg-background dark:bg-darkbg rounded-md shadow-md border border-gray-200 dark:border-gray-700 px-4 py-3', + 'text-sm font-sans text-label dark:text-label-invert bg-surface dark:bg-dark-surface rounded-md shadow-card-md border border-border dark:border-dark-border px-4 py-3', }} /> diff --git a/app/onboarding/_components/OnboardingForm.tsx b/app/onboarding/_components/OnboardingForm.tsx index ce00894..74ff727 100644 --- a/app/onboarding/_components/OnboardingForm.tsx +++ b/app/onboarding/_components/OnboardingForm.tsx @@ -16,15 +16,13 @@ export default function OnboardingForm() { const [nickname, setNickname] = useState(''); const [bio, setBio] = useState(''); const [loading, setLoading] = useState(false); - const router = useRouter(); const handleSubmit = async () => { setLoading(true); if (!/^[a-zA-Z0-9_]+$/.test(nickname)) { - const errorMessage = '닉네임은 영어 알파벳과 숫자, 언더스코어(_)만 사용할 수 있습니다.'; - toast.error(errorMessage); + toast.error('닉네임은 영어 알파벳과 숫자, 언더스코어(_)만 사용할 수 있습니다.'); setLoading(false); return; } @@ -52,20 +50,16 @@ export default function OnboardingForm() { tag = generateRandomTag(); tries++; } else if (res.status === 409) { - const errorMessage = result.error || '이미 프로필이 존재합니다.'; - toast.error(errorMessage); + toast.error(result.error || '이미 프로필이 존재합니다.'); router.push('/protected/dashboard'); return; } else { - const errorMessage = result.error || '프로필 등록 중 오류가 발생했습니다.'; - toast.error(errorMessage); + toast.error(result.error || '프로필 등록 중 오류가 발생했습니다.'); setLoading(false); return; } } - - const errorMessage = '중복 태그가 너무 많습니다. 닉네임을 바꿔보세요.'; - toast.error(errorMessage); + toast.error('중복 태그가 너무 많습니다. 닉네임을 바꿔보세요.'); } catch (error) { toast.error('예기치 않은 오류가 발생했습니다. 나중에 다시 시도해주세요.'); console.error(error); @@ -77,7 +71,7 @@ export default function OnboardingForm() { return (
-

+

프로필 설정

@@ -87,7 +81,7 @@ export default function OnboardingForm() { placeholder="이름" value={name} onChange={(e) => setName(e.target.value)} - className="w-full border p-2 rounded bg-white dark:bg-gray-800 dark:text-white" + className="w-full border p-2 rounded bg-surface dark:bg-dark-surface dark:text-label-invert" /> 닉네임 setNickname(e.target.value)} - className="w-full border p-2 rounded bg-white dark:bg-gray-800 dark:text-white" + className="w-full border p-2 rounded bg-surface dark:bg-dark-surface dark:text-label-invert" /> -

+

닉네임은 영어 알파벳과 숫자, 언더스코어(_)만 사용할 수 있습니다.

자기소개 @@ -106,9 +100,8 @@ export default function OnboardingForm() { placeholder="자기소개 (선택)" value={bio} onChange={(e) => setBio(e.target.value)} - className="w-full border p-2 rounded bg-white dark:bg-gray-800 dark:text-white resize-none" + className="w-full border p-2 rounded bg-surface dark:bg-dark-surface dark:text-label-invert resize-none" /> - diff --git a/app/protected/dashboard/_components/InProgressBooksSection.tsx b/app/protected/dashboard/_components/InProgressBooksSection.tsx index fd0467f..3ea15a9 100644 --- a/app/protected/dashboard/_components/InProgressBooksSection.tsx +++ b/app/protected/dashboard/_components/InProgressBooksSection.tsx @@ -1,7 +1,6 @@ 'use client'; import Link from 'next/link'; - import { MyBook } from '@/types/book'; import Card from '@/components/ui/Card'; @@ -22,20 +21,22 @@ export function InProgressBooksSection({ myBooks }: Props) { return (
-

📚 진행 중인 책

+

📚 진행 중인 책

{books.map((book, idx) => ( -

{book.title}

+

+ {book.title} +

{book.author && ( -

{book.author}

+

{book.author}

)} -

+

📈 진행률: {book.progress}%

{book.started_at && ( -

+

등록일: {new Date(book.started_at).toLocaleDateString()}

)} diff --git a/app/protected/dashboard/_components/SocialFeedItem.tsx b/app/protected/dashboard/_components/SocialFeedItem.tsx index d375db9..a607aec 100644 --- a/app/protected/dashboard/_components/SocialFeedItem.tsx +++ b/app/protected/dashboard/_components/SocialFeedItem.tsx @@ -17,7 +17,6 @@ export default function FeedItem({ entry, profile, initialLiked }: SocialFeedEnt const router = useRouter(); const supabase = createSupabaseClient(); - // 초기 좋아요 상태 (entry.initialLiked 등 이미 prop으로 넘어온다고 가정하거나, 필요시 fetch) const [isLiked, setIsLiked] = useState(initialLiked); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isLikeLoading, setIsLikeLoading] = useState(false); @@ -41,7 +40,6 @@ export default function FeedItem({ entry, profile, initialLiked }: SocialFeedEnt return () => document.removeEventListener('mousedown', handleClickOutside); }, [isMenuOpen]); - // 실제 좋아요 처리 로직 const handleLike = async (e: React.MouseEvent) => { e.preventDefault(); if (isLikeLoading) return; @@ -54,21 +52,17 @@ export default function FeedItem({ entry, profile, initialLiked }: SocialFeedEnt return; } - // 1. Optimistic Update: UI 먼저 변경 const previousState = isLiked; setIsLiked(!previousState); setIsLikeLoading(true); try { if (!previousState) { - // 좋아요 추가 - const { error } = await supabase.from('likes').insert({ - entry_id: entry.id, - user_id: user.id, - }); + const { error } = await supabase + .from('likes') + .insert({ entry_id: entry.id, user_id: user.id }); if (error) throw error; } else { - // 좋아요 취소 const { error } = await supabase .from('likes') .delete() @@ -77,7 +71,7 @@ export default function FeedItem({ entry, profile, initialLiked }: SocialFeedEnt } } catch (error) { console.error('Like error:', error); - setIsLiked(previousState); // 실패 시 롤백 + setIsLiked(previousState); } finally { setIsLikeLoading(false); } @@ -86,7 +80,7 @@ export default function FeedItem({ entry, profile, initialLiked }: SocialFeedEnt return ( {profile.name} - 님이 + 님이
{isMenuOpen && ( -
+
)}
-
-
- - {entry.book.title} - - - - {entry.to_page ? `${entry.to_page}p까지` : ''} - - 읽었어요 -
- -
- - {formatDistance(targetDate, now, { addSuffix: true, locale: ko })} - - - -
+ + + + {entry.book.title} + + + + +

+ {entry.summary} +

+ + +
+ + {formatDistance(targetDate, now, { addSuffix: true, locale: ko })} + +
diff --git a/app/protected/dashboard/_components/TodaySummarySection.tsx b/app/protected/dashboard/_components/TodaySummarySection.tsx index 224b1fd..a6b2574 100644 --- a/app/protected/dashboard/_components/TodaySummarySection.tsx +++ b/app/protected/dashboard/_components/TodaySummarySection.tsx @@ -12,18 +12,20 @@ export function TodaySummarySection({ entry }: Props) { {entry ? (
-

📖 오늘 읽은 책

-

+

📖 오늘 읽은 책

+

{entry.book.title}

-

+

"{entry.summary?.slice(0, 100)}..."

) : (
-

🕐 오늘은 아직 기록이 없어요

-

+

+ 🕐 오늘은 아직 기록이 없어요 +

+

하루 한 줄 기록, 지금 써보는 건 어때요?

diff --git a/app/protected/dashboard/_components/WeeklyStreakSection.tsx b/app/protected/dashboard/_components/WeeklyStreakSection.tsx index 1ea0e8a..ad8d514 100644 --- a/app/protected/dashboard/_components/WeeklyStreakSection.tsx +++ b/app/protected/dashboard/_components/WeeklyStreakSection.tsx @@ -16,19 +16,18 @@ export function WeeklyStreakSection({ streak, weekActivity }: Props) { useEffect(() => { setMounted(true); - setToday(new Date()); // 브라우저에 접속한 바로 그 시점의 시간을 세팅 + setToday(new Date()); }, []); - // 서버에서 렌더링할 때는 아무것도 보여주지 않거나 스켈레톤을 보여줍니다. if (!mounted) { - return
; // 레이아웃 시프트 방지용 빈 박스 + return
; } const startDate = startOfWeek(today, { weekStartsOn: 0 }); return ( -

+

📅 이번 주의 리듬

@@ -41,10 +40,10 @@ export function WeeklyStreakSection({ streak, weekActivity }: Props) { const baseStyle = 'w-8 h-8 rounded-md flex items-center justify-center text-sm font-semibold border'; const stateClass = didWrite - ? 'bg-green-500 text-white' + ? 'bg-success text-white border-success' : isPast && !isToday - ? 'bg-red-200 text-red-700 dark:bg-red-900 dark:text-red-300' - : 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'; + ? 'bg-danger-subtle text-danger border-danger-muted dark:bg-danger/20 dark:text-danger dark:border-danger/30' + : 'bg-surface-raised text-label-muted border-border dark:bg-dark-raised dark:text-label-muted dark:border-dark-border'; return (
@@ -54,7 +53,7 @@ export function WeeklyStreakSection({ streak, weekActivity }: Props) { })}
-

+

{streak > 0 ? ( <>🔥 현재 {streak}일 연속 기록 중! ) : ( diff --git a/app/protected/profile/edit/page.tsx b/app/protected/profile/edit/page.tsx index fa149cf..3a6e4e5 100644 --- a/app/protected/profile/edit/page.tsx +++ b/app/protected/profile/edit/page.tsx @@ -9,6 +9,10 @@ import { useProfileUpdate } from '@/hooks/useProfileUpdate'; import { Profile } from '@/types/profile'; import BackButton from '@/components/ui/BackButton'; import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import { Textarea } from '@/components/ui/Textarea'; +import FormGroup from '@/components/ui/FormGroup'; +import FormLabel from '@/components/ui/FormLabel'; export default function EditProfilePage() { const router = useRouter(); @@ -18,7 +22,6 @@ export default function EditProfilePage() { const [name, setName] = useState(''); const [bio, setBio] = useState(''); - // 1. 프로필 초기 데이터 불러오기 useEffect(() => { async function loadData() { const { @@ -37,7 +40,6 @@ export default function EditProfilePage() { loadData(); }, [supabase, router]); - // 커스텀 훅 연결 (imagePath는 훅에서 관리하는 최신 상대 경로) const { uploading, updating, imagePath, uploadAvatar, updateProfile } = useProfileUpdate(profile); if (!profile) @@ -51,26 +53,25 @@ export default function EditProfilePage() {

-

+

프로필 수정

-
+
{/* 이미지 수정 섹션 */}
-
+
{imagePath ? ( Avatar ) : ( -
+
{nickname?.at(0) || 'U'}
)} @@ -80,7 +81,7 @@ export default function EditProfilePage() {
)}
-
-

프로필 사진

-

+

프로필 사진

+

나를 나타내는 멋진 사진을 올려보세요.

@@ -101,42 +102,37 @@ export default function EditProfilePage() { {/* 텍스트 입력 섹션 */}
-
- - + 이름 + setName(e.target.value)} - className="w-full px-5 py-4 rounded-[1.5rem] bg-zinc-50 dark:bg-zinc-900 border-2 border-zinc-100 dark:border-zinc-800 focus:border-zinc-900 dark:focus:border-zinc-100 outline-none transition-all font-bold" placeholder="이름 입력" /> -
+ -
- - + 닉네임 + setNickname(e.target.value)} - className="w-full px-5 py-4 rounded-[1.5rem] bg-zinc-50 dark:bg-zinc-900 border-2 border-zinc-100 dark:border-zinc-800 focus:border-zinc-900 dark:focus:border-zinc-100 outline-none transition-all font-bold" placeholder="닉네임 입력" /> -
+ -
- -