Skip to content

Commit e1469c3

Browse files
committed
feat(bookshelf): add 'The Reading Room' skeuomorphic bookstore edition
Add /bookshelf, a warm, tactile (refined early-iOS skeuomorphic) reading experience for From Serfs and Frauds, alongside /snf and /stories. - Shelf: the seven volumes as embossed cloth books on lamp-lit wood shelves, with a 3D pick-up hover - Paginated reader: a real two-page book spread (single page on mobile) with page-turns via tap zones, swipe, arrow keys and red chevrons -- no scroll - Day / sepia / night reading themes with themed popovers, size + typeface prefs, bookmark ribbon and a contents sheet - Reuses public/stories piml data via the shared useStoriesData hook
1 parent a058780 commit e1469c3

11 files changed

Lines changed: 1333 additions & 4 deletions

File tree

public/sidebar.piml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@
124124
(label) S&F // Terminal
125125
(to) /snf
126126
(icon) TerminalWindowIcon
127+
> (item)
128+
(label) SnF Bookshelf
129+
(to) /bookshelf
130+
(icon) BookOpenIcon
127131
> (item)
128132
(label) RSS_Feed
129133
(url) /rss.xml

src/components/AnimatedRoutes.jsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ const SnfAgentsPage = lazy(() => import('../pages/snf/SnfAgentsPage'));
3535
const SnfBoardPage = lazy(() => import('../pages/snf/SnfBoardPage'));
3636
const SnfSettingsPage = lazy(() => import('../pages/snf/SnfSettingsPage'));
3737
const SnfNotFoundPage = lazy(() => import('../pages/snf/SnfNotFoundPage'));
38+
// The Reading Room — skeuomorphic bookstore (/snf-v3)
39+
const SnfV3ShelfPage = lazy(() => import('../pages/snf-v3/SnfV3ShelfPage'));
40+
const SnfV3ReadPage = lazy(() => import('../pages/snf-v3/SnfV3ReadPage'));
3841
const AppPage = lazy(() => import('../pages/AppPage'));
3942
const IpPage = lazy(() => import('../pages/apps/IpPage'));
4043
const WordCounterPage = lazy(() => import('../pages/apps/WordCounterPage'));
@@ -819,6 +822,31 @@ const AnimatedRoutes = ({
819822
</Suspense>
820823
}
821824
/>
825+
{/* ===== The Reading Room — bookstore (/snf-v3) ===== */}
826+
<Route
827+
path="/bookshelf"
828+
element={
829+
<Suspense fallback={<Loading />}>
830+
<SnfV3ShelfPage />
831+
</Suspense>
832+
}
833+
/>
834+
<Route
835+
path="/bookshelf/read/:bookId/:episodeId"
836+
element={
837+
<Suspense fallback={<Loading />}>
838+
<SnfV3ReadPage />
839+
</Suspense>
840+
}
841+
/>
842+
<Route
843+
path="/bookshelf/*"
844+
element={
845+
<Suspense fallback={<Loading />}>
846+
<SnfV3ShelfPage />
847+
</Suspense>
848+
}
849+
/>
822850
<Route
823851
path="/random"
824852
element={

src/components/Layout.jsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useProjects } from '../utils/projectParser';
2424

2525
import { DndProvider } from '../context/DndContext';
2626
import { SnfProvider } from '../context/SnfContext';
27+
import { SnfV3Provider } from '../context/SnfV3Context';
2728

2829
const Layout = ({
2930
children,
@@ -52,7 +53,9 @@ const Layout = ({
5253
const isAboutPage = location.pathname.startsWith('/about');
5354
const isGraphPage = location.pathname.startsWith('/graph');
5455
const isTerminalPage = location.pathname.startsWith('/terminal');
56+
const isBookshelfPage = location.pathname.startsWith('/bookshelf');
5557
const isSnfPage = location.pathname.startsWith('/snf');
58+
const isSnfAny = isSnfPage || isBookshelfPage;
5659

5760
// Check for special project styles that require hiding the default layout
5861
const projectSlug = location.pathname.startsWith('/projects/')
@@ -82,11 +85,13 @@ const Layout = ({
8285
isSpecialProject ||
8386
isTheVaguePage ||
8487
isTerminalPage ||
85-
isSnfPage ||
88+
isSnfAny ||
8689
(isAppDetail && isAppFullscreen);
8790

8891
const mainContent = location.pathname.startsWith('/stories') ? (
8992
<DndProvider>{children}</DndProvider>
93+
) : isBookshelfPage ? (
94+
<SnfV3Provider>{children}</SnfV3Provider>
9095
) : isSnfPage ? (
9196
<SnfProvider>{children}</SnfProvider>
9297
) : (
@@ -170,8 +175,8 @@ const Layout = ({
170175

171176
return (
172177
<>
173-
{!isSnfPage && <Banner />}
174-
{!isSnfPage && <FalloutOverlay />}
178+
{!isSnfAny && <Banner />}
179+
{!isSnfAny && <FalloutOverlay />}
175180
{isGarden && !hideLayout && <DigitalFlowers />}
176181
{isAutumn && !hideLayout && <DigitalLeaves />}
177182
{isRain && !hideLayout && <NaturalRain />}
@@ -184,7 +189,7 @@ const Layout = ({
184189
/>
185190
{!hideLayout && <SidePanel />}
186191
{mainContent}
187-
{!isSnfPage && <SyntaxSprite />}
192+
{!isSnfAny && <SyntaxSprite />}
188193
</>
189194
);
190195
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { motion } from 'framer-motion';
4+
import { toRoman } from '../../pages/snf-v3/snfV3Utils';
5+
6+
// Muted, aged cloth bindings — one per volume.
7+
const BINDINGS = [
8+
'#7c2b2b', // oxblood
9+
'#2f4a44', // deep teal
10+
'#39466a', // navy
11+
'#6b5836', // ochre
12+
'#4a3550', // plum
13+
'#34442f', // forest
14+
'#5a3326', // chestnut
15+
];
16+
17+
const SnfV3Book = ({ book, language = 'en' }) => {
18+
const color = BINDINGS[(Number(book.bookId) - 1) % BINDINGS.length];
19+
const firstEp = book.episodes?.[0];
20+
const to = firstEp ? `/bookshelf/read/${book.bookId}/${firstEp.id}` : '/bookshelf';
21+
const title = book.bookTitle.replace(/^Volume\s+[IVX]+:\s*/i, '');
22+
const count = book.episodes?.length || 0;
23+
const tr = language === 'tr';
24+
25+
return (
26+
<div
27+
className="snf3-book-wrap relative"
28+
style={{ paddingBottom: 16, perspective: '1100px' }}
29+
>
30+
<div className="snf3-book-shadow" />
31+
<motion.div
32+
whileHover={{ rotateX: -8, rotateY: -7, y: -6, scale: 1.02, zIndex: 20 }}
33+
whileTap={{ rotateX: -4, rotateY: -3, scale: 1.0 }}
34+
transition={{ type: 'spring', stiffness: 240, damping: 19 }}
35+
className="relative"
36+
style={{ transformStyle: 'preserve-3d', transformOrigin: 'center bottom' }}
37+
>
38+
<Link
39+
to={to}
40+
className="snf3-book"
41+
style={{ background: color }}
42+
aria-label={title}
43+
>
44+
<span className="snf3-book-pages" />
45+
<span className="snf3-book-num">VOL. {toRoman(book.bookId)}</span>
46+
<span className="block">
47+
<span className="snf3-book-title block">{title}</span>
48+
<span className="snf3-book-rule" />
49+
<span className="snf3-book-num block !text-[0.58rem] !tracking-[0.14em] opacity-85">
50+
{count} {tr ? 'bölüm' : count === 1 ? 'chapter' : 'chapters'}
51+
</span>
52+
</span>
53+
</Link>
54+
</motion.div>
55+
</div>
56+
);
57+
};
58+
59+
export default SnfV3Book;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React from 'react';
2+
import { motion } from 'framer-motion';
3+
import { useSnfV3 } from '../../context/SnfV3Context';
4+
5+
const THEMES = [
6+
{ value: 'day', swatch: '#f4ecd8', label: 'Day' },
7+
{ value: 'sepia', swatch: '#e8d7b4', label: 'Sepia' },
8+
{ value: 'night', swatch: '#16130e', label: 'Night' },
9+
];
10+
11+
const Seg = ({ on, onClick, children }) => (
12+
<button type="button" className="snf3-seg" data-on={on ? '1' : '0'} onClick={onClick}>
13+
{children}
14+
</button>
15+
);
16+
17+
/** Reading preferences sheet (theme · size · typeface). */
18+
const SnfV3Prefs = ({ onClose }) => {
19+
const { settings, setPref, language } = useSnfV3();
20+
const tr = language === 'tr';
21+
return (
22+
<>
23+
<div className="fixed inset-0 z-40" onClick={onClose} aria-hidden />
24+
<motion.div
25+
initial={{ opacity: 0, y: -10, scale: 0.98 }}
26+
animate={{ opacity: 1, y: 0, scale: 1 }}
27+
exit={{ opacity: 0, y: -10, scale: 0.98 }}
28+
transition={{ type: 'spring', stiffness: 380, damping: 26 }}
29+
className="snf3-sheet absolute right-3 md:right-5 top-full mt-2 z-50 w-72 p-4"
30+
role="dialog"
31+
aria-label="Reading preferences"
32+
>
33+
<div
34+
className="text-[0.7rem] tracking-[0.18em] uppercase mb-3"
35+
style={{ color: 'var(--snf3-sheet-muted)' }}
36+
>
37+
{tr ? 'Görünüm' : 'Appearance'}
38+
</div>
39+
<div className="flex items-center justify-between gap-3 mb-4">
40+
{THEMES.map((th) => (
41+
<button
42+
key={th.value}
43+
type="button"
44+
className="snf3-swatch"
45+
data-on={settings.theme === th.value ? '1' : '0'}
46+
style={{ background: th.swatch }}
47+
onClick={() => setPref('theme', th.value)}
48+
aria-label={th.label}
49+
title={th.label}
50+
/>
51+
))}
52+
</div>
53+
54+
<div className="flex items-center justify-between gap-2 mb-3">
55+
<span className="text-[0.72rem]" style={{ color: 'var(--snf3-sheet-muted)' }}>
56+
{tr ? 'Boyut' : 'Size'}
57+
</span>
58+
<div className="flex">
59+
{[['sm', 'A'], ['md', 'A'], ['lg', 'A']].map(([v, l], i) => (
60+
<Seg key={v} on={settings.size === v} onClick={() => setPref('size', v)}>
61+
<span style={{ fontSize: `${0.7 + i * 0.18}rem` }}>{l}</span>
62+
</Seg>
63+
))}
64+
</div>
65+
</div>
66+
67+
<div className="flex items-center justify-between gap-2">
68+
<span className="text-[0.72rem]" style={{ color: 'var(--snf3-sheet-muted)' }}>
69+
{tr ? 'Yazı tipi' : 'Typeface'}
70+
</span>
71+
<div className="flex">
72+
<Seg on={settings.typeface === 'serif'} onClick={() => setPref('typeface', 'serif')}>
73+
<span style={{ fontFamily: 'var(--snf3-serif)' }}>Serif</span>
74+
</Seg>
75+
<Seg on={settings.typeface === 'sans'} onClick={() => setPref('typeface', 'sans')}>
76+
<span style={{ fontFamily: 'var(--snf3-ui)' }}>Sans</span>
77+
</Seg>
78+
</div>
79+
</div>
80+
</motion.div>
81+
</>
82+
);
83+
};
84+
85+
export default SnfV3Prefs;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React, { useEffect } from 'react';
2+
import { useLocation } from 'react-router-dom';
3+
import { useSnfV3 } from '../../context/SnfV3Context';
4+
import '../../styles/snf-v3.css';
5+
6+
/** Shared root for /snf-v3: applies the `.snf3` scroll container + theme attrs. */
7+
const SnfV3Root = ({ children, className = '' }) => {
8+
const { settings, scrollRef } = useSnfV3();
9+
const location = useLocation();
10+
useEffect(() => {
11+
scrollRef.current?.scrollTo({ top: 0, left: 0 });
12+
}, [location.pathname, scrollRef]);
13+
14+
return (
15+
<div
16+
ref={scrollRef}
17+
className={`snf3 ${className}`}
18+
data-snf3-theme={settings.theme}
19+
data-snf3-size={settings.size}
20+
data-snf3-typeface={settings.typeface}
21+
>
22+
{children}
23+
</div>
24+
);
25+
};
26+
27+
export default SnfV3Root;

src/context/SnfV3Context.jsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React, {
2+
createContext,
3+
useContext,
4+
useMemo,
5+
useCallback,
6+
useRef,
7+
} from 'react';
8+
import usePersistentState from '../hooks/usePersistentState';
9+
10+
/**
11+
* SnfV3Context — "The Reading Room".
12+
* A warm, tactile (refined-skeuomorphic) bookstore for From Serfs and Frauds.
13+
* Minimal reading preferences, in the spirit of early iBooks.
14+
*/
15+
export const SnfV3Context = createContext(null);
16+
17+
export const SNF3_DEFAULTS = {
18+
theme: 'day', // day | sepia | night (paper tone while reading)
19+
size: 'md', // sm | md | lg
20+
typeface: 'serif', // serif (Lora) | sans (system)
21+
};
22+
23+
export const SnfV3Provider = ({ children }) => {
24+
const [prefs, setPrefs] = usePersistentState('snf3-prefs', SNF3_DEFAULTS);
25+
const settings = useMemo(() => ({ ...SNF3_DEFAULTS, ...prefs }), [prefs]);
26+
27+
const setPref = useCallback(
28+
(key, value) => setPrefs((p) => ({ ...SNF3_DEFAULTS, ...p, [key]: value })),
29+
[setPrefs],
30+
);
31+
32+
const [language, setLanguage] = usePersistentState('snf3-language', 'en');
33+
const scrollRef = useRef(null);
34+
35+
const value = useMemo(
36+
() => ({ settings, setPref, language, setLanguage, scrollRef }),
37+
[settings, setPref, language, setLanguage],
38+
);
39+
40+
return (
41+
<SnfV3Context.Provider value={value}>{children}</SnfV3Context.Provider>
42+
);
43+
};
44+
45+
export const useSnfV3 = () => {
46+
const ctx = useContext(SnfV3Context);
47+
if (!ctx) throw new Error('useSnfV3 must be used within a SnfV3Provider');
48+
return ctx;
49+
};
50+
51+
export default SnfV3Provider;

0 commit comments

Comments
 (0)