diff --git a/apps/web/index.css b/apps/web/index.css deleted file mode 100644 index d9a377d..0000000 --- a/apps/web/index.css +++ /dev/null @@ -1,56 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;1,400;1,600&family=Nunito:wght@400;600;700&family=VT323&display=swap'); - -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - /* New Palette: Cozy Library */ - --paper-cream: 253 251 247; /* #FDFBF7 */ - --aged-paper: 245 230 211; /* #F5E6D3 */ - --ink-navy: 28 42 58; /* #1C2A3A */ - --sepia-brown: 62 39 35; /* #3E2723 */ - --sage-green: 126 156 142; /* #7E9C8E */ - --woodstock-gold: 255 213 79; /* #FFD54F */ - --clay-red: 211 84 0; /* #D35400 */ - - /* Pixel Art Touches */ - --pixel-border: 2px solid rgb(var(--ink-navy)); - } - - body { - @apply bg-[rgb(var(--paper-cream))] text-[rgb(var(--ink-navy))] font-sans antialiased selection:bg-[rgb(var(--woodstock-gold))] selection:text-[rgb(var(--ink-navy))]; - } - - h1, h2, h3, h4, h5, h6 { - @apply font-serif font-semibold; - } -} - -@layer components { - /* Cozy Primitives */ - .card-paper { - @apply bg-white border border-[rgb(var(--aged-paper))] rounded-xl shadow-sm transition-all duration-300; - } - - .btn-cozy { - @apply inline-flex items-center justify-center rounded-full px-4 py-2 font-medium transition-all duration-200 active:scale-95; - @apply bg-[rgb(var(--ink-navy))] text-[rgb(var(--paper-cream))] hover:bg-[rgb(var(--sage-green))]; - } - - .input-underline { - @apply bg-transparent border-b-2 border-[rgb(var(--aged-paper))] px-2 py-1 outline-none transition-colors focus:border-[rgb(var(--sage-green))]; - } - - /* Pixel Art */ - .pixel-border { - border: 2px solid rgb(var(--ink-navy)); - box-shadow: 4px 4px 0px rgb(var(--ink-navy)); - } - - .pixel-btn { - @apply font-pixel text-xs px-4 py-2 bg-[rgb(var(--woodstock-gold))] text-[rgb(var(--ink-navy))] uppercase tracking-widest border-2 border-[rgb(var(--ink-navy))] hover:translate-y-0.5 hover:shadow-none active:translate-y-1 transition-all; - box-shadow: 3px 3px 0px rgb(var(--ink-navy)); - } -} diff --git a/apps/web/index.html b/apps/web/index.html index 1e25e0d..41944d9 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,31 +1,25 @@ - - - - - - - - - - - - - - Sanctuary - Book Reader - - - - - - -
- - + + + + + + + + + + + + Sanctuary + + +
+ + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4d35a57..5956c25 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -20,6 +20,7 @@ import ReaderView from "./components/pages/ReaderView"; import SettingsView from "./components/pages/SettingsView"; import StatsView from "./components/pages/StatsView"; import ClerkAuth from "./components/pages/Auth"; +import ScrapbookLayout from "./components/layout/ScrapbookLayout"; import { ReaderErrorBoundary } from "./components/ui/ReaderErrorBoundary"; const App: React.FC = () => { @@ -53,7 +54,6 @@ const App: React.FC = () => { })); // Library & Stats Hooks - // Guest and Clerk users both persist through the API with scoped identities. const persistent = true; useBookStoreController({ persistent }); @@ -143,87 +143,81 @@ const App: React.FC = () => { const root = document.documentElement; root.classList.toggle("dark", theme === Theme.DARK); root.classList.toggle("reduce-motion", reduceMotion); - - const bgColor = theme === Theme.DARK ? "#0f0e0d" : "#fefcf8"; - document.body.style.backgroundColor = bgColor; - document.body.style.transition = reduceMotion ? "none" : "background-color 0.3s ease"; }, [theme, reduceMotion]); - // Render - Explicit Auth Screen (only when user asks to sign in) + // Render - Explicit Auth Screen (Scrapbook Themed) if (showAuthScreen && !isLoaded) { return ( -
-
-
-
- -
-
-
-

Sanctuary

-

Preparing your reading sanctuary...

+ +
+
+ +
+

+ Gathering supplies... +

-
+ ); } if (showAuthScreen) { - return { - setIsGuest(true); - setShowAuthScreen(false); - }} />; + return ( + + { + setIsGuest(true); + setShowAuthScreen(false); + }} /> + + ); } const isReader = view === View.READER; - // Render - App + if (isReader && selectedBook) { + return ( + { void handleCloseReader(); }} resetKey={selectedBook.id}> + + + ); + } + + // Render - Main App (Scrapbook Layout) return ( -
+ {/* Header */} - {!isReader && ( -
- )} - - {/* Main Content */} -
-
+
+ + {/* Main Content Area */} +
{view === View.LIBRARY && ( - + )} + {/* We keep Stats and Settings as is for now, they will inherit global font/color styles but might need specific layout tweaks later if requested. The prompt focused on Library/Empty State. */} {view === View.SETTINGS && } {view === View.STATS && } - {view === View.READER && selectedBook && ( - { void handleCloseReader(); }} resetKey={selectedBook.id}> - - - )} -
{/* Navigation */} - {!isReader && ( - - )} -
+ + ); }; diff --git a/apps/web/src/components/layout/ScrapbookLayout.tsx b/apps/web/src/components/layout/ScrapbookLayout.tsx new file mode 100644 index 0000000..ff06acb --- /dev/null +++ b/apps/web/src/components/layout/ScrapbookLayout.tsx @@ -0,0 +1,87 @@ +import React, { ReactNode } from "react"; +import { motion } from "framer-motion"; + +interface ScrapbookLayoutProps { + children: ReactNode; + view?: string; +} + +const ScrapbookLayout: React.FC = ({ children, view }) => { + return ( +
+ {/* + ============================================================ + LAYER 0: Base Texture + ============================================================ + */} +
+ + {/* + ============================================================ + LAYER 1: The Big Scraps (Collage Background) + ============================================================ + */} +
+ + {/* Top Left: Celestial Star Map (Abstracted) */} + +
+
+
+
+ + {/* Right Side: Vintage Sheet Music (CSS Pattern) */} + + {/* Music Notes Scatter */} +
𝄞
+
𝅘𝅥𝅮
+
𝅘𝅥𝅯
+
+ + {/* Bottom Left: Night Sky / Navy Paper */} + + {/* Stars */} +
+
+
+
+ + + {/* Center/Random: Coffee Stain */} +
+
+ + {/* + ============================================================ + LAYER 2: Content Container + ============================================================ + */} +
+ {children} +
+
+ ); +}; + +export default ScrapbookLayout; diff --git a/apps/web/src/components/pages/Auth.tsx b/apps/web/src/components/pages/Auth.tsx index 752c485..c6158bf 100644 --- a/apps/web/src/components/pages/Auth.tsx +++ b/apps/web/src/components/pages/Auth.tsx @@ -1,39 +1,93 @@ -import React from "react"; -import { User, BookOpen, Sparkles } from "lucide-react"; +import React, { useRef } from "react"; +import { User, BookOpen, PenTool } from "lucide-react"; import { SignIn } from "@/hooks/useAuth"; -import { motion } from "framer-motion"; +import { motion, useMotionValue, useTransform } from "framer-motion"; interface AuthProps { onContinueAsGuest?: () => void; } const Auth: React.FC = ({ onContinueAsGuest }) => { + // Parallax Effect for Illustration + const ref = useRef(null); + const x = useMotionValue(0); + const y = useMotionValue(0); + + const rotateX = useTransform(y, [-300, 300], [5, -5]); + const rotateY = useTransform(x, [-300, 300], [-5, 5]); + + const handleMouseMove = (event: React.MouseEvent) => { + const rect = ref.current?.getBoundingClientRect(); + if (rect) { + x.set(event.clientX - rect.left - rect.width / 2); + y.set(event.clientY - rect.top - rect.height / 2); + } + }; + return ( -
+
{/* Left: Cozy Illustration (Desktop Only) */} -
- {/* Background Texture */} -
+
{ x.set(0); y.set(0); }} + className="hidden lg:flex w-1/2 relative overflow-hidden bg-[rgb(var(--aged-paper))] border-r-2 border-[rgb(var(--ink-navy))] items-center justify-center perspective-1000" + > + {/* Animated Dust Motes Background */} +
+ {[...Array(20)].map((_, i) => ( + + ))} +
-
-
+ + Cozy Reading Bunnies + {/* Backing Photo Effect */} +
+ + {/* Tape */} +
+ {/* Pixel decoration */} -
-
💤
-
+
+
💤
+ -

+

"Just one more chapter..."

-

+

Join the bunnies in your personal reading sanctuary.

-
+
{/* Right: Login Form (Guest Book Style) */} @@ -42,56 +96,71 @@ const Auth: React.FC = ({ onContinueAsGuest }) => {
-
- -
-

Sanctuary

-
-

Please sign the guestbook to enter.

+ + + +

Sanctuary

+
+

Please sign the guestbook to enter.

-
- {/* "Paper" lines background */} -
+ {/* Guest Book Card */} +
+
-
-
- -
+ {/* Signing Pen Animation */} + + + -
-
-
+
+
+
-
- - Or - + +
+
+
+
+
+ + Or + +
-
- - -

- (Your reading progress will be saved on this device) -

+ + +

+ (Your reading progress will be saved on this device) +

+
diff --git a/apps/web/src/components/pages/LibraryGrid.tsx b/apps/web/src/components/pages/LibraryGrid.tsx index 6089bf3..cccd12e 100644 --- a/apps/web/src/components/pages/LibraryGrid.tsx +++ b/apps/web/src/components/pages/LibraryGrid.tsx @@ -1,20 +1,21 @@ import React, { useState, useEffect, useMemo, useRef } from "react"; import type { Book, SortOption, FilterOption, ViewMode } from "@/types"; -import { Grid3X3, List, SortAsc, Filter, Star, Clock, ChevronRight, ChevronDown, Search, BookOpen } from "lucide-react"; +import { Grid3X3, List, SortAsc, Filter, Star, Clock, ChevronRight, ChevronDown, Search, BookOpen, Sparkles, BookUp, Ghost } from "lucide-react"; import BookCard from "../ui/BookCard"; import AddBookButton from "../ui/AddBookButton"; import BunniesPick from "../ui/BunniesPick"; import { useBookStore } from "@/store/useBookStore"; import { useUIStore } from "@/store/useUIStore"; import { useShallow } from "zustand/react/shallow"; +import { motion, AnimatePresence } from "framer-motion"; interface LibraryGridProps { onSelectBook: (book: Book) => void; } const SkeletonCard: React.FC = () => ( -
-
+
+
); @@ -58,6 +59,8 @@ const LibraryGrid: React.FC = ({ const [visibleCount, setVisibleCount] = useState(LIBRARY_PAGE_SIZE); const [showSortMenu, setShowSortMenu] = useState(false); const [showFilterMenu, setShowFilterMenu] = useState(false); + const [selectedBookForDetails, setSelectedBookForDetails] = useState(null); + const sortRef = useRef(null); const filterRef = useRef(null); const sortMenuId = "library-sort-menu"; @@ -108,6 +111,23 @@ const LibraryGrid: React.FC = ({ [displayBooks, visibleCount] ); + const upNextBooks = useMemo(() => { + if (books.length === 0) return []; + const unread = books.filter(b => b.progress === 0 && b.readingList !== 'finished'); + return unread.slice(0, 5); + }, [books]); + + const handleBookClick = (book: Book) => { + setSelectedBookForDetails(book); + }; + + const handleStartReading = () => { + if (selectedBookForDetails) { + onSelectBook(selectedBookForDetails); + setSelectedBookForDetails(null); + } + }; + if (isLoading) { return (
@@ -126,19 +146,67 @@ const LibraryGrid: React.FC = ({ ); } + // Enhanced Empty State (Scrapbook / Desk Cluster) if (books.length === 0) { return ( -
-
- +
+ {/* Desk Cluster */} +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ VOL. I +
+
+ +
+
+
+
+
+
+
-

Your Library Awaits

-

- Add your first book to begin your reading journey -

-
- - EPUB format supported + + {/* Message Frame */} +
+
+
+
+ +
+ 🌿 +
+ +
+

+ The shelves are bare... +

+

+ It looks like a quiet afternoon.
+ Add a story to fill the silence. +

+ +
+ +
+ + Supports EPUB + +
+
); @@ -153,11 +221,11 @@ const LibraryGrid: React.FC = ({ count?: number; icon?: React.ElementType; }) => ( -
- {Icon && } -

{title}

+
+ {Icon && } +

{title}

{count !== undefined && ( - + {count} )} @@ -165,10 +233,14 @@ const LibraryGrid: React.FC = ({ ); const HorizontalScroll = ({ books: scrollBooks }: { books: Book[] }) => ( -
- {scrollBooks.map((book) => ( -
- +
+ {scrollBooks.map((book, i) => ( +
+
))}
@@ -194,7 +266,7 @@ const LibraryGrid: React.FC = ({ id={id} role="menu" aria-orientation="vertical" - className="absolute right-0 top-full mt-1.5 w-40 py-1 rounded-xl bg-light-surface dark:bg-dark-surface shadow-lg border border-black/[0.08] dark:border-white/[0.08] z-20 animate-scaleIn origin-top-right" + className="absolute right-0 top-full mt-2 w-48 py-2 bg-white border-2 border-scrap-navy rounded-lg shadow-scrap-deep z-20 animate-scaleIn origin-top-right font-body" > {options.map((opt) => ( + +
+
+
+ +
+ )} + + {filterBy === "all" && !searchTerm && books.length > 0 && ( - + )} -
-
-

Library

-

- {books.length} {books.length === 1 ? "book" : "books"} -

+
+
+

Library

+
-
-
+ +
+
))}
)} {visibleBooks.length < displayBooks.length && ( -
+
diff --git a/apps/web/src/components/pages/ReaderView.tsx b/apps/web/src/components/pages/ReaderView.tsx index ae6819a..8ad3adc 100644 --- a/apps/web/src/components/pages/ReaderView.tsx +++ b/apps/web/src/components/pages/ReaderView.tsx @@ -6,6 +6,8 @@ import { useReaderShortcuts } from "@/hooks/useReaderShortcuts"; import ReaderContent from "@/components/reader/ReaderContent"; import ReaderOverlay from "@/components/reader/ReaderOverlay"; import { useReaderProgressStore } from "@/store/useReaderProgressStore"; +import { Sparkles, BookmarkPlus, Copy } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; interface ReaderViewProps { book: Book; @@ -51,6 +53,14 @@ const ReaderView: React.FC = ({ const lastMouseMoveRef = useRef(Date.now()); const latestBookRef = useRef(book); + // Context Menu State + const [selection, setSelection] = useState(null); + const [tooltipPosition, setTooltipPosition] = useState<{ x: number, y: number } | null>(null); + const [contextMessage, setContextMessage] = useState(null); + + // Page Turn Animation State + const [isTurningPage, setIsTurningPage] = useState(false); + // Local hydrated book state (for lazy loading content) const [hydratedBook, setHydratedBook] = useState(book); const [isFetchingContent, setIsFetchingContent] = useState(false); @@ -94,12 +104,24 @@ const ReaderView: React.FC = ({ }, [activeBook.id, activeBook.epubBlob, getBookContent]); // Settings - const { screenReaderMode, brightness, grayscale } = useSettingsShallow((state) => ({ + const { screenReaderMode, brightness, grayscale, reduceMotion, fontSize, fontFamily, lineHeight } = useSettingsShallow((state) => ({ screenReaderMode: state.screenReaderMode, brightness: state.brightness, grayscale: state.grayscale, + reduceMotion: state.reduceMotion, + fontSize: state.fontSize, + fontFamily: state.fontFamily, + lineHeight: state.lineHeight })); + // Apply font settings via CSS variables for immediate effect + useEffect(() => { + if (rootRef.current) { + rootRef.current.style.setProperty('--reader-font-size', `${fontSize}px`); + rootRef.current.style.setProperty('--reader-line-height', `${lineHeight}`); + } + }, [fontSize, lineHeight]); + // Reader Engine const { isLoading: engineLoading, @@ -115,6 +137,51 @@ const ReaderView: React.FC = ({ const isLoading = engineLoading || isFetchingContent; + // Selection Handling + useEffect(() => { + const handleSelection = () => { + const sel = window.getSelection(); + if (sel && !sel.isCollapsed && sel.toString().trim().length > 0) { + const range = sel.getRangeAt(0); + const rect = range.getBoundingClientRect(); + setSelection(sel); + setTooltipPosition({ + x: rect.left + rect.width / 2, + y: rect.top - 10 + }); + setContextMessage(null); + } else { + setSelection(null); + setTooltipPosition(null); + } + }; + + document.addEventListener("selectionchange", handleSelection); + return () => document.removeEventListener("selectionchange", handleSelection); + }, []); + + const handleCopy = () => { + if (!selection) return; + navigator.clipboard.writeText(selection.toString()); + setContextMessage("Copied!"); + setTimeout(() => setContextMessage(null), 2000); + }; + + const handleSaveHighlight = async () => { + if (!selection || !currentCfi) return; + try { + await onAddBookmark(activeBook.id, { + cfi: currentCfi, + title: `Highlight: ${selection.toString().slice(0, 20)}...`, + note: selection.toString() + }); + setContextMessage("Saved!"); + setTimeout(() => setContextMessage(null), 2000); + } catch (e) { + setContextMessage("Error"); + } + }; + // Bookmark sync useEffect(() => { setIsBookmarked(activeBook.bookmarks?.some((b) => b.cfi === currentCfi) ?? false); @@ -161,6 +228,23 @@ const ReaderView: React.FC = ({ display(href); }, [display]); + // Wrap page turns to trigger animation + const handleNextPage = useCallback(() => { + if (!reduceMotion && !screenReaderMode) { + setIsTurningPage(true); + setTimeout(() => setIsTurningPage(false), 600); // Animation duration + } + nextPage(); + }, [nextPage, reduceMotion, screenReaderMode]); + + const handlePrevPage = useCallback(() => { + if (!reduceMotion && !screenReaderMode) { + setIsTurningPage(true); + setTimeout(() => setIsTurningPage(false), 600); + } + prevPage(); + }, [prevPage, reduceMotion, screenReaderMode]); + const handlePageChange = useCallback((page: number) => { goToPage(page); }, [goToPage]); @@ -169,12 +253,10 @@ const ReaderView: React.FC = ({ const root = rootRef.current; if (!root) return; previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; - // Ensure reader owns keyboard focus when opened. root.focus({ preventScroll: true }); const focusRoot = () => root.focus({ preventScroll: true }); const handleVisibilityChange = () => { if (document.visibilityState === "visible") { - // Defer one tick so browser restores active element first. window.setTimeout(focusRoot, 0); } }; @@ -189,8 +271,8 @@ const ReaderView: React.FC = ({ // Shortcuts useReaderShortcuts({ - nextPage, - prevPage, + nextPage: handleNextPage, + prevPage: handlePrevPage, onClose, toggleBookmark: handleToggleBookmark, toggleFullscreen: handleToggleFullscreen, @@ -219,7 +301,7 @@ const ReaderView: React.FC = ({ setShowUI(true); return; } - if (showUI && !showSettings && !showControls) { + if (showUI && !showSettings && !showControls && !tooltipPosition) { if (Date.now() - lastMouseMoveRef.current > 3000) { setShowUI(false); } @@ -233,19 +315,85 @@ const ReaderView: React.FC = ({ document.removeEventListener("mousemove", handleMove); document.removeEventListener("touchstart", handleMove); }; - }, [showUI, showSettings, showControls, screenReaderMode]); + }, [showUI, showSettings, showControls, screenReaderMode, tooltipPosition]); return (
{/* Ambient Background Noise/Texture */} -
+ {/* Page Turn Overlay Effect */} + + {isTurningPage && !reduceMotion && !screenReaderMode && ( + + )} + + + {/* Visual Bookmark Ribbon (Progress) */} +
+ +
+ + {Math.round((currentPage / totalPages) * 100)}% + + +
+ + {/* Context Menu Tooltip */} + {tooltipPosition && ( +
+
+ {contextMessage ? ( + + {contextMessage} + + ) : ( + <> + +
+ + + )} +
+ {/* Arrow */} +
+
+ )} + {bookmarkError && (
{bookmarkError} @@ -291,8 +439,8 @@ const ReaderView: React.FC = ({ onToggleControls={() => setShowControls(!showControls)} onToggleFullscreen={handleToggleFullscreen} - onNextPage={nextPage} - onPrevPage={prevPage} + onNextPage={handleNextPage} + onPrevPage={handlePrevPage} onNavigate={handleNavigate} onJumpToTop={() => { display("0"); }} // Jump to start onJumpToBottom={() => { goToPage(totalPages); }} diff --git a/apps/web/src/components/pages/SettingsView.tsx b/apps/web/src/components/pages/SettingsView.tsx index a113734..7713d01 100644 --- a/apps/web/src/components/pages/SettingsView.tsx +++ b/apps/web/src/components/pages/SettingsView.tsx @@ -1,6 +1,6 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { useSettingsShallow } from "@/context/SettingsContext"; -import { Folder, Volume2, Moon, Sun, Type, Monitor, Sparkles, Brain, Save } from "lucide-react"; +import { Folder, Moon, Sun, Type, Brain, Save, Volume2 } from "lucide-react"; import { Theme } from "@/types"; import { useUIStore } from "@/store/useUIStore"; import { motion } from "framer-motion"; @@ -36,8 +36,16 @@ const SettingsView: React.FC = () => { })); // Mock state for new "Organizer" features - const [cozyMode, setCozyMode] = React.useState(true); - const [aiAssistant, setAiAssistant] = React.useState(false); // Default off/hidden + const [cozyMode, setCozyMode] = useState(true); + const [aiAssistant, setAiAssistant] = useState(false); // Default off/hidden + + // Effect to persist/apply settings if they were real (mocked for visual logic) + useEffect(() => { + // Here we would play a sound if cozy mode is enabled and toggled on + if (cozyMode) { + // new Audio('/sounds/page-flip.mp3').play().catch(() => {}); + } + }, [cozyMode]); const tabs = [ { id: "general", label: "General", icon: Folder }, @@ -65,12 +73,12 @@ const SettingsView: React.FC = () => {

Settings Organizer

{/* Folder Tabs */} -
+
{tabs.map(tab => )}
{/* Folder Content Area */} -
+
{activeTab === "general" && ( { {/* Cozy Mode */}

- Atmosphere + Atmosphere

@@ -191,9 +199,9 @@ const SettingsView: React.FC = () => { Intelligence -
-
- +
+
+

AI Assistant

diff --git a/apps/web/src/components/pages/StatsView.tsx b/apps/web/src/components/pages/StatsView.tsx index d8856a9..1e6ea5c 100644 --- a/apps/web/src/components/pages/StatsView.tsx +++ b/apps/web/src/components/pages/StatsView.tsx @@ -1,171 +1,270 @@ -import React from "react"; +import React, { useMemo } from "react"; import { useStatsStore } from "@/store/useStatsStore"; -import { Book, Award, Clock, Flame, Calendar, Sparkles } from "lucide-react"; +import { Book, Award, Clock, Flame, Calendar, Sparkles, Scroll, Feather } from "lucide-react"; import { motion } from "framer-motion"; const StatsView: React.FC = () => { const stats = useStatsStore((state) => state.stats); - // Mock data for new visualization features - const moodData = [ - { genre: "Fiction", count: 12, color: "bg-[rgb(var(--sage-green))]" }, - { genre: "History", count: 5, color: "bg-[rgb(var(--woodstock-gold))]" }, - { genre: "Sci-Fi", count: 8, color: "bg-[rgb(var(--ink-navy))]" }, - ]; + // Dynamic Mood Data from Stats + const moodData = useMemo(() => { + if (!stats.genreDistribution || stats.genreDistribution.length === 0) { + return [ + { genre: "Adventure", count: 0, color: "bg-[rgb(var(--sage-green))]" }, + { genre: "Mystery", count: 0, color: "bg-[rgb(var(--woodstock-gold))]" }, + { genre: "Classics", count: 0, color: "bg-[rgb(var(--ink-navy))]" }, + ]; + } + return stats.genreDistribution.slice(0, 3).map((g, i) => ({ + genre: g.genre, + count: g.count, + color: i === 0 ? "bg-[rgb(var(--sage-green))]" : i === 1 ? "bg-[rgb(var(--woodstock-gold))]" : "bg-[rgb(var(--ink-navy))]" + })); + }, [stats.genreDistribution]); - // Helper to generate a pixel-art contribution grid (mock) + // Contribution Grid based on actual weekly activity (simplified visualization) const renderContributionGrid = () => { + // Map weekly minutes to "intensity" + const intensity = stats.weeklyData.map(d => { + if (d.minutes > 60) return "bg-[rgb(var(--sage-green))]"; + if (d.minutes > 30) return "bg-[rgb(var(--woodstock-gold))]"; + if (d.minutes > 0) return "bg-[rgb(var(--ink-navy))] opacity-50"; + return "bg-[rgb(var(--aged-paper))]"; + }); + + // Fill the rest with empty + const grid = [...intensity, ...Array(28 - intensity.length).fill("bg-[rgb(var(--aged-paper))]")]; + return (
- {[...Array(28)].map((_, i) => ( + {grid.map((color, i) => (
0.7 - ? "bg-[rgb(var(--sage-green))]" - : Math.random() > 0.4 - ? "bg-[rgb(var(--woodstock-gold))]" - : "bg-[rgb(var(--aged-paper))]" - }`} + className={`w-3 h-3 rounded-sm ${color}`} + title={i < 7 ? `${stats.weeklyData[i]?.day}: ${stats.weeklyData[i]?.minutes} min` : ""} /> ))}
); }; + // Derived "Reading Memories" from actual stats + const readingMemories = useMemo(() => { + const memories = []; + if (stats.booksCompletedThisMonth > 0) { + memories.push({ id: 1, date: "This Month", action: "Completed", book: `${stats.booksCompletedThisMonth} books`, mood: "Accomplished" }); + } + if (stats.currentStreak > 3) { + memories.push({ id: 2, date: "Streak", action: "Reached", book: `${stats.currentStreak} Days`, mood: "Determined" }); + } + if (stats.totalReadingTime > 600) { // 10 hours + memories.push({ id: 3, date: "Milestone", action: "Read for", book: "10+ Hours", mood: "Dedicated" }); + } + + if (memories.length === 0) { + memories.push({ id: 0, date: "Today", action: "Started", book: "Your Journey", mood: "Hopeful" }); + } + return memories; + }, [stats]); + return (
{/* Journal Header */} -
-
+
+ -
-

Reading Journal

-

"A room without books is like a body without a soul."

+ +

Reading Journal

+

"A room without books is like a body without a soul."

+ + {/* Coffee Stain Decoration */} +
+ {/* Letter from the Librarian */} + + {/* Tape */} +
+ +
+
+ +
+
+

+ Weekly Correspondence +

+

+ "Dearest Reader,

+ {stats.totalPagesRead > 0 + ? `It seems you've been quite busy! Turning ${stats.totalPagesRead} pages is a wonderful achievement. Your consistency is admirable.` + : "The library is quiet, waiting for you to open a new chapter. There is no time like the present to begin an adventure." + } +

+ Keep the kettle on." +

+

- The Librarian

+
+
+ + {/* Main Stats Grid */} -
+
{/* Current Streak (Ticket Style) */}

Current Streak

- {stats.currentStreak} - days + {stats.currentStreak} + days
- -
-
- - - {/* Total Pages (Badge Style) */} - -
- -
-
-

Pages Turned

-

{stats.totalPagesRead.toLocaleString()}

-
-
+ - {/* Time Reading (Badge Style) */} - -
- -
-
-

Time Spent

-

- {Math.round(stats.totalReadingTime / 60)} hours -

-
+ {/* Ticket Cutouts */} +
+
{/* Level / XP (Pixel Progress) */} -
- LEVEL 3 BOOKWORM - XP: 850/1000 + {/* Background Grid Pattern */} +
+ +
+ + LEVEL {Math.floor(stats.totalPagesRead / 100) + 1} BOOKWORM + +
+ + {stats.totalPagesRead % 100}/100 XP +
-
-
+
+
- {/* Reading Mood & Calendar */} -
- {/* Calendar */} -
-
- -

Consistency Calendar

+
+ {/* Reading Memories (Timeline) */} +
+ {/* Tape holding it */} +
+ +
+ +

Recent Memories

-
- {/* Replace with real calendar later */} - {renderContributionGrid()} +
+ {/* Timeline Line (Hand drawn style) */} +
+ + {readingMemories.map((memory) => ( +
+
+
+
+ {memory.date} +

+ {memory.action} {memory.book} +

+ Mood: {memory.mood} +
+ ))}
-

KEEP THE FIRE BURNING!

- {/* Mood Palette */} -
-
- -

Reading Mood

+ {/* Calendar & Mood */} +
+ {/* Calendar */} +
+
+ +
+
+
+ +
+

Consistency Calendar

+
+
+ {renderContributionGrid()} +
+

"KEEP THE FIRE BURNING!"

-
- {moodData.map((mood) => ( -
-
- {mood.genre} - {mood.count} -
-
-
-
+ + {/* Mood Palette */} +
+
+
+
- ))} +

Reading Mood

+
+
+ {moodData.map((mood) => ( +
+
+ {mood.genre} + {mood.count} +
+
+
+
+
+ ))} +
- {/* Recent Badges Section */} -
-

--- RECENTLY UNLOCKED ---

-
- {[1, 2, 3].map((i) => ( -
- -
- badge_name_0{i} + {/* Badges Section */} +
+
+

+ UNLOCKED BADGES +

+ +
+ {stats.badges.filter(b => b.unlocked).length > 0 ? stats.badges.filter(b => b.unlocked).map((badge) => ( +
+ + {badge.name} + + {/* Tooltip */} +
+ Unlocked!
- ))} -
- ? -
+ )) : ( +
+ ? +
+ )}
diff --git a/apps/web/src/components/reader/ReaderOverlay.tsx b/apps/web/src/components/reader/ReaderOverlay.tsx index b7fc9f7..8b4c6f9 100644 --- a/apps/web/src/components/reader/ReaderOverlay.tsx +++ b/apps/web/src/components/reader/ReaderOverlay.tsx @@ -1,9 +1,7 @@ -import React from "react"; -import type { Book, Bookmark } from "@/types"; -import ReaderHeader from "@/components/reader/ReaderHeader"; -import ReaderFooter from "@/components/reader/ReaderFooter"; -import ReaderSettings from "@/components/reader/ReaderSettings"; -import ReaderControls from "@/components/reader/ReaderControls"; +import React, { useState } from "react"; +import type { Book, Bookmark, TOCItem } from "@/types"; +import { ArrowLeft, BookOpen, Settings, ChevronLeft, ChevronRight, Bookmark as BookmarkIcon, List, X, Trash2, Maximize, Minimize } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; interface ReaderOverlayProps { book: Book; @@ -15,92 +13,257 @@ interface ReaderOverlayProps { totalPages: number; readingTime: number; isBookmarked: boolean; - currentCfi: string; - toc: Array<{ id?: string; href: string; label: string; subitems?: Array<{ id?: string; href: string; label: string }> }>; + currentCfi: string | null; + toc: TOCItem[]; bookmarks: Bookmark[]; isFullscreen: boolean; + onClose: () => void; onToggleBookmark: () => void; onToggleTOC: () => void; onToggleSettings: () => void; onToggleControls: () => void; onToggleFullscreen: () => void; + onNextPage: () => void; onPrevPage: () => void; onNavigate: (href: string) => void; onJumpToTop: () => void; onJumpToBottom: () => void; onPageChange: (page: number) => void; + onRemoveBookmark: (bookId: string, bookmarkId: string) => void; onCloseSettings: () => void; onCloseControls: () => void; } -const ReaderOverlay: React.FC = (props) => { - const tocId = (parentKey: string, id?: string, href?: string, label?: string) => { - const stablePart = id || href || label || "item"; - return `${parentKey}:${encodeURIComponent(stablePart)}`; - }; - - const mappedToc = props.toc.map((item, itemIndex) => { - const itemId = tocId(`toc:${itemIndex}`, item.id, item.href, item.label); - return { - id: itemId, - href: item.href, - label: item.label, - subitems: item.subitems?.map((sub, subIndex) => ({ - id: tocId(`${itemId}:${subIndex}`, sub.id, sub.href, sub.label), - href: sub.href, - label: sub.label - })) - }; - }); +const ReaderOverlay: React.FC = ({ + book, + showUI, + showSettings, + showControls, + isLoading, + currentPage, + totalPages, + readingTime, + isBookmarked, + toc, + bookmarks, + isFullscreen, + onClose, + onToggleBookmark, + onToggleTOC, + onToggleSettings, + onToggleControls, + onToggleFullscreen, + onNextPage, + onPrevPage, + onNavigate, + onRemoveBookmark, + onCloseSettings, + onCloseControls, +}) => { + const [activeTab, setActiveTab] = useState<"toc" | "bookmarks">("toc"); return ( <> - - - + {/* Header Bar */} + +
+ +
+

+ {book.title} +

+
+
- {props.showControls && ( -
- props.onNavigate(href)} - onJumpToTop={props.onJumpToTop} - onJumpToBottom={props.onJumpToBottom} - onRemoveBookmark={(bookmarkId) => props.onRemoveBookmark(props.book.id, bookmarkId)} - /> - +
+ + +
- )} + - {props.showSettings && ( -
- - + {/* Footer Bar */} + +
+ {/* Page Control - Tactile Style */} +
+ + +
+ + {currentPage} / {totalPages || "--"} + + + {readingTime} min left + +
+ + +
- )} +
+ + {/* TOC / Controls Drawer (Leather Journal Style) */} + + {showControls && ( + <> + + + {/* Header */} +
+

Contents

+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Content List */} +
+ {activeTab === "toc" ? ( +
+ {toc.map((item, i) => ( + + ))} +
+ ) : ( +
+ {bookmarks.length === 0 ? ( +
+ +

No dog-ears yet.

+
+ ) : ( + bookmarks.map((bookmark) => ( +
+ + +
+ )) + )} +
+ )} +
+
+ + )} +
); }; diff --git a/apps/web/src/components/ui/AddBookButton.tsx b/apps/web/src/components/ui/AddBookButton.tsx index 61fae9c..4b3500b 100644 --- a/apps/web/src/components/ui/AddBookButton.tsx +++ b/apps/web/src/components/ui/AddBookButton.tsx @@ -64,27 +64,28 @@ const AddBookButton: React.FC = ({ onAddBook, variant = "fab {errorMessage && ( -

{errorMessage}

+

{errorMessage}

)} ); } + // FAB Variant (Stamp / Sticker Style) return ( <> = ({ onAddBook, variant = "fab onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} disabled={isLoading} - className={`group flex items-center justify-center w-14 h-14 rounded-2xl shadow-lg transition-all duration-200 ${ - isDragging - ? "bg-light-accent dark:bg-dark-accent scale-110" - : "bg-gradient-to-br from-light-accent to-amber-600 dark:from-dark-accent dark:to-amber-500 hover:shadow-xl hover:scale-105" - }`} + className={` + group flex items-center justify-center + w-16 h-16 rounded-full + bg-scrap-navy border-4 border-scrap-cream outline outline-2 outline-scrap-navy + shadow-scrap-deep transition-all duration-300 + hover:scale-110 hover:-translate-y-1 hover:rotate-12 hover:shadow-scrap-lift + active:scale-95 active:translate-y-0 + ${isDragging ? "bg-scrap-sage rotate-12" : ""} + `} aria-label="Add book" + title="Add a new book (EPUB)" > {isLoading ? ( - + ) : ( )} + + {/* Sticker Edge Effect */} +
+ {/* Label Tooltip (Scrap of paper) */} +
+ ADD NEW +
+ {isDragging && ( -
+
)}
{errorMessage && ( -
- {errorMessage} +
+ Oops! {errorMessage}
)} diff --git a/apps/web/src/components/ui/BookCard.tsx b/apps/web/src/components/ui/BookCard.tsx index cd97aec..1d2a1b5 100644 --- a/apps/web/src/components/ui/BookCard.tsx +++ b/apps/web/src/components/ui/BookCard.tsx @@ -21,6 +21,12 @@ const BookCard: React.FC = ({ const [showConfirmDelete, setShowConfirmDelete] = useState(false); const [imageError, setImageError] = useState(false); + // Random tilt for "living bookshelf" feel + const tilt = React.useMemo(() => { + const hash = book.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return (hash % 6) - 3; // -3deg to +3deg + }, [book.id]); + const handleDelete = (e: React.MouseEvent) => { e.stopPropagation(); setShowConfirmDelete(true); @@ -37,14 +43,15 @@ const BookCard: React.FC = ({ }; if (variant === "compact") { - // Compact variant for horizontal scrolls (keep simpler but consistent) + // Compact variant for horizontal scrolls (Polaroid style) return ( onSelect(book)} - whileHover={{ y: -4 }} + whileHover={{ y: -8, rotateX: 5, zIndex: 10 }} + style={{ rotate: tilt }} > -
+
{!imageError ? ( = ({ onError={() => setImageError(true)} /> ) : ( -
- +
+ {book.title}
)} + + {/* Dust/Texture Overlay */} +
+ {/* Progress Bar Overlay */} {book.progress > 0 && ( -
+
)}
-

{book.title}

+

{book.title}

); } // Standard 3D Book Card return ( -
+
{showConfirmDelete && ( e.stopPropagation()} > -

Delete book?

+

Burn this book?

@@ -107,50 +118,65 @@ const BookCard: React.FC = ({ onSelect(book)} - className="cursor-pointer relative transform-style-3d transition-transform duration-300 group-hover:-translate-y-2 group-hover:rotate-y-[-5deg]" + className="cursor-pointer relative transform-style-3d transition-transform duration-500 ease-out group-hover:-translate-y-4 group-hover:rotate-y-[-10deg] group-hover:rotate-x-[5deg] group-hover:scale-105 z-10" > {/* Book Spine (Fake 3D) */}
+
+ {book.title.slice(0, 15)} +
+
+ + {/* Page Edges (Right Side) */} +
{/* Main Cover */} -
+
{!imageError ? ( {book.title} setImageError(true)} /> ) : ( -
-
- +
+
+
- + {book.title} - + {book.author}
)} - {/* Shine/Texture Overlay */} -
-
{/* Spine crease */} + {/* Realistic Shine/Texture Overlay */} +
+
+
{/* Spine crease */} {/* Badges */} -
+
{book.isFavorite && ( -
+
)} {book.progress > 0 && book.progress < 100 && ( -
+
)} @@ -158,39 +184,43 @@ const BookCard: React.FC = ({
{/* Shadow (Bottom) */} -
+
{/* Info (Below Book) */} -
-

+
+

{book.title}

-

+

{book.author}

{/* Progress Bar (Visible on hover or if active) */} {book.progress > 0 && ( -
+
+ > +
+
)}
- {/* Hover Actions */} -
+ {/* Hover Actions (Floating off to the side) */} +
diff --git a/apps/web/src/components/ui/BunniesPick.tsx b/apps/web/src/components/ui/BunniesPick.tsx index 68e5026..da13d7c 100644 --- a/apps/web/src/components/ui/BunniesPick.tsx +++ b/apps/web/src/components/ui/BunniesPick.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from "react"; -import { Book } from "@/types"; -import { BookOpen, Star, Sparkles } from "lucide-react"; +import type { Book } from "@/types"; +import { BookOpen, Sparkles } from "lucide-react"; import { motion } from "framer-motion"; interface BunniesPickProps { @@ -9,9 +9,6 @@ interface BunniesPickProps { } const BunniesPick: React.FC = ({ books, onSelectBook }) => { - // Logic: Prefer a "Favorite" book that is NOT finished. - // If no favorites, pick a random unfinished book. - // If all finished, pick a random book. const pickedBook = useMemo(() => { if (!books.length) return null; @@ -32,72 +29,77 @@ const BunniesPick: React.FC = ({ books, onSelectBook }) => { return ( - {/* Decorative Background Elements */} -
-
- - {/* Pixel Art Decoration (SVG) */} -
- - - + {/* Tape Strip */} +
+ + {/* Background Texture */} +
+ + {/* Decorative Stamp */} +
+
+ BUNNIES'
CHOICE
+
-
+
{/* Book Cover with 3D effect */} -
-
+
{pickedBook.title} - {/* Shine effect */} -
+ {/* Gloss */} +
+ {/* Shadow */} +
{/* Content */}
-
- - Bunnies' Pick - +
+ + Recommended Reading +
-

+

{pickedBook.title}

-

+

by {pickedBook.author}

-

- The bunnies have sniffed around your library and think this is the perfect story for right now. - Curl up with a warm drink and dive in! +

+ "The bunnies have sniffed around your library and think this is the perfect story for right now. + Curl up with a warm drink and dive in!"

-
+
{pickedBook.progress > 0 && ( - + {pickedBook.progress}% Complete )} diff --git a/apps/web/src/components/ui/Header.tsx b/apps/web/src/components/ui/Header.tsx index 2f7ea01..7e369d8 100644 --- a/apps/web/src/components/ui/Header.tsx +++ b/apps/web/src/components/ui/Header.tsx @@ -1,13 +1,14 @@ -import React, { useEffect, useRef, useState } from "react"; -import { Search, LogOut, LogIn, X, BookOpen, Moon, Sun } from "lucide-react"; +import React, { useState } from "react"; import { Theme } from "@/types"; +import { Search, Moon, Sun, User, LogOut, X, Sparkles, Star } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; interface HeaderProps { theme: Theme; onToggleTheme: () => void; searchTerm: string; onSearch: (term: string) => void; - isGuest?: boolean; + isGuest: boolean; onShowLogin?: () => void; onSignOut?: () => void; userEmail?: string | null; @@ -19,104 +20,136 @@ const Header: React.FC = ({ onToggleTheme, searchTerm, onSearch, - isGuest = false, + isGuest, onShowLogin, onSignOut, userEmail, userImage, }) => { - const inputRef = useRef(null); - const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm); - - useEffect(() => { - setLocalSearchTerm(searchTerm); - }, [searchTerm]); - - useEffect(() => { - const timer = window.setTimeout(() => { - if (localSearchTerm !== searchTerm) { - onSearch(localSearchTerm); - } - }, 300); - return () => window.clearTimeout(timer); - }, [localSearchTerm, onSearch, searchTerm]); + const [isSearchFocused, setIsSearchFocused] = useState(false); return ( -
-
-
-
-
- +
+ {/* + ============================================================ + Header: Thick Rounded Rectangle + ============================================================ + */} +
+
+ {/* Hanging Charms (Dangling from the bottom edge) */} +
+
+ +
-
-

Sanctuary

-

Book Reader

+
+
+ +
-
-
-
- - setLocalSearchTerm(e.target.value)} - className="w-full h-10 pl-10 pr-10 rounded-xl border border-black/[0.08] dark:border-white/[0.08] bg-light-surface dark:bg-dark-surface text-sm text-light-text dark:text-dark-text placeholder:text-light-text-muted dark:placeholder:text-dark-text-muted focus:outline-none focus:ring-2 focus:ring-light-accent/30 dark:focus:ring-dark-accent/30" - /> - {localSearchTerm && ( - - )} + {/* Left: Brand / Logo */} +
+
+ S +
+ + Sanctuary +
-
-
- + {/* Center: Search Bar (Taped Notebook Paper) */} +
+ {/* Tape Strip (Top Center) */} +
- {isGuest ? ( - - ) : onSignOut ? ( -
- {userImage && Profile} - {userEmail && ( - - {userEmail} - - )} + + + onSearch(e.target.value)} + onFocus={() => setIsSearchFocused(true)} + onBlur={() => setIsSearchFocused(false)} + placeholder="Search index cards..." + className="w-full bg-transparent outline-none text-base font-body text-scrap-navy placeholder-scrap-blue/60" + /> + {searchTerm && ( + + )} + + {/* Index Tab */} +
+ FIND +
+
+
+ + {/* Right: Controls */} +
-
- ) : null} -
+ + {isGuest ? ( + + ) : ( +
+ + + {/* Dropdown Menu (Paper Style) */} +
+
+
+

{userEmail}

+
+ +
+
+
+ )} +
diff --git a/apps/web/src/components/ui/Navigation.tsx b/apps/web/src/components/ui/Navigation.tsx index f87ce78..4672325 100644 --- a/apps/web/src/components/ui/Navigation.tsx +++ b/apps/web/src/components/ui/Navigation.tsx @@ -1,6 +1,6 @@ -import React from "react"; +import React, { useMemo } from "react"; import { View } from "@/types"; -import { Library, BookOpen, BarChart3, Settings } from "lucide-react"; +import { Library, BookOpen, Settings, BarChart2 } from "lucide-react"; interface NavigationProps { activeView: View; @@ -9,40 +9,60 @@ interface NavigationProps { } const Navigation: React.FC = ({ activeView, onNavigate, isReaderActive }) => { - const navItems = [ - { view: View.LIBRARY, label: "Library", icon: Library, disabled: false }, - { view: View.READER, label: "Reader", icon: BookOpen, disabled: !isReaderActive }, - { view: View.STATS, label: "Stats", icon: BarChart3, disabled: false }, - { view: View.SETTINGS, label: "Settings", icon: Settings, disabled: false }, - ]; + const navItems = useMemo(() => [ + { id: View.LIBRARY, label: "Library", icon: Library, rotation: -2, delay: 0 }, + { id: View.READER, label: "Reader", icon: BookOpen, rotation: 3, delay: 0.1, disabled: !isReaderActive }, + { id: View.STATS, label: "Journal", icon: BarChart2, rotation: -1, delay: 0.2 }, + { id: View.SETTINGS, label: "Settings", icon: Settings, rotation: 2, delay: 0.3 }, + ], [isReaderActive]); return ( -