Add map and KJV audio playback UI

This commit is contained in:
2026-07-15 11:54:52 -05:00
parent a1a902c938
commit 02ffa1dfc4
8 changed files with 1080 additions and 77 deletions
+16
View File
@@ -1,5 +1,21 @@
# Changelog # 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 ## 0.1.26 - 2026-07-12
- Started Phase 5 storage/search foundation with a reusable verse search index for browser/local preview queries. - Started Phase 5 storage/search foundation with a reusable verse search index for browser/local preview queries.
+102 -71
View File
@@ -1,14 +1,15 @@
{ {
"name": "libre-study", "name": "libre-study",
"version": "0.1.26", "version": "0.1.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "libre-study", "name": "libre-study",
"version": "0.1.26", "version": "0.1.29",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"jszip": "^3.10.1",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7"
}, },
@@ -213,9 +214,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -233,9 +231,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -253,9 +248,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -273,9 +265,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -293,9 +282,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -313,9 +299,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -333,9 +316,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -353,9 +333,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -526,9 +503,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -546,9 +520,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -566,9 +537,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -586,9 +554,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -606,9 +571,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -626,9 +588,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -814,9 +773,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -834,9 +790,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -854,9 +807,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -874,9 +824,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -894,9 +841,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"optional": true, "optional": true,
"os": [ "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "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": "^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": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1217,9 +1206,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1241,9 +1227,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1265,9 +1248,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1289,9 +1269,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1464,6 +1447,12 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/react": {
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -1485,6 +1474,21 @@
"react": "^19.2.7" "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": { "node_modules/rolldown": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -1519,12 +1523,24 @@
"@rolldown/binding-win32-x64-msvc": "1.1.5" "@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": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1535,6 +1551,15 @@
"node": ">=0.10.0" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1581,6 +1606,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "8.1.4", "version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
+4 -3
View File
@@ -1,11 +1,11 @@
{ {
"name": "libre-study", "name": "libre-study",
"private": true, "private": true,
"version": "0.1.26", "version": "0.1.29",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1", "dev": "vite --host 0.0.0.0",
"dev:web": "vite --host 127.0.0.1", "dev:web": "vite --host 0.0.0.0",
"dev:tauri": "tauri dev", "dev:tauri": "tauri dev",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:web:hosted": "tsc -b && vite build --base=/study/ --outDir dist-hosted", "build:web:hosted": "tsc -b && vite build --base=/study/ --outDir dist-hosted",
@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"jszip": "^3.10.1",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7"
}, },
+236 -2
View File
@@ -741,7 +741,9 @@ textarea:focus-visible {
} }
.commentary-card, .commentary-card,
.dictionary-card { .dictionary-card,
.map-card,
.audio-card {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -787,6 +789,211 @@ textarea:focus-visible {
text-transform: uppercase; 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 { .notes-workspace {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1381,11 +1588,28 @@ textarea:focus-visible {
.theme-dark .original-language-verse, .theme-dark .original-language-verse,
.theme-dark .english-strong-line, .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; border-color: #3f493f;
background: #20251f; 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 { .theme-dark .margin-section-header {
background: #2b332a; background: #2b332a;
} }
@@ -1420,6 +1644,14 @@ textarea:focus-visible {
.theme-dark .timeline-canvas small, .theme-dark .timeline-canvas small,
.theme-dark .note-helper small, .theme-dark .note-helper small,
.theme-dark .note-placeholder 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 .session-facts dt,
.theme-dark .about-list dt, .theme-dark .about-list dt,
.theme-dark .modal-section label, .theme-dark .modal-section label,
@@ -1431,6 +1663,8 @@ textarea:focus-visible {
.theme-dark .strong-focus p, .theme-dark .strong-focus p,
.theme-dark .commentary-list h3, .theme-dark .commentary-list h3,
.theme-dark .dictionary-list h3, .theme-dark .dictionary-list h3,
.theme-dark .place-list h3,
.theme-dark .route-list span,
.theme-dark .commentary-list p, .theme-dark .commentary-list p,
.theme-dark .dictionary-list p, .theme-dark .dictionary-list p,
.theme-dark .linked-line-text, .theme-dark .linked-line-text,
+352 -1
View File
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
addTagToVerse, addTagToVerse,
getCommentaryForVerse, getCommentaryForVerse,
getAudioContextForVerse,
getAudioObjectUrl,
getDictionaryEntriesForTerms, getDictionaryEntriesForTerms,
getLibraryState, getLibraryState,
getMapContextForVerses,
getTranslationNotesForVerses, getTranslationNotesForVerses,
getTranslations, getTranslations,
searchVerses, searchVerses,
@@ -18,7 +21,15 @@ import {
timelineLimit, timelineLimit,
} from './workspace' } from './workspace'
import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react' 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 { import type {
StrongDisplayMode, StrongDisplayMode,
StudySession, StudySession,
@@ -221,7 +232,19 @@ function App() {
const [splitPercent, setSplitPercent] = useState(50) const [splitPercent, setSplitPercent] = useState(50)
const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([]) const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([])
const [dictionaryEntries, setDictionaryEntries] = useState<DictionaryEntry[]>([]) 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 [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
const [mapResourcesLoading, setMapResourcesLoading] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const restoredSessionId = useRef('') const restoredSessionId = useRef('')
@@ -229,6 +252,9 @@ function App() {
const notesDragOpenTimeout = useRef<number | null>(null) const notesDragOpenTimeout = useRef<number | null>(null)
const remoteWorkspaceSaveTimeout = useRef<number | null>(null) const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
const readerRef = useRef<HTMLElement | 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 hydratedNoteChapters = useRef(new Set<string>())
const workspaceHydrated = useRef(false) const workspaceHydrated = useRef(false)
@@ -603,6 +629,70 @@ function App() {
.filter((verse) => verse.book === book) .filter((verse) => verse.book === book)
}, [libraryVerses, pageStartId, selectedVerse, verses]) }, [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(() => { useEffect(() => {
if (!activeSession?.baseTranslation || pageVerses.length === 0) return if (!activeSession?.baseTranslation || pageVerses.length === 0) return
const chapterKeys = [ 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) { function renderVerseText(verse: Verse) {
if (!activeSession?.strongsEnabled || !verse.strongs.length || strongDisplay === 'off') return verse.text 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') { function renderBiblePage(label = activeTranslation?.abbreviation ?? 'Bible') {
if (!selectedVerse) return null if (!selectedVerse) return null
return ( return (
@@ -1476,6 +1825,8 @@ function App() {
)} )}
<div className="margin-resource-grid"> <div className="margin-resource-grid">
{renderStudyNotesSection()} {renderStudyNotesSection()}
{renderAudioSection()}
{renderMapSection()}
{renderCommentarySection()} {renderCommentarySection()}
{renderDictionarySection()} {renderDictionarySection()}
</div> </div>
+299
View File
@@ -1,10 +1,16 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import JSZip from 'jszip'
import { buildVerseSearchIndex } from './searchIndex' import { buildVerseSearchIndex } from './searchIndex'
import type { import type {
AudioBibleContext,
AudioBibleSegment,
CommentaryEntry, CommentaryEntry,
BiblePlace,
BibleRoute,
DictionaryEntry, DictionaryEntry,
Footnote, Footnote,
LibraryState, LibraryState,
MapContext,
StrongLink, StrongLink,
Tag, Tag,
TranslationResource, 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 = { type ImportedStrongEntry = {
strongs_number: string strongs_number: string
language: 'hebrew' | 'greek' language: 'hebrew' | 'greek'
@@ -102,6 +123,36 @@ type ImportedDictionaryEntry = {
sources: string[] 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 = { type BrowserLexiconEntry = {
lemma: string lemma: string
transliteration: 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>>>() const browserSearchIndexCache = new Map<string, Promise<ReturnType<typeof buildVerseSearchIndex>>>()
let browserCommentaryCache: Promise<Map<string, CommentaryEntry[]>> | null = null let browserCommentaryCache: Promise<Map<string, CommentaryEntry[]>> | null = null
let browserDictionaryCache: Promise<Map<string, DictionaryEntry>> | 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 browserVerseSourceIds = new Map<number, string>()
const browserAudioZipCache = new Map<string, Promise<JSZip>>()
const browserAudioObjectUrlCache = new Map<string, Promise<string>>()
const fallbackBrowserVerses: Verse[] = [ const fallbackBrowserVerses: Verse[] = [
{ {
@@ -416,6 +472,100 @@ export async function getDictionaryEntriesForTerms(terms: string[]): Promise<Dic
return entries 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> { async function loadBrowserCatalog(): Promise<ImportedCatalog> {
browserCatalogCache ??= fetchJson<ImportedCatalog>(`${libreBibleDevRoot}/catalog.json`) browserCatalogCache ??= fetchJson<ImportedCatalog>(`${libreBibleDevRoot}/catalog.json`)
return browserCatalogCache return browserCatalogCache
@@ -499,6 +649,155 @@ async function loadBrowserDictionary(): Promise<Map<string, DictionaryEntry>> {
return browserDictionaryCache 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>> { async function loadLibreBibleDevDictionary(): Promise<Map<string, DictionaryEntry>> {
const importedEntries = await fetchJsonl<ImportedDictionaryEntry>( const importedEntries = await fetchJsonl<ImportedDictionaryEntry>(
`${libreBibleDevRoot}/easton-smith-dictionaries-neuu/entries.jsonl`, `${libreBibleDevRoot}/easton-smith-dictionaries-neuu/entries.jsonl`,
+70
View File
@@ -73,6 +73,76 @@ export type DictionaryEntry = {
sources: string[] 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 = { export type LibraryState = {
verses: Verse[] verses: Verse[]
tags: Tag[] tags: Tag[]
+1
View File
@@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
allowedHosts: ['desktop'],
fs: { fs: {
allow: ['..', 'W:/libre-bible-data'], allow: ['..', 'W:/libre-bible-data'],
}, },