diff --git a/package-lock.json b/package-lock.json index 7fe9439..c81c852 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "libre-study", - "version": "0.1.10", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "libre-study", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { "@tauri-apps/api": "^2.11.1", "react": "^19.2.7", diff --git a/package.json b/package.json index 1042c12..e39ed58 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "libre-study", "private": true, - "version": "0.1.10", + "version": "0.1.11", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 32eff1c..9963648 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "0.1.10" +version = "0.1.11" dependencies = [ "log", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b52b589..f8cb454 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.10" +version = "0.1.11" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ff5817b..0948602 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Libre Study", - "version": "0.1.10", + "version": "0.1.11", "identifier": "org.librestudy.desktop", "build": { "frontendDist": "../dist", diff --git a/src/App.css b/src/App.css index d362e5e..12ced6d 100644 --- a/src/App.css +++ b/src/App.css @@ -285,6 +285,7 @@ textarea:focus-visible { grid-template-rows: minmax(0, 1fr) auto; min-height: 0; overflow: visible; + perspective: 1400px; } .page-system.study, @@ -338,6 +339,14 @@ textarea:focus-visible { font-family: Georgia, 'Times New Roman', serif; } +.page-system.turn-forward .bible-page { + animation: page-turn-forward 480ms ease; +} + +.page-system.turn-back .bible-page { + animation: page-turn-back 480ms ease; +} + .page-verse { break-inside: avoid; margin: 0 0 9px; @@ -651,73 +660,113 @@ textarea:focus-visible { text-underline-offset: 3px; } -.session-timeline { +.note-helper { display: grid; - gap: 10px; - padding-top: 2px; -} - -.session-timeline header { - display: flex; - justify-content: space-between; + grid-template-columns: 74px minmax(0, 1fr); gap: 12px; align-items: center; -} - -.session-timeline header strong { - color: #657061; - font-size: 12px; -} - -.session-timeline ol { - display: grid; - gap: 6px; - max-height: 240px; - margin: 0; - padding: 0; - overflow: auto; - list-style: none; -} - -.session-timeline li { - margin: 0; -} - -.session-timeline li button { - display: grid; - grid-template-columns: 54px minmax(0, 1fr); - gap: 4px 8px; - width: 100%; - padding: 8px 9px; + margin-top: auto; + padding: 12px; border-color: #ded6c7; background: #fbf8ef; color: #202124; text-align: left; } -.session-timeline li button:disabled { - cursor: default; - opacity: 1; +.note-helper strong, +.note-helper small { + display: block; } -.session-timeline li button span, -.session-timeline li button small { +.note-helper small { color: #657061; - font-size: 11px; + font-size: 12px; } -.session-timeline li button small { - grid-column: 2; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.note-helper-figure { + position: relative; + display: block; + width: 64px; + height: 58px; } -.session-timeline p { - margin: 0; - color: #657061; - font-size: 13px; - line-height: 1.45; +.helper-head, +.helper-body, +.helper-notebook, +.helper-pencil { + position: absolute; + display: block; +} + +.helper-head { + top: 2px; + left: 20px; + width: 26px; + height: 22px; + border: 2px solid #252821; + border-radius: 8px; + background: #d6a84f; +} + +.helper-head::before, +.helper-head::after { + position: absolute; + top: 8px; + width: 4px; + height: 4px; + border-radius: 50%; + background: #252821; + content: ''; +} + +.helper-head::before { + left: 6px; +} + +.helper-head::after { + right: 6px; +} + +.helper-body { + top: 27px; + left: 17px; + width: 32px; + height: 22px; + border-radius: 8px 8px 5px 5px; + background: #2f6f5e; +} + +.helper-notebook { + right: 2px; + bottom: 3px; + width: 34px; + height: 22px; + border: 2px solid #c9baa0; + border-radius: 3px; + background: #fffef8; + transform: rotate(-4deg); +} + +.helper-notebook::before { + position: absolute; + top: 6px; + left: 5px; + width: 20px; + height: 2px; + background: #d8cfbf; + box-shadow: 0 5px 0 #d8cfbf; + content: ''; +} + +.helper-pencil { + right: 18px; + bottom: 21px; + width: 24px; + height: 4px; + border-radius: 999px; + background: #735c0f; + transform-origin: right center; + animation: helper-writing 900ms ease-in-out infinite; } .book-tabs { @@ -731,6 +780,14 @@ textarea:focus-visible { background: #ece9df; } +.book-tabs.turn-forward { + animation: tab-window-forward 320ms ease; +} + +.book-tabs.turn-back { + animation: tab-window-back 320ms ease; +} + .book-tabs button { min-width: 0; height: 30px; @@ -896,6 +953,10 @@ textarea:focus-visible { width: min(760px, 100%); } +.timeline-modal { + width: min(1080px, 100%); +} + .about-modal p { margin: 0; color: #3f443d; @@ -917,6 +978,158 @@ textarea:focus-visible { text-decoration: none; } +.timeline-canvas { + overflow-x: auto; + padding: 30px 4px 12px; +} + +.timeline-canvas ol { + position: relative; + display: flex; + gap: 18px; + min-width: max-content; + margin: 0; + padding: 0; + list-style: none; +} + +.timeline-canvas ol::before { + position: absolute; + top: 18px; + right: 0; + left: 0; + height: 3px; + border-radius: 999px; + background: #d6a84f; + content: ''; +} + +.timeline-canvas li { + position: relative; + width: 190px; + padding-top: 30px; +} + +.timeline-dot { + position: absolute; + top: 10px; + left: 16px; + z-index: 1; + width: 18px; + height: 18px; + border: 3px solid #fffef8; + border-radius: 50%; + background: #2f6f5e; + box-shadow: 0 0 0 2px #d6a84f; +} + +.timeline-canvas button { + display: grid; + gap: 5px; + width: 100%; + min-height: 120px; + align-content: start; + border-color: #ded6c7; + background: #fbf8ef; + color: #202124; + text-align: left; +} + +.timeline-canvas button:disabled { + cursor: default; + opacity: 1; +} + +.timeline-canvas time, +.timeline-canvas span, +.timeline-canvas small { + color: #657061; + font-size: 12px; +} + +.timeline-canvas small { + overflow: hidden; + text-overflow: ellipsis; +} + +.empty-timeline { + display: grid; + gap: 12px; + justify-items: center; + padding: 30px; + color: #657061; +} + +@keyframes page-turn-forward { + 0% { + opacity: 0.8; + transform: translateX(18px) rotateY(-4deg); + } + 55% { + opacity: 1; + transform: translateX(-6px) rotateY(2deg); + } + 100% { + transform: translateX(0) rotateY(0); + } +} + +@keyframes page-turn-back { + 0% { + opacity: 0.8; + transform: translateX(-18px) rotateY(4deg); + } + 55% { + opacity: 1; + transform: translateX(6px) rotateY(-2deg); + } + 100% { + transform: translateX(0) rotateY(0); + } +} + +@keyframes tab-window-forward { + 0% { + transform: translateX(18px); + opacity: 0.65; + } + 100% { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes tab-window-back { + 0% { + transform: translateX(-18px); + opacity: 0.65; + } + 100% { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes helper-writing { + 0%, + 100% { + transform: translate(0, 0) rotate(-18deg); + } + 50% { + transform: translate(5px, 3px) rotate(-10deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .page-system.turn-forward .bible-page, + .page-system.turn-back .bible-page, + .book-tabs.turn-forward, + .book-tabs.turn-back, + .helper-pencil { + animation: none; + } +} + .status-panel.error { border-color: #b42318; color: #b42318; diff --git a/src/App.tsx b/src/App.tsx index c6b826a..cee36e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ const activeSessionStorageKey = 'libre-study.active-session.v1' const timelineLimit = 200 type ViewMode = 'page' | 'study' | 'notes' | 'compare' type StrongDisplayMode = 'off' | 'highlight' | 'numbers' +type PageMotion = 'idle' | 'turn-forward' | 'turn-back' type TimelineEntryType = 'verse' | 'strong' | 'footnote' | 'search' | 'navigation' | 'view' | 'setting' type TimelineEntry = { @@ -153,6 +154,8 @@ function App() { const [railOpen, setRailOpen] = useState(false) const [sessionModalOpen, setSessionModalOpen] = useState(false) const [aboutModalOpen, setAboutModalOpen] = useState(false) + const [timelineModalOpen, setTimelineModalOpen] = useState(false) + const [pageMotion, setPageMotion] = useState('idle') const [viewMode, setViewMode] = useState('notes') const [query, setQuery] = useState('') const [libraryVerses, setLibraryVerses] = useState([]) @@ -172,6 +175,7 @@ function App() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const restoredSessionId = useRef('') + const pageMotionTimeout = useRef(null) async function loadLibrary() { setError(null) @@ -223,6 +227,40 @@ function App() { ) } + function playPageTurnSound() { + if (!window.AudioContext) return + + try { + const audio = new window.AudioContext() + const oscillator = audio.createOscillator() + const gain = audio.createGain() + + oscillator.type = 'triangle' + oscillator.frequency.setValueAtTime(120, audio.currentTime) + oscillator.frequency.exponentialRampToValueAtTime(42, audio.currentTime + 0.08) + gain.gain.setValueAtTime(0.0001, audio.currentTime) + gain.gain.exponentialRampToValueAtTime(0.06, audio.currentTime + 0.012) + gain.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + 0.11) + oscillator.connect(gain) + gain.connect(audio.destination) + oscillator.start() + oscillator.stop(audio.currentTime + 0.12) + window.setTimeout(() => audio.close().catch(() => null), 180) + } catch { + // Page sound is intentionally best-effort. + } + } + + function triggerPageMotion(direction: PageMotion) { + if (pageMotionTimeout.current) window.clearTimeout(pageMotionTimeout.current) + setPageMotion('idle') + window.requestAnimationFrame(() => { + setPageMotion(direction) + playPageTurnSound() + pageMotionTimeout.current = window.setTimeout(() => setPageMotion('idle'), 520) + }) + } + async function runSearch(nextQuery = query) { setError(null) const trimmed = nextQuery.trim() @@ -439,6 +477,9 @@ function App() { libraryVerses.find((entry) => entry.book === book) if (!next) return + const currentBookIndex = selectedVerse ? books.indexOf(selectedVerse.book) : -1 + const nextBookIndex = books.indexOf(next.book) + triggerPageMotion(nextBookIndex >= currentBookIndex ? 'turn-forward' : 'turn-back') setQuery('') setSelectedStrong(null) setVerses(libraryVerses) @@ -466,6 +507,7 @@ function App() { pageAnchors[Math.min(pageAnchors.length - 1, Math.max(0, index + direction))] if (!next) return + triggerPageMotion(direction > 0 ? 'turn-forward' : 'turn-back') setPageStartId(next.id) setSelectedId(next.id) setSelectedStrong(null) @@ -817,7 +859,7 @@ function App() { if (!selectedVerse) return null return ( -