Add map and KJV audio playback UI
This commit is contained in:
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.29 - 2026-07-15
|
||||
|
||||
- Changed the Vite dev server to bind to `0.0.0.0` and allow the `desktop` MagicDNS host so the hot view can be reached from Pinky at `http://desktop:5174/`.
|
||||
|
||||
## 0.1.28 - 2026-07-12
|
||||
|
||||
- Added a KJV Audio Bible card to the study margin with selected-verse, full-chapter, and from-selected playback.
|
||||
- Added browser-side ZIP extraction for the LibreBible KJV audio package so MP3s can play from upstream book ZIPs without vendoring the audio files.
|
||||
- Added audio volume, speed, stop controls, loading/playback status, and CC BY 4.0 attribution display.
|
||||
|
||||
## 0.1.27 - 2026-07-12
|
||||
|
||||
- Added browser/local preview consumption for LibreBible contextual map packages.
|
||||
- Added a study-margin context map panel that shows mapped places for the visible page and highlights selected-verse places.
|
||||
- Added related UBS Bible route suggestions based on visible place context.
|
||||
|
||||
## 0.1.26 - 2026-07-12
|
||||
|
||||
- Started Phase 5 storage/search foundation with a reusable verse search index for browser/local preview queries.
|
||||
|
||||
Generated
+102
-71
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "libre-study",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.29",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "libre-study",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.29",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
@@ -213,9 +214,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -233,9 +231,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -253,9 +248,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -273,9 +265,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -293,9 +282,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -313,9 +299,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -333,9 +316,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -353,9 +333,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -526,9 +503,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -546,9 +520,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -566,9 +537,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -586,9 +554,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -606,9 +571,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -626,9 +588,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -814,9 +773,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -834,9 +790,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -854,9 +807,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -874,9 +824,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -894,9 +841,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1024,6 +968,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1074,6 +1024,45 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -1217,9 +1206,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1241,9 +1227,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1265,9 +1248,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1289,9 +1269,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1415,6 +1392,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1464,6 +1447,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
@@ -1485,6 +1474,21 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
@@ -1519,12 +1523,24 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1535,6 +1551,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -1581,6 +1606,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||
|
||||
+4
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "libre-study",
|
||||
"private": true,
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.29",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"dev:web": "vite --host 127.0.0.1",
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"dev:web": "vite --host 0.0.0.0",
|
||||
"dev:tauri": "tauri dev",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:web:hosted": "tsc -b && vite build --base=/study/ --outDir dist-hosted",
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
|
||||
+236
-2
@@ -741,7 +741,9 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.commentary-card,
|
||||
.dictionary-card {
|
||||
.dictionary-card,
|
||||
.map-card,
|
||||
.audio-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -787,6 +789,211 @@ textarea:focus-visible {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.map-card {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.context-map {
|
||||
position: relative;
|
||||
min-height: 190px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d9cfbd;
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(47, 111, 94, 0.12), transparent 34%),
|
||||
linear-gradient(0deg, rgba(214, 168, 79, 0.18), transparent 42%),
|
||||
#eef1e6;
|
||||
}
|
||||
|
||||
.map-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(68, 76, 65, 0.12) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(68, 76, 65, 0.12) 1px, transparent 1px);
|
||||
background-size: 25% 25%;
|
||||
}
|
||||
|
||||
.map-marker {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 2px solid #fffef8;
|
||||
border-radius: 50%;
|
||||
background: #2f6f5e;
|
||||
color: #fffef8;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 2px 7px rgba(22, 26, 20, 0.26);
|
||||
}
|
||||
|
||||
.map-marker.selected {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: #d6a84f;
|
||||
color: #141510;
|
||||
}
|
||||
|
||||
.place-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.place-list section {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 7px;
|
||||
border: 1px solid #e4dccd;
|
||||
border-radius: 5px;
|
||||
background: #fbf8ef;
|
||||
}
|
||||
|
||||
.place-list h3 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #252821;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.place-list p {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
color: #657061;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.place-list small {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.route-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
color: #657061;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.route-list span {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #d8cfbf;
|
||||
border-radius: 999px;
|
||||
background: #fbf8ef;
|
||||
color: #252821;
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.route-list small {
|
||||
color: #657061;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.map-attribution {
|
||||
margin: 0;
|
||||
color: #657061;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.audio-card {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audio-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.audio-controls button {
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 5px 7px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audio-controls .secondary-button {
|
||||
border-color: #d8cfbf;
|
||||
background: #fbf8ef;
|
||||
color: #252821;
|
||||
}
|
||||
|
||||
.audio-sliders {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(90px, 0.45fr);
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.audio-sliders label {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: #657061;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.audio-sliders select {
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
|
||||
.audio-sliders input[type='range'] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.audio-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: #3f443d;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.audio-status strong {
|
||||
flex: 0 0 auto;
|
||||
color: #657061;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.audio-status span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notes-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1381,11 +1588,28 @@ textarea:focus-visible {
|
||||
|
||||
.theme-dark .original-language-verse,
|
||||
.theme-dark .english-strong-line,
|
||||
.theme-dark .resource-card {
|
||||
.theme-dark .resource-card,
|
||||
.theme-dark .place-list section,
|
||||
.theme-dark .route-list span,
|
||||
.theme-dark .audio-controls .secondary-button {
|
||||
border-color: #3f493f;
|
||||
background: #20251f;
|
||||
}
|
||||
|
||||
.theme-dark .context-map {
|
||||
border-color: #3f493f;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(58, 122, 105, 0.22), transparent 34%),
|
||||
linear-gradient(0deg, rgba(199, 150, 63, 0.2), transparent 42%),
|
||||
#1c241e;
|
||||
}
|
||||
|
||||
.theme-dark .map-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(199, 213, 190, 0.13) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(199, 213, 190, 0.13) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.theme-dark .margin-section-header {
|
||||
background: #2b332a;
|
||||
}
|
||||
@@ -1420,6 +1644,14 @@ textarea:focus-visible {
|
||||
.theme-dark .timeline-canvas small,
|
||||
.theme-dark .note-helper small,
|
||||
.theme-dark .note-placeholder small,
|
||||
.theme-dark .place-list p,
|
||||
.theme-dark .place-list small,
|
||||
.theme-dark .route-list,
|
||||
.theme-dark .route-list small,
|
||||
.theme-dark .map-attribution,
|
||||
.theme-dark .audio-sliders label,
|
||||
.theme-dark .audio-status,
|
||||
.theme-dark .audio-status strong,
|
||||
.theme-dark .session-facts dt,
|
||||
.theme-dark .about-list dt,
|
||||
.theme-dark .modal-section label,
|
||||
@@ -1431,6 +1663,8 @@ textarea:focus-visible {
|
||||
.theme-dark .strong-focus p,
|
||||
.theme-dark .commentary-list h3,
|
||||
.theme-dark .dictionary-list h3,
|
||||
.theme-dark .place-list h3,
|
||||
.theme-dark .route-list span,
|
||||
.theme-dark .commentary-list p,
|
||||
.theme-dark .dictionary-list p,
|
||||
.theme-dark .linked-line-text,
|
||||
|
||||
+352
-1
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
addTagToVerse,
|
||||
getCommentaryForVerse,
|
||||
getAudioContextForVerse,
|
||||
getAudioObjectUrl,
|
||||
getDictionaryEntriesForTerms,
|
||||
getLibraryState,
|
||||
getMapContextForVerses,
|
||||
getTranslationNotesForVerses,
|
||||
getTranslations,
|
||||
searchVerses,
|
||||
@@ -18,7 +21,15 @@ import {
|
||||
timelineLimit,
|
||||
} from './workspace'
|
||||
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
||||
import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types'
|
||||
import type {
|
||||
AudioBibleContext,
|
||||
AudioBibleSegment,
|
||||
CommentaryEntry,
|
||||
DictionaryEntry,
|
||||
MapContext,
|
||||
TranslationResource,
|
||||
Verse,
|
||||
} from './types'
|
||||
import type {
|
||||
StrongDisplayMode,
|
||||
StudySession,
|
||||
@@ -221,7 +232,19 @@ function App() {
|
||||
const [splitPercent, setSplitPercent] = useState(50)
|
||||
const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([])
|
||||
const [dictionaryEntries, setDictionaryEntries] = useState<DictionaryEntry[]>([])
|
||||
const [mapContext, setMapContext] = useState<MapContext>({
|
||||
selectedPlaces: [],
|
||||
pagePlaces: [],
|
||||
relatedRoutes: [],
|
||||
})
|
||||
const [audioContext, setAudioContext] = useState<AudioBibleContext | null>(null)
|
||||
const [audioResourcesLoading, setAudioResourcesLoading] = useState(false)
|
||||
const [audioStatus, setAudioStatus] = useState('Ready')
|
||||
const [audioPlayingSegmentId, setAudioPlayingSegmentId] = useState<string | null>(null)
|
||||
const [audioVolume, setAudioVolume] = useState(0.85)
|
||||
const [audioSpeed, setAudioSpeed] = useState(1)
|
||||
const [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
|
||||
const [mapResourcesLoading, setMapResourcesLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const restoredSessionId = useRef('')
|
||||
@@ -229,6 +252,9 @@ function App() {
|
||||
const notesDragOpenTimeout = useRef<number | null>(null)
|
||||
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
|
||||
const readerRef = useRef<HTMLElement | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const audioQueueRef = useRef<AudioBibleSegment[]>([])
|
||||
const audioQueueIndexRef = useRef(0)
|
||||
const hydratedNoteChapters = useRef(new Set<string>())
|
||||
const workspaceHydrated = useRef(false)
|
||||
|
||||
@@ -603,6 +629,70 @@ function App() {
|
||||
.filter((verse) => verse.book === book)
|
||||
}, [libraryVerses, pageStartId, selectedVerse, verses])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadMapContext() {
|
||||
if (!selectedVerse || pageVerses.length === 0) {
|
||||
setMapContext({ selectedPlaces: [], pagePlaces: [], relatedRoutes: [] })
|
||||
return
|
||||
}
|
||||
|
||||
setMapResourcesLoading(true)
|
||||
try {
|
||||
const nextContext = await getMapContextForVerses(selectedVerse, pageVerses)
|
||||
if (!cancelled) setMapContext(nextContext)
|
||||
} catch (reason) {
|
||||
if (!cancelled) setError(String(reason))
|
||||
} finally {
|
||||
if (!cancelled) setMapResourcesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadMapContext()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [pageVerses, selectedVerse])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadAudioContext() {
|
||||
if (!selectedVerse) {
|
||||
setAudioContext(null)
|
||||
return
|
||||
}
|
||||
|
||||
setAudioResourcesLoading(true)
|
||||
try {
|
||||
const nextContext = await getAudioContextForVerse(
|
||||
activeSession?.baseTranslation ?? defaultTranslationId,
|
||||
selectedVerse,
|
||||
)
|
||||
if (!cancelled) setAudioContext(nextContext)
|
||||
} catch (reason) {
|
||||
if (!cancelled) {
|
||||
setAudioContext(null)
|
||||
setError(String(reason))
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setAudioResourcesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadAudioContext()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeSession?.baseTranslation, selectedVerse])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return
|
||||
audioRef.current.volume = audioVolume
|
||||
audioRef.current.playbackRate = audioSpeed
|
||||
}, [audioSpeed, audioVolume])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession?.baseTranslation || pageVerses.length === 0) return
|
||||
const chapterKeys = [
|
||||
@@ -985,6 +1075,69 @@ function App() {
|
||||
})
|
||||
}
|
||||
|
||||
async function playAudioQueue(segments: AudioBibleSegment[], startIndex = 0) {
|
||||
if (!segments.length) return
|
||||
audioQueueRef.current = segments
|
||||
audioQueueIndexRef.current = startIndex
|
||||
await playQueuedAudioSegment(startIndex)
|
||||
}
|
||||
|
||||
async function playQueuedAudioSegment(index: number) {
|
||||
const segment = audioQueueRef.current[index]
|
||||
const audio = audioRef.current
|
||||
if (!segment || !audio) return
|
||||
|
||||
setAudioResourcesLoading(true)
|
||||
setAudioStatus(`Loading ${segment.reference}`)
|
||||
try {
|
||||
const url = await getAudioObjectUrl(segment)
|
||||
audio.pause()
|
||||
audio.src = url
|
||||
audio.volume = audioVolume
|
||||
audio.playbackRate = audioSpeed
|
||||
await audio.play()
|
||||
setAudioPlayingSegmentId(segment.id)
|
||||
setAudioStatus(`Playing ${segment.reference}`)
|
||||
recordTimeline({
|
||||
type: 'setting',
|
||||
label: `Audio: ${segment.reference}`,
|
||||
reference: segment.reference,
|
||||
detail: segment.audioZipFile,
|
||||
})
|
||||
} catch (reason) {
|
||||
setAudioPlayingSegmentId(null)
|
||||
setAudioStatus(`Audio unavailable: ${String(reason)}`)
|
||||
} finally {
|
||||
setAudioResourcesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
const audio = audioRef.current
|
||||
audioQueueRef.current = []
|
||||
audioQueueIndexRef.current = 0
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
setAudioPlayingSegmentId(null)
|
||||
setAudioStatus('Stopped')
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
const nextIndex = audioQueueIndexRef.current + 1
|
||||
if (nextIndex < audioQueueRef.current.length) {
|
||||
audioQueueIndexRef.current = nextIndex
|
||||
playQueuedAudioSegment(nextIndex).catch((reason) => setAudioStatus(String(reason)))
|
||||
return
|
||||
}
|
||||
|
||||
audioQueueRef.current = []
|
||||
audioQueueIndexRef.current = 0
|
||||
setAudioPlayingSegmentId(null)
|
||||
setAudioStatus('Ready')
|
||||
}
|
||||
|
||||
function renderVerseText(verse: Verse) {
|
||||
if (!activeSession?.strongsEnabled || !verse.strongs.length || strongDisplay === 'off') return verse.text
|
||||
|
||||
@@ -1355,6 +1508,202 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function renderMapSection() {
|
||||
const places = mapContext.pagePlaces
|
||||
const selectedPlaces = mapContext.selectedPlaces
|
||||
const routes = mapContext.relatedRoutes
|
||||
const bounds = mapBounds(places)
|
||||
|
||||
return (
|
||||
<article className="resource-card map-card" aria-label="Contextual maps">
|
||||
<header className="margin-section-header">
|
||||
<button type="button" className="collapse-button">Context Map</button>
|
||||
<span>
|
||||
{places.length
|
||||
? `${places.length} places`
|
||||
: mapResourcesLoading
|
||||
? 'Loading'
|
||||
: 'None'}
|
||||
</span>
|
||||
</header>
|
||||
{places.length ? (
|
||||
<>
|
||||
<div className="context-map" aria-label="Visible-page place map">
|
||||
<div className="map-grid" aria-hidden="true" />
|
||||
{places.map((place) => {
|
||||
const selected = selectedPlaces.some((item) => item.placeId === place.placeId)
|
||||
const point = mapPoint(place, bounds)
|
||||
return (
|
||||
<button
|
||||
key={place.id}
|
||||
type="button"
|
||||
className={selected ? 'map-marker selected' : 'map-marker'}
|
||||
style={{ left: `${point.x}%`, top: `${point.y}%` }}
|
||||
title={`${place.placeName}: ${place.modernLocationName}`}
|
||||
>
|
||||
<span>{place.placeName.slice(0, 1)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="place-list">
|
||||
{places.slice(0, 8).map((place) => (
|
||||
<section key={place.id}>
|
||||
<h3>{place.placeName}</h3>
|
||||
<p>
|
||||
<span>{place.modernLocationName || 'Unresolved location'}</span>
|
||||
<small>{place.confidenceLabel} confidence</small>
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{routes.length ? (
|
||||
<div className="route-list">
|
||||
<strong>Related routes</strong>
|
||||
{routes.map((route) => (
|
||||
<span key={route.id}>
|
||||
{route.title}
|
||||
<small>{route.mapNumber}</small>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="map-attribution">
|
||||
Places: OpenBible.info. Routes: UBS Project MARBLE. Coordinates are contextual and may be approximate.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="resource-disabled">
|
||||
{mapResourcesLoading ? 'Loading contextual map data...' : 'No mapped places matched this page.'}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAudioSection() {
|
||||
const selectedSegment = audioContext?.selectedSegment ?? null
|
||||
const chapterSegments = audioContext?.chapterPlaylist?.segments ?? []
|
||||
const selectedIndex = selectedSegment
|
||||
? Math.max(0, chapterSegments.findIndex((segment) => segment.id === selectedSegment.id))
|
||||
: 0
|
||||
|
||||
return (
|
||||
<article className="resource-card audio-card" aria-label="Audio Bible">
|
||||
<header className="margin-section-header">
|
||||
<button type="button" className="collapse-button">Audio Bible</button>
|
||||
<span>
|
||||
{audioResourcesLoading
|
||||
? 'Loading'
|
||||
: audioContext
|
||||
? audioContext.shortTitle
|
||||
: 'None'}
|
||||
</span>
|
||||
</header>
|
||||
{audioContext && selectedSegment ? (
|
||||
<>
|
||||
<div className="audio-controls">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => playAudioQueue([selectedSegment]).catch((reason) => setAudioStatus(String(reason)))}
|
||||
>
|
||||
Play verse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
playAudioQueue(chapterSegments, 0).catch((reason) => setAudioStatus(String(reason)))
|
||||
}
|
||||
>
|
||||
Play chapter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
playAudioQueue(chapterSegments, selectedIndex).catch((reason) => setAudioStatus(String(reason)))
|
||||
}
|
||||
>
|
||||
From verse
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={stopAudio}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
<div className="audio-sliders">
|
||||
<label>
|
||||
Volume
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={audioVolume}
|
||||
onChange={(event) => setAudioVolume(Number(event.target.value))}
|
||||
/>
|
||||
<span>{Math.round(audioVolume * 100)}%</span>
|
||||
</label>
|
||||
<label>
|
||||
Speed
|
||||
<select
|
||||
value={audioSpeed}
|
||||
onChange={(event) => setAudioSpeed(Number(event.target.value))}
|
||||
>
|
||||
<option value="0.75">0.75x</option>
|
||||
<option value="1">1x</option>
|
||||
<option value="1.25">1.25x</option>
|
||||
<option value="1.5">1.5x</option>
|
||||
<option value="2">2x</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="audio-status">
|
||||
<strong>{audioPlayingSegmentId ? 'Playing' : 'Status'}</strong>
|
||||
<span>{audioStatus}</span>
|
||||
</p>
|
||||
<p className="map-attribution">{audioContext.attribution}</p>
|
||||
<audio ref={audioRef} preload="none" onEnded={handleAudioEnded} />
|
||||
</>
|
||||
) : (
|
||||
<p className="resource-disabled">
|
||||
{audioResourcesLoading
|
||||
? 'Loading audio metadata...'
|
||||
: 'No audio package is attached to this translation yet.'}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function mapBounds(places: MapContext['pagePlaces']) {
|
||||
const points = places.filter((place) => place.lon != null && place.lat != null)
|
||||
if (points.length === 0) {
|
||||
return { minLon: 0, maxLon: 1, minLat: 0, maxLat: 1 }
|
||||
}
|
||||
|
||||
const minLon = Math.min(...points.map((place) => place.lon ?? 0))
|
||||
const maxLon = Math.max(...points.map((place) => place.lon ?? 0))
|
||||
const minLat = Math.min(...points.map((place) => place.lat ?? 0))
|
||||
const maxLat = Math.max(...points.map((place) => place.lat ?? 0))
|
||||
const lonPad = Math.max(0.2, (maxLon - minLon) * 0.18)
|
||||
const latPad = Math.max(0.2, (maxLat - minLat) * 0.18)
|
||||
|
||||
return {
|
||||
minLon: minLon - lonPad,
|
||||
maxLon: maxLon + lonPad,
|
||||
minLat: minLat - latPad,
|
||||
maxLat: maxLat + latPad,
|
||||
}
|
||||
}
|
||||
|
||||
function mapPoint(place: MapContext['pagePlaces'][number], bounds: ReturnType<typeof mapBounds>) {
|
||||
const lonRange = bounds.maxLon - bounds.minLon || 1
|
||||
const latRange = bounds.maxLat - bounds.minLat || 1
|
||||
return {
|
||||
x: Math.max(5, Math.min(95, (((place.lon ?? bounds.minLon) - bounds.minLon) / lonRange) * 100)),
|
||||
y: Math.max(5, Math.min(95, 100 - (((place.lat ?? bounds.minLat) - bounds.minLat) / latRange) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
function renderBiblePage(label = activeTranslation?.abbreviation ?? 'Bible') {
|
||||
if (!selectedVerse) return null
|
||||
return (
|
||||
@@ -1476,6 +1825,8 @@ function App() {
|
||||
)}
|
||||
<div className="margin-resource-grid">
|
||||
{renderStudyNotesSection()}
|
||||
{renderAudioSection()}
|
||||
{renderMapSection()}
|
||||
{renderCommentarySection()}
|
||||
{renderDictionarySection()}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import JSZip from 'jszip'
|
||||
import { buildVerseSearchIndex } from './searchIndex'
|
||||
import type {
|
||||
AudioBibleContext,
|
||||
AudioBibleSegment,
|
||||
CommentaryEntry,
|
||||
BiblePlace,
|
||||
BibleRoute,
|
||||
DictionaryEntry,
|
||||
Footnote,
|
||||
LibraryState,
|
||||
MapContext,
|
||||
StrongLink,
|
||||
Tag,
|
||||
TranslationResource,
|
||||
@@ -62,6 +68,21 @@ type ImportedCatalog = {
|
||||
}>
|
||||
}
|
||||
|
||||
type ImportedAudioChapterPlaylist = {
|
||||
id: string
|
||||
resource_id: string
|
||||
translation_id: string
|
||||
book_id: string
|
||||
book: string
|
||||
chapter: number
|
||||
reference: string
|
||||
audio_zip_file: string
|
||||
audio_zip_url: string
|
||||
segment_ids: string[]
|
||||
audio_entry_paths: string[]
|
||||
segment_count: number
|
||||
}
|
||||
|
||||
type ImportedStrongEntry = {
|
||||
strongs_number: string
|
||||
language: 'hebrew' | 'greek'
|
||||
@@ -102,6 +123,36 @@ type ImportedDictionaryEntry = {
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
type ImportedVersePlace = {
|
||||
verse_id: string
|
||||
readable: string
|
||||
place_id: string
|
||||
place_name: string
|
||||
place_types: string[]
|
||||
modern_location_id: string
|
||||
modern_location_name: string
|
||||
lon: number | null
|
||||
lat: number | null
|
||||
confidence: number | null
|
||||
confidence_label: string
|
||||
}
|
||||
|
||||
type ImportedBibleRoute = {
|
||||
id: string
|
||||
route_number: number
|
||||
title: string
|
||||
section_number: string
|
||||
section_title: string
|
||||
map_number: string
|
||||
geojson_file: string
|
||||
bounds: {
|
||||
min_lon: number
|
||||
min_lat: number
|
||||
max_lon: number
|
||||
max_lat: number
|
||||
} | null
|
||||
}
|
||||
|
||||
type BrowserLexiconEntry = {
|
||||
lemma: string
|
||||
transliteration: string
|
||||
@@ -123,7 +174,12 @@ const browserChapterStudyNotesCache = new Map<string, Promise<Map<string, Footno
|
||||
const browserSearchIndexCache = new Map<string, Promise<ReturnType<typeof buildVerseSearchIndex>>>()
|
||||
let browserCommentaryCache: Promise<Map<string, CommentaryEntry[]>> | null = null
|
||||
let browserDictionaryCache: Promise<Map<string, DictionaryEntry>> | null = null
|
||||
let browserVersePlacesCache: Promise<Map<string, BiblePlace[]>> | null = null
|
||||
let browserRoutesCache: Promise<BibleRoute[]> | null = null
|
||||
let browserAudioPlaylistCache: Promise<Map<string, ImportedAudioChapterPlaylist>> | null = null
|
||||
const browserVerseSourceIds = new Map<number, string>()
|
||||
const browserAudioZipCache = new Map<string, Promise<JSZip>>()
|
||||
const browserAudioObjectUrlCache = new Map<string, Promise<string>>()
|
||||
|
||||
const fallbackBrowserVerses: Verse[] = [
|
||||
{
|
||||
@@ -416,6 +472,100 @@ export async function getDictionaryEntriesForTerms(terms: string[]): Promise<Dic
|
||||
return entries
|
||||
}
|
||||
|
||||
export async function getMapContextForVerses(
|
||||
selectedVerse: Verse,
|
||||
pageVerses: Verse[],
|
||||
): Promise<MapContext> {
|
||||
if (inTauri()) {
|
||||
return { selectedPlaces: [], pagePlaces: [], relatedRoutes: [] }
|
||||
}
|
||||
|
||||
const versePlaces = await loadBrowserVersePlaces()
|
||||
const selectedVerseId = browserVerseKey(selectedVerse)
|
||||
const pageVerseIds = new Set(pageVerses.map((verse) => browserVerseKey(verse)).filter(Boolean))
|
||||
const selectedPlaces = uniquePlaces(versePlaces.get(selectedVerseId) ?? [])
|
||||
const pagePlaces = uniquePlaces(
|
||||
[...pageVerseIds].flatMap((verseId) => versePlaces.get(verseId) ?? []),
|
||||
)
|
||||
const relatedRoutes = await relatedRoutesForPlaces(pagePlaces)
|
||||
|
||||
return {
|
||||
selectedPlaces,
|
||||
pagePlaces,
|
||||
relatedRoutes,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAudioContextForVerse(
|
||||
translationId: string,
|
||||
verse: Verse,
|
||||
): Promise<AudioBibleContext | null> {
|
||||
if (inTauri()) return null
|
||||
|
||||
const catalog = await loadBrowserCatalog()
|
||||
const resource = catalog.resources.find(
|
||||
(item) =>
|
||||
item.resource_type === 'audio_bible' &&
|
||||
item.id === 'kjv-audio-eliranwong' &&
|
||||
translationId === defaultTranslationId,
|
||||
)
|
||||
if (!resource) return null
|
||||
|
||||
const verseId = browserVerseKey(verse)
|
||||
const [bookId, chapter] = verseId.split('.')
|
||||
if (!bookId || !chapter) return null
|
||||
|
||||
const playlists = await loadBrowserAudioPlaylists()
|
||||
const playlist = playlists.get(`${bookId}.${chapter}`) ?? null
|
||||
const selectedIndex = playlist?.segment_ids.findIndex((id) => id.endsWith(`:${verseId}`)) ?? -1
|
||||
const licenseName = resource.license?.name ?? ''
|
||||
const attribution = resource.license?.attribution ?? ''
|
||||
|
||||
return {
|
||||
resourceId: resource.id,
|
||||
title: resource.title,
|
||||
shortTitle: resource.short_title ?? resource.title,
|
||||
licenseName,
|
||||
attribution,
|
||||
selectedSegment:
|
||||
playlist && selectedIndex >= 0
|
||||
? mapAudioSegment(playlist, selectedIndex, licenseName, attribution)
|
||||
: null,
|
||||
chapterPlaylist: playlist
|
||||
? {
|
||||
id: playlist.id,
|
||||
resourceId: playlist.resource_id,
|
||||
translationId: playlist.translation_id,
|
||||
book: playlist.book,
|
||||
chapter: playlist.chapter,
|
||||
reference: playlist.reference,
|
||||
segments: playlist.segment_ids.map((_, index) =>
|
||||
mapAudioSegment(playlist, index, licenseName, attribution),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAudioObjectUrl(segment: AudioBibleSegment): Promise<string> {
|
||||
const cacheKey = `${segment.audioZipUrl}#${segment.audioEntryPath}`
|
||||
if (!browserAudioObjectUrlCache.has(cacheKey)) {
|
||||
browserAudioObjectUrlCache.set(
|
||||
cacheKey,
|
||||
loadAudioZip(segment.audioZipUrl).then(async (zip) => {
|
||||
const file = zip.file(segment.audioEntryPath)
|
||||
if (!file) {
|
||||
throw new Error(`Audio entry not found in ${segment.audioZipFile}: ${segment.audioEntryPath}`)
|
||||
}
|
||||
const blob = await file.async('blob')
|
||||
return URL.createObjectURL(blob)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return browserAudioObjectUrlCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
async function loadBrowserCatalog(): Promise<ImportedCatalog> {
|
||||
browserCatalogCache ??= fetchJson<ImportedCatalog>(`${libreBibleDevRoot}/catalog.json`)
|
||||
return browserCatalogCache
|
||||
@@ -499,6 +649,155 @@ async function loadBrowserDictionary(): Promise<Map<string, DictionaryEntry>> {
|
||||
return browserDictionaryCache
|
||||
}
|
||||
|
||||
async function loadBrowserVersePlaces(): Promise<Map<string, BiblePlace[]>> {
|
||||
browserVersePlacesCache ??= loadLibreBibleDevVersePlaces()
|
||||
return browserVersePlacesCache
|
||||
}
|
||||
|
||||
async function loadLibreBibleDevVersePlaces(): Promise<Map<string, BiblePlace[]>> {
|
||||
const importedEntries = await fetchJsonl<ImportedVersePlace>(
|
||||
`${libreBibleDevRoot}/biblical-places-openbible/verse-places.jsonl`,
|
||||
)
|
||||
const places = new Map<string, BiblePlace[]>()
|
||||
|
||||
for (const entry of importedEntries) {
|
||||
const mapped: BiblePlace = {
|
||||
id: `${entry.verse_id}:${entry.place_id}`,
|
||||
verseId: entry.verse_id,
|
||||
readable: entry.readable,
|
||||
placeId: entry.place_id,
|
||||
placeName: entry.place_name,
|
||||
placeTypes: entry.place_types,
|
||||
modernLocationId: entry.modern_location_id,
|
||||
modernLocationName: entry.modern_location_name,
|
||||
lon: entry.lon,
|
||||
lat: entry.lat,
|
||||
confidence: entry.confidence,
|
||||
confidenceLabel: entry.confidence_label,
|
||||
}
|
||||
const current = places.get(mapped.verseId) ?? []
|
||||
current.push(mapped)
|
||||
places.set(mapped.verseId, current)
|
||||
}
|
||||
|
||||
return places
|
||||
}
|
||||
|
||||
async function loadBrowserRoutes(): Promise<BibleRoute[]> {
|
||||
browserRoutesCache ??= loadLibreBibleDevRoutes()
|
||||
return browserRoutesCache
|
||||
}
|
||||
|
||||
async function loadBrowserAudioPlaylists(): Promise<Map<string, ImportedAudioChapterPlaylist>> {
|
||||
browserAudioPlaylistCache ??= loadLibreBibleDevAudioPlaylists()
|
||||
return browserAudioPlaylistCache
|
||||
}
|
||||
|
||||
async function loadLibreBibleDevAudioPlaylists(): Promise<Map<string, ImportedAudioChapterPlaylist>> {
|
||||
const importedEntries = await fetchJsonl<ImportedAudioChapterPlaylist>(
|
||||
`${libreBibleDevRoot}/kjv-audio-eliranwong/chapter-playlists.jsonl`,
|
||||
)
|
||||
return new Map(importedEntries.map((entry) => [`${entry.book_id}.${entry.chapter}`, entry]))
|
||||
}
|
||||
|
||||
function mapAudioSegment(
|
||||
playlist: ImportedAudioChapterPlaylist,
|
||||
index: number,
|
||||
licenseName: string,
|
||||
attribution: string,
|
||||
): AudioBibleSegment {
|
||||
const id = playlist.segment_ids[index]
|
||||
const verseId = id?.split(':').at(-1) ?? ''
|
||||
const verseNumber = Number(verseId.split('.').at(-1) ?? index + 1)
|
||||
|
||||
return {
|
||||
id,
|
||||
resourceId: playlist.resource_id,
|
||||
translationId: playlist.translation_id,
|
||||
verseId,
|
||||
reference: `${playlist.book} ${playlist.chapter}:${verseNumber}`,
|
||||
audioZipUrl: playlist.audio_zip_url,
|
||||
audioZipFile: playlist.audio_zip_file,
|
||||
audioEntryPath: playlist.audio_entry_paths[index],
|
||||
attribution,
|
||||
licenseName,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAudioZip(url: string): Promise<JSZip> {
|
||||
if (!browserAudioZipCache.has(url)) {
|
||||
browserAudioZipCache.set(
|
||||
url,
|
||||
fetch(url)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load audio package: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.arrayBuffer()
|
||||
})
|
||||
.then((data) => JSZip.loadAsync(data)),
|
||||
)
|
||||
}
|
||||
|
||||
return browserAudioZipCache.get(url)!
|
||||
}
|
||||
|
||||
async function loadLibreBibleDevRoutes(): Promise<BibleRoute[]> {
|
||||
const importedEntries = await fetchJsonl<ImportedBibleRoute>(
|
||||
`${libreBibleDevRoot}/bible-routes-ubs-marble/routes.jsonl`,
|
||||
)
|
||||
return importedEntries.map((entry) => ({
|
||||
id: entry.id,
|
||||
routeNumber: entry.route_number,
|
||||
title: entry.title,
|
||||
sectionNumber: entry.section_number,
|
||||
sectionTitle: entry.section_title,
|
||||
mapNumber: entry.map_number,
|
||||
geojsonFile: entry.geojson_file,
|
||||
bounds: entry.bounds
|
||||
? {
|
||||
minLon: entry.bounds.min_lon,
|
||||
minLat: entry.bounds.min_lat,
|
||||
maxLon: entry.bounds.max_lon,
|
||||
maxLat: entry.bounds.max_lat,
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
|
||||
async function relatedRoutesForPlaces(places: BiblePlace[]): Promise<BibleRoute[]> {
|
||||
if (places.length === 0) return []
|
||||
const routes = await loadBrowserRoutes()
|
||||
const placeTerms = new Set(
|
||||
places.flatMap((place) => [place.placeName, place.modernLocationName])
|
||||
.map(normalizeTerm)
|
||||
.filter((term) => term.length >= 4),
|
||||
)
|
||||
const matches = routes
|
||||
.map((route) => {
|
||||
const haystack = normalizeTerm(`${route.title} ${route.sectionTitle}`)
|
||||
const score = [...placeTerms].filter((term) => haystack.includes(term)).length
|
||||
return { route, score }
|
||||
})
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((left, right) => right.score - left.score || left.route.routeNumber - right.route.routeNumber)
|
||||
|
||||
return matches.slice(0, 6).map((entry) => entry.route)
|
||||
}
|
||||
|
||||
function uniquePlaces(places: BiblePlace[]) {
|
||||
const unique = new Map<string, BiblePlace>()
|
||||
for (const place of places) {
|
||||
if (place.lon == null || place.lat == null) continue
|
||||
unique.set(place.placeId, place)
|
||||
}
|
||||
return [...unique.values()].sort(
|
||||
(left, right) =>
|
||||
(right.confidence ?? 0) - (left.confidence ?? 0) ||
|
||||
left.placeName.localeCompare(right.placeName),
|
||||
)
|
||||
}
|
||||
|
||||
async function loadLibreBibleDevDictionary(): Promise<Map<string, DictionaryEntry>> {
|
||||
const importedEntries = await fetchJsonl<ImportedDictionaryEntry>(
|
||||
`${libreBibleDevRoot}/easton-smith-dictionaries-neuu/entries.jsonl`,
|
||||
|
||||
@@ -73,6 +73,76 @@ export type DictionaryEntry = {
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
export type BiblePlace = {
|
||||
id: string
|
||||
verseId: string
|
||||
readable: string
|
||||
placeId: string
|
||||
placeName: string
|
||||
placeTypes: string[]
|
||||
modernLocationId: string
|
||||
modernLocationName: string
|
||||
lon: number | null
|
||||
lat: number | null
|
||||
confidence: number | null
|
||||
confidenceLabel: string
|
||||
}
|
||||
|
||||
export type BibleRoute = {
|
||||
id: string
|
||||
routeNumber: number
|
||||
title: string
|
||||
sectionNumber: string
|
||||
sectionTitle: string
|
||||
mapNumber: string
|
||||
geojsonFile: string
|
||||
bounds: {
|
||||
minLon: number
|
||||
minLat: number
|
||||
maxLon: number
|
||||
maxLat: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export type MapContext = {
|
||||
selectedPlaces: BiblePlace[]
|
||||
pagePlaces: BiblePlace[]
|
||||
relatedRoutes: BibleRoute[]
|
||||
}
|
||||
|
||||
export type AudioBibleSegment = {
|
||||
id: string
|
||||
resourceId: string
|
||||
translationId: string
|
||||
verseId: string
|
||||
reference: string
|
||||
audioZipUrl: string
|
||||
audioZipFile: string
|
||||
audioEntryPath: string
|
||||
attribution: string
|
||||
licenseName: string
|
||||
}
|
||||
|
||||
export type AudioChapterPlaylist = {
|
||||
id: string
|
||||
resourceId: string
|
||||
translationId: string
|
||||
book: string
|
||||
chapter: number
|
||||
reference: string
|
||||
segments: AudioBibleSegment[]
|
||||
}
|
||||
|
||||
export type AudioBibleContext = {
|
||||
resourceId: string
|
||||
title: string
|
||||
shortTitle: string
|
||||
licenseName: string
|
||||
attribution: string
|
||||
selectedSegment: AudioBibleSegment | null
|
||||
chapterPlaylist: AudioChapterPlaylist | null
|
||||
}
|
||||
|
||||
export type LibraryState = {
|
||||
verses: Verse[]
|
||||
tags: Tag[]
|
||||
|
||||
@@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
allowedHosts: ['desktop'],
|
||||
fs: {
|
||||
allow: ['..', 'W:/libre-bible-data'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user