tangled
alpha
login
or
join now
kokirigla.de
/
nara
0
fork
atom
online Minecraft written book viewer
0
fork
atom
overview
issues
pulls
pipelines
feat(serve): yeet categories
kokirigla.de
3 weeks ago
ed63c51e
d16ea67e
verified
This commit was signed with the committer's
known signature
.
kokirigla.de
SSH Key Fingerprint:
SHA256:BlSEtD3ZoKT3iKveofI8gba+lZ9CEolKRM1Pzy3pAwg=
+102
-416
10 changed files
expand all
collapse all
unified
split
Cargo.lock
Cargo.toml
src
cli.rs
library.rs
main.rs
web
api.rs
assets
stylesheet.css
pages.rs
templates
book.html
index.html
+7
-7
Cargo.lock
reviewed
···
1011
1011
1012
1012
[[package]]
1013
1013
name = "nara"
1014
1014
-
version = "0.1.0"
1014
1014
+
version = "0.2.0"
1015
1015
dependencies = [
1016
1016
"ahash",
1017
1017
"anyhow",
···
1041
1041
1042
1042
[[package]]
1043
1043
name = "nara_core"
1044
1044
-
version = "0.1.0"
1044
1044
+
version = "0.2.0"
1045
1045
dependencies = [
1046
1046
"hex",
1047
1047
"insta",
···
1055
1055
1056
1056
[[package]]
1057
1057
name = "nara_io"
1058
1058
-
version = "0.1.0"
1058
1058
+
version = "0.2.0"
1059
1059
dependencies = [
1060
1060
"crab_nbt",
1061
1061
"flate2",
···
1066
1066
1067
1067
[[package]]
1068
1068
name = "nara_mcr"
1069
1069
-
version = "0.1.0"
1069
1069
+
version = "0.2.0"
1070
1070
dependencies = [
1071
1071
"crab_nbt",
1072
1072
"flate2",
···
1076
1076
1077
1077
[[package]]
1078
1078
name = "nara_slurper_1_12_core"
1079
1079
-
version = "0.1.0"
1079
1079
+
version = "0.2.0"
1080
1080
dependencies = [
1081
1081
"nara_core",
1082
1082
"nara_mcr",
···
1085
1085
1086
1086
[[package]]
1087
1087
name = "nara_slurper_1_12_infinity"
1088
1088
-
version = "0.1.0"
1088
1088
+
version = "0.2.0"
1089
1089
dependencies = [
1090
1090
"nara_core",
1091
1091
"nara_io",
···
1095
1095
1096
1096
[[package]]
1097
1097
name = "nara_slurper_1_12_world"
1098
1098
-
version = "0.1.0"
1098
1098
+
version = "0.2.0"
1099
1099
dependencies = [
1100
1100
"crab_nbt",
1101
1101
"insta",
+1
-1
Cargo.toml
reviewed
···
3
3
members = ["nara_io", "nara_slurper_1_12_core", "nara_slurper_1_12_infinity", "nara_slurper_1_12_world", "nara_core", "nara_mcr"]
4
4
5
5
[workspace.package]
6
6
-
version = "0.1.0"
6
6
+
version = "0.2.0"
7
7
edition = "2024"
8
8
9
9
[workspace.dependencies]
-3
src/cli.rs
reviewed
···
70
70
71
71
#[derive(Args, Debug)]
72
72
pub struct ServeArgs {
73
73
-
/// Whether to warn about duplicate books being found during initial indexing.
74
74
-
#[arg(short = 'd', long, default_value_t = true, action = ArgAction::Set)]
75
75
-
pub warn_duplicates: bool,
76
73
/// Whether to warn about empty/whitespace books being skipped during indexing.
77
74
#[arg(short = 'e', long, default_value_t = true, action = ArgAction::Set)]
78
75
pub warn_empty: bool,
+2
-79
src/library.rs
reviewed
···
8
8
use std::{cell::RefCell, num::NonZeroUsize};
9
9
use strsim::jaro_winkler;
10
10
11
11
+
11
12
pub type BookId = usize;
12
13
13
14
/// In-memory index of all books with lookup and fuzzy search helpers.
···
17
18
18
19
by_hash: AHashMap<BookHash, BookId>,
19
20
source_by_hash: AHashMap<BookHash, BookSource>,
20
20
-
by_category: AHashMap<SmolStr, Vec<BookId>>,
21
21
by_author_lc: AHashMap<SmolStr, Vec<BookId>>,
22
22
-
23
23
-
// per-book data, parallel to `books`
24
24
-
category_by_id: Vec<SmolStr>,
25
22
26
23
// normalized blobs for scoring (same index as `books`)
27
24
norm_title: Vec<String>,
···
38
35
title_threshold: f64,
39
36
40
37
cache_books_by_author: RefCell<LruCache<SmolStr, Vec<BookId>>>,
41
41
-
cache_books_in_category: RefCell<LruCache<SmolStr, Vec<BookId>>>,
42
38
cache_fuzzy_title: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
43
39
cache_fuzzy_author: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
44
40
cache_fuzzy_contents: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
45
41
cache_fuzzy_all: RefCell<LruCache<FuzzyKey, Vec<(BookId, f64)>>>,
46
42
47
47
-
duplicate_books_filtered: u16,
48
43
empty_books_filtered: u16,
49
44
}
50
45
···
55
50
limit: usize,
56
51
}
57
52
58
58
-
/// Assigns a category to a book. Currently always returns "Uncategorized".
59
59
-
fn categorize(_book: &Book) -> SmolStr {
60
60
-
SmolStr::new_static("Uncategorized")
61
61
-
}
62
62
-
63
53
impl Library {
64
54
pub fn new(
65
55
content_threshold: f64,
···
70
60
books: Vec::new(),
71
61
by_hash: AHashMap::new(),
72
62
source_by_hash: AHashMap::new(),
73
73
-
by_category: AHashMap::new(),
74
63
by_author_lc: AHashMap::new(),
75
75
-
category_by_id: Vec::new(),
76
64
norm_title: Vec::new(),
77
65
norm_author: Vec::new(),
78
66
norm_contents: Vec::new(),
···
83
71
title_threshold,
84
72
author_threshold,
85
73
cache_books_by_author: RefCell::new(new_lru(CACHE_BY_AUTHOR_CAP)),
86
86
-
cache_books_in_category: RefCell::new(new_lru(CACHE_BY_CATEGORY_CAP)),
87
74
cache_fuzzy_title: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
88
75
cache_fuzzy_author: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
89
76
cache_fuzzy_contents: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
90
77
cache_fuzzy_all: RefCell::new(new_lru(CACHE_FUZZY_CAP)),
91
91
-
duplicate_books_filtered: 0,
92
78
empty_books_filtered: 0,
93
79
}
94
80
}
···
97
83
pub fn add_book(
98
84
&mut self,
99
85
book: Book,
100
100
-
warn_duplicates: bool,
101
86
warn_empty: bool,
102
87
filter_empty_books: bool,
103
88
) {
···
116
101
117
102
let h = book.hash();
118
103
if self.by_hash.contains_key(&h) {
119
119
-
if warn_duplicates {
120
120
-
let existing_book_source =
121
121
-
self.source_by_hash.get(&h).expect("book to exist");
122
122
-
tracing::warn!(
123
123
-
"Duplicate book with source {0:?}: {1} by {2} [already one with {3:?}]",
124
124
-
book.metadata.source,
125
125
-
book.content.title,
126
126
-
book.content.author,
127
127
-
existing_book_source
128
128
-
);
129
129
-
}
130
130
-
self.duplicate_books_filtered += 1;
131
104
return;
132
105
}
133
106
···
135
108
136
109
let source = book.metadata.source.clone();
137
110
self.books.push(book);
138
138
-
139
139
-
let category = categorize(&self.books[id]);
140
111
141
112
// indices...
142
113
self.by_hash.insert(h, id);
143
143
-
self.by_category
144
144
-
.entry(category.clone())
145
145
-
.or_default()
146
146
-
.push(id);
147
114
self.source_by_hash.insert(h, source);
148
148
-
self.category_by_id.push(category);
149
115
150
116
let author_lc = SmolStr::new(normalize(&self.books[id].content.author));
151
117
if !author_lc.is_empty() {
···
166
132
index_trigrams(&mut self.tri_contents, id, &self.norm_contents[id]);
167
133
168
134
self.cache_books_by_author.borrow_mut().clear();
169
169
-
self.cache_books_in_category.borrow_mut().clear();
170
135
self.cache_fuzzy_title.borrow_mut().clear();
171
136
self.cache_fuzzy_author.borrow_mut().clear();
172
137
self.cache_fuzzy_contents.borrow_mut().clear();
···
203
168
ids.into_iter().map(|id| &self.books[id])
204
169
}
205
170
206
206
-
/// Lists books by category derived from location strings.
207
207
-
#[inline]
208
208
-
pub fn books_in_category<'a>(
209
209
-
&'a self,
210
210
-
category: &str,
211
211
-
) -> impl Iterator<Item = &'a Book> + 'a {
212
212
-
let key = SmolStr::new(category);
213
213
-
let ids = if key.is_empty() {
214
214
-
Vec::new()
215
215
-
} else if let Some(ids) =
216
216
-
self.cache_books_in_category.borrow_mut().get(&key).cloned()
217
217
-
{
218
218
-
ids
219
219
-
} else {
220
220
-
let ids = self.by_category.get(&key).cloned().unwrap_or_default();
221
221
-
self.cache_books_in_category
222
222
-
.borrow_mut()
223
223
-
.put(key.clone(), ids.clone());
224
224
-
ids
225
225
-
};
226
226
-
227
227
-
ids.into_iter().map(|id| &self.books[id])
228
228
-
}
229
229
-
230
171
/// Fuzzy search over normalized titles.
231
172
pub fn fuzzy_title(&self, query: &str, limit: usize) -> Vec<(&Book, f64)> {
232
173
let key = SmolStr::new(normalize(query));
···
427
368
self.books.iter()
428
369
}
429
370
430
430
-
/// Returns all categories with their book counts.
431
431
-
#[inline]
432
432
-
pub fn categories(&self) -> Vec<(SmolStr, usize)> {
433
433
-
self.by_category
434
434
-
.iter()
435
435
-
.map(|(k, v)| (k.clone(), v.len()))
436
436
-
.collect()
437
437
-
}
438
438
-
439
371
/// Returns the source for a book hash, if present.
440
372
#[inline]
441
373
pub fn source_for_hash(&self, hash: &BookHash) -> Option<&BookSource> {
442
374
self.source_by_hash.get(hash)
443
375
}
444
376
445
445
-
/// Returns the category for a book hash, or an empty string if not found.
446
446
-
#[inline]
447
447
-
pub fn category_for_hash(&self, hash: &BookHash) -> &str {
448
448
-
self.by_hash
449
449
-
.get(hash)
450
450
-
.map(|&id| self.category_by_id[id].as_str())
451
451
-
.unwrap_or("")
452
452
-
}
453
377
}
454
378
455
379
/// Lowercases and normalizes a query string.
···
468
392
}
469
393
470
394
const CACHE_BY_AUTHOR_CAP: usize = 1024;
471
471
-
const CACHE_BY_CATEGORY_CAP: usize = 1024;
472
395
const CACHE_FUZZY_CAP: usize = 256;
473
396
474
397
/// Helper to build LRU caches with non-zero capacity.
···
664
587
fn fuzzy_rank_contents(
665
588
query: &str,
666
589
norm_field: &[String],
667
667
-
tri_index: &ahash::AHashMap<u32, Vec<BookId>>,
590
590
+
tri_index: &AHashMap<u32, Vec<BookId>>,
668
591
limit: usize,
669
592
score_threshold: f64,
670
593
) -> Vec<(BookId, f64)> {
+13
-2
src/main.rs
reviewed
···
1
1
-
use std::time::Instant;
1
1
+
use std::time::{Instant, SystemTime};
2
2
3
3
use anyhow::Context;
4
4
use clap::Parser as _;
···
88
88
}
89
89
90
90
fn build_library(args: &ServeArgs) -> anyhow::Result<Library> {
91
91
+
let start = SystemTime::now();
92
92
+
tracing::info!("Opening book container...");
91
93
let container = BookContainer::open_or_new(&args.container_path)
92
94
.context("Opening container")?;
95
95
+
tracing::info!(
96
96
+
"Container opened, took {} ms. Building library...",
97
97
+
SystemTime::now().duration_since(start)?.as_nanos() / 1_000_000
98
98
+
);
99
99
+
100
100
+
let start = SystemTime::now();
93
101
let mut library = Library::new(
94
102
args.content_threshold,
95
103
args.title_threshold,
···
98
106
for book in container.books() {
99
107
library.add_book(
100
108
book.clone(),
101
101
-
args.warn_duplicates,
102
109
args.warn_empty,
103
110
args.filter_empty_books,
104
111
);
105
112
}
113
113
+
tracing::info!(
114
114
+
"Built library in {}ms",
115
115
+
SystemTime::now().duration_since(start)?.as_nanos() / 1_000_000
116
116
+
);
106
117
Ok(library)
107
118
}
+1
-57
src/web/api.rs
reviewed
···
24
24
.route("/health", get(health))
25
25
.route("/books", get(list_books))
26
26
.route("/books/by-author", get(books_by_author))
27
27
-
.route("/books/by-category", get(books_by_category))
28
27
.route("/books/by-hash/{hash}", get(book_by_hash))
29
29
-
.route("/categories", get(list_categories))
30
28
.route("/search", get(search_all))
31
29
.route("/search/title", get(search_title))
32
30
.route("/search/author", get(search_author))
···
37
35
#[derive(serde::Serialize)]
38
36
struct RootResponse {
39
37
message: &'static str,
40
40
-
endpoints: [&'static str; 10],
38
38
+
endpoints: [&'static str; 8],
41
39
}
42
40
43
41
async fn root() -> axum::Json<RootResponse> {
···
47
45
"/api/health",
48
46
"/api/books?limit=25&offset=0",
49
47
"/api/books/by-author?author=Name&limit=25&offset=0",
50
50
-
"/api/books/by-category?category=Category&limit=25&offset=0",
51
48
"/api/books/by-hash/:hash",
52
52
-
"/api/categories",
53
49
"/api/search?query=term&limit=25&offset=0",
54
50
"/api/search/title?query=term&limit=25&offset=0",
55
51
"/api/search/author?query=term&limit=25&offset=0",
···
140
136
Ok(Json(items))
141
137
}
142
138
143
143
-
#[derive(Deserialize)]
144
144
-
struct CategoryQuery {
145
145
-
category: String,
146
146
-
limit: Option<usize>,
147
147
-
offset: Option<usize>,
148
148
-
}
149
149
-
150
150
-
async fn books_by_category(
151
151
-
State(state): State<AppState>,
152
152
-
Query(query): Query<CategoryQuery>,
153
153
-
) -> Result<Json<Vec<BookDetail>>, ApiError> {
154
154
-
let library = state.library.lock().expect("library mutex poisoned");
155
155
-
let category = query.category.trim();
156
156
-
if category.is_empty() {
157
157
-
return Err(ApiError::bad_request("category is required"));
158
158
-
}
159
159
-
160
160
-
let limit = clamp_limit(query.limit);
161
161
-
let offset = query.offset.unwrap_or(0);
162
162
-
163
163
-
let items = library
164
164
-
.books_in_category(category)
165
165
-
.skip(offset)
166
166
-
.take(limit)
167
167
-
.map(|book| book_to_detail(book, &library))
168
168
-
.collect();
169
169
-
170
170
-
Ok(Json(items))
171
171
-
}
172
172
-
173
139
async fn book_by_hash(
174
140
State(state): State<AppState>,
175
141
Path(hash): Path<String>,
···
181
147
};
182
148
183
149
Ok(Json(book_to_detail(book, &library)))
184
184
-
}
185
185
-
186
186
-
#[derive(Serialize)]
187
187
-
struct CategorySummary {
188
188
-
name: String,
189
189
-
count: usize,
190
190
-
}
191
191
-
192
192
-
async fn list_categories(
193
193
-
State(state): State<AppState>,
194
194
-
) -> Json<Vec<CategorySummary>> {
195
195
-
let library = state.library.lock().expect("library mutex poisoned");
196
196
-
let mut categories: Vec<CategorySummary> = library
197
197
-
.categories()
198
198
-
.into_iter()
199
199
-
.map(|(name, count)| CategorySummary {
200
200
-
name: name.to_string(),
201
201
-
count,
202
202
-
})
203
203
-
.collect();
204
204
-
categories.sort_by(|a, b| a.name.cmp(&b.name));
205
205
-
Json(categories)
206
150
}
207
151
208
152
#[derive(Deserialize)]
+63
-84
src/web/assets/stylesheet.css
reviewed
···
1
1
@font-face {
2
2
font-family: "Minecraft";
3
3
-
src: url("/assets/font/regular.woff2?v=__NARA_ASSET_VERSION__") format("woff2");
3
3
+
src: url("/assets/font/regular.woff2?v=__NARA_ASSET_VERSION__")
4
4
+
format("woff2");
4
5
font-weight: 400;
5
6
font-style: normal;
6
7
font-display: swap;
···
16
17
17
18
@font-face {
18
19
font-family: "Minecraft";
19
19
-
src: url("/assets/font/italic.woff2?v=__NARA_ASSET_VERSION__") format("woff2");
20
20
+
src: url("/assets/font/italic.woff2?v=__NARA_ASSET_VERSION__")
21
21
+
format("woff2");
20
22
font-weight: 400;
21
23
font-style: italic;
22
24
font-display: swap;
···
24
26
25
27
@font-face {
26
28
font-family: "Minecraft";
27
27
-
src: url("/assets/font/bold_italic.woff2?v=__NARA_ASSET_VERSION__") format("woff2");
29
29
+
src: url("/assets/font/bold_italic.woff2?v=__NARA_ASSET_VERSION__")
30
30
+
format("woff2");
28
31
font-weight: 700;
29
32
font-style: italic;
30
33
font-display: swap;
31
34
}
32
35
33
36
.font-minecraft {
34
34
-
font-family: "Minecraft", system-ui, -apple-system, "Segoe UI", sans-serif;
37
37
+
font-family:
38
38
+
"Minecraft",
39
39
+
system-ui,
40
40
+
-apple-system,
41
41
+
"Segoe UI",
42
42
+
sans-serif;
35
43
font-synthesis: none;
36
44
}
37
45
···
44
52
}
45
53
46
54
.text-dark_blue {
47
47
-
color: #0000AA;
55
55
+
color: #0000aa;
48
56
}
49
57
50
58
.text-dark_green {
51
51
-
color: #00AA00;
59
59
+
color: #00aa00;
52
60
}
53
61
54
62
.text-dark_aqua {
55
55
-
color: #00AAAA;
63
63
+
color: #00aaaa;
56
64
}
57
65
58
66
.text-dark_red {
59
59
-
color: #AA0000;
67
67
+
color: #aa0000;
60
68
}
61
69
62
70
.text-dark_purple {
63
63
-
color: #AA00AA;
71
71
+
color: #aa00aa;
64
72
}
65
73
66
74
.text-gold {
67
67
-
color: #FFAA00;
75
75
+
color: #ffaa00;
68
76
}
69
77
70
78
.text-gray {
71
71
-
color: #AAAAAA;
79
79
+
color: #aaaaaa;
72
80
}
73
81
74
82
.text-dark_gray {
···
76
84
}
77
85
78
86
.text-blue {
79
79
-
color: #5555FF;
87
87
+
color: #5555ff;
80
88
}
81
89
82
90
.text-green {
83
83
-
color: #55FF55;
91
91
+
color: #55ff55;
84
92
}
85
93
86
94
.text-aqua {
87
87
-
color: #55FFFF;
95
95
+
color: #55ffff;
88
96
}
89
97
90
98
.text-red {
91
91
-
color: #FF5555;
99
99
+
color: #ff5555;
92
100
}
93
101
94
102
.text-light_purple {
95
95
-
color: #FF55FF;
103
103
+
color: #ff55ff;
96
104
}
97
105
98
106
.text-yellow {
99
99
-
color: #FFFF55;
107
107
+
color: #ffff55;
100
108
}
101
109
102
110
.text-white {
103
103
-
color: #FFFFFF;
111
111
+
color: #ffffff;
104
112
}
105
113
106
114
:root {
···
118
126
119
127
--accent: #4f9a3a;
120
128
--accent-strong: #336f26;
121
121
-
--category-active-border: #275117;
122
122
-
--category-active-surface: #78b65a;
123
123
-
--category-active-ink: #0f2a09;
124
129
125
130
--border: #3b3b3b;
126
131
--button-border: #1f1f1f;
···
168
173
margin: 0;
169
174
font-family: inherit;
170
175
color: var(--ink);
171
171
-
background:
172
172
-
linear-gradient(45deg, #7f7f7f 25%, #777777 25%, #777777 50%, #7f7f7f 50%, #7f7f7f 75%, #777777 75%, #777777 100%);
176
176
+
background: linear-gradient(
177
177
+
45deg,
178
178
+
#7f7f7f 25%,
179
179
+
#777777 25%,
180
180
+
#777777 50%,
181
181
+
#7f7f7f 50%,
182
182
+
#7f7f7f 75%,
183
183
+
#777777 75%,
184
184
+
#777777 100%
185
185
+
);
173
186
background-size: 1rem 1rem;
174
187
}
175
188
···
206
219
background: var(--tile-surface);
207
220
}
208
221
209
209
-
:is(.book-badge, .book-icon, .category-link, .tag, .chip, .detail-link, button, .button, .link-reset, input[type="search"], select) {
222
222
+
:is(
223
223
+
.book-badge,
224
224
+
.book-icon,
225
225
+
.category-link,
226
226
+
.tag,
227
227
+
.chip,
228
228
+
.detail-link,
229
229
+
button,
230
230
+
.button,
231
231
+
.link-reset,
232
232
+
input[type="search"],
233
233
+
select
234
234
+
) {
210
235
border-radius: var(--radius-control);
211
236
}
212
237
···
338
363
339
364
.layout {
340
365
margin-top: 1.5rem;
341
341
-
display: grid;
342
342
-
grid-template-columns: 16.25rem 1fr;
343
343
-
gap: 1.5rem;
344
344
-
}
345
345
-
346
346
-
.sidebar .panel {
347
347
-
margin-bottom: 1.5rem;
348
348
-
padding: 1.125rem;
349
349
-
}
350
350
-
351
351
-
.sidebar h2 {
352
352
-
margin: 0 0 0.75rem;
353
353
-
font-size: 1.125rem;
354
354
-
}
355
355
-
356
356
-
.all-link {
357
357
-
display: inline-block;
358
358
-
margin-bottom: 0.75rem;
359
359
-
font-size: 0.8125rem;
360
360
-
}
361
361
-
362
362
-
.category-list {
363
363
-
margin: 0;
364
364
-
padding: 0;
365
365
-
list-style: none;
366
366
-
display: grid;
367
367
-
gap: 0.5rem;
368
368
-
}
369
369
-
370
370
-
.category-link {
371
371
-
display: flex;
372
372
-
justify-content: space-between;
373
373
-
padding: 0.5rem 0.625rem;
374
374
-
border: var(--border-thick);
375
375
-
color: var(--ink);
376
376
-
background: #c3c3c3;
377
377
-
}
378
378
-
379
379
-
.category-link.active {
380
380
-
border-color: var(--category-active-border);
381
381
-
background: var(--category-active-surface);
382
382
-
color: var(--category-active-ink);
383
383
-
}
384
384
-
385
385
-
.category-link .count {
386
386
-
font-size: 0.75rem;
387
387
-
color: var(--ink-muted);
388
366
}
389
367
390
368
.results-header {
···
412
390
.book-tile {
413
391
text-align: center;
414
392
padding: 0.75rem 0.625rem;
415
415
-
transition: border 0.15s ease, background 0.15s ease;
393
393
+
transition:
394
394
+
border 0.15s ease,
395
395
+
background 0.15s ease;
416
396
}
417
397
418
398
.book-tile:hover {
···
434
414
align-items: center;
435
415
justify-content: center;
436
416
box-shadow: var(--bevel-control);
437
437
-
transition: transform 0.15s ease, box-shadow 0.15s ease;
417
417
+
transition:
418
418
+
transform 0.15s ease,
419
419
+
box-shadow 0.15s ease;
438
420
}
439
421
440
422
.book-icon:hover {
···
521
503
522
504
.pager {
523
505
margin-top: 1.125rem;
506
506
+
margin-bottom: 1.125rem;
524
507
display: flex;
525
508
gap: 0.75rem;
526
509
}
···
567
550
margin-top: 1.5rem;
568
551
margin-bottom: 1.5rem;
569
552
width: 100%;
570
570
-
max-width: calc((var(--book-sprite-width) * 3) + (var(--book-grid-gap) * 2));
553
553
+
max-width: calc(
554
554
+
(var(--book-sprite-width) * 3) + (var(--book-grid-gap) * 2)
555
555
+
);
571
556
margin-inline: auto;
572
557
}
573
558
···
659
644
mask-image: var(--mask, var(--book-mask));
660
645
mask-repeat: no-repeat;
661
646
mask-size: 100% 100%;
662
662
-
animation: glintMove 10s linear infinite, glintHue 10s linear infinite;
647
647
+
animation:
648
648
+
glintMove 10s linear infinite,
649
649
+
glintHue 10s linear infinite;
663
650
}
664
651
665
652
@keyframes glintMove {
···
683
670
}
684
671
685
672
@media (max-width: 61.25rem) {
686
686
-
.layout {
687
687
-
grid-template-columns: 1fr;
688
688
-
}
689
689
-
690
690
-
.sidebar .panel {
691
691
-
position: static;
692
692
-
}
693
693
-
694
673
.search {
695
674
grid-template-columns: 1fr;
696
675
}
···
706
685
flex-direction: column;
707
686
align-items: flex-start;
708
687
}
709
709
-
}
688
688
+
}
+14
-146
src/web/pages.rs
reviewed
···
36
36
pub next_offset: usize,
37
37
pub pager_query: String,
38
38
pub books: Vec<BookCard>,
39
39
-
pub categories: Vec<CategoryView>,
40
39
pub authors: Vec<AuthorView>,
41
40
pub git_hash: String,
42
41
}
···
56
55
pub struct IndexQuery {
57
56
q: Option<String>,
58
57
scope: Option<String>,
59
59
-
category: Option<String>,
60
58
author: Option<String>,
61
59
limit: Option<usize>,
62
60
offset: Option<usize>,
···
71
69
pub struct QueryView {
72
70
pub q: String,
73
71
pub scope: String,
74
74
-
pub category: String,
75
72
pub author: String,
76
76
-
pub has_category: bool,
77
73
pub has_author: bool,
78
74
pub active: bool,
79
75
}
80
76
81
77
#[derive(Debug)]
82
82
-
pub struct CategoryView {
83
83
-
pub name: String,
84
84
-
pub count: usize,
85
85
-
pub href: String,
86
86
-
pub active: bool,
87
87
-
}
88
88
-
89
89
-
#[derive(Debug)]
90
78
pub struct AuthorView {
91
79
pub name: String,
92
80
pub count: usize,
···
107
95
pub author: String,
108
96
pub author_href: String,
109
97
pub source: BookSource,
110
110
-
pub category: String,
111
111
-
pub category_href: String,
112
98
pub pages: Vec<PageView>,
113
99
pub page_count: usize,
114
100
}
···
130
116
131
117
impl HtmlSafe for HtmlText {}
132
118
133
133
-
const DEFAULT_LIMIT: usize = 25;
134
134
-
const MAX_LIMIT: usize = 50;
119
119
+
const DEFAULT_LIMIT: usize = 100;
120
120
+
const MAX_LIMIT: usize = 200;
135
121
const SEARCH_MAX_RESULTS: usize = 200;
136
122
137
123
#[derive(Debug)]
138
124
struct FilterParams {
139
125
query: String,
140
126
scope: &'static str,
141
141
-
category: Option<String>,
142
127
author: Option<String>,
143
128
limit: usize,
144
129
offset: usize,
145
130
author_norm: Option<String>,
146
146
-
category_norm: Option<String>,
147
131
}
148
132
149
133
pub async fn index(
···
162
146
String,
163
147
usize,
164
148
String,
165
165
-
) = if !params.query.is_empty()
166
166
-
|| params.author.is_some()
167
167
-
|| params.category.is_some()
168
168
-
{
149
149
+
) = if !params.query.is_empty() || params.author.is_some() {
169
150
let filtered = collect_candidates(&library, ¶ms);
170
151
let total = filtered.len();
171
152
let offset_used = params.offset.min(total);
···
181
162
format!("Top {0} matches", total)
182
163
};
183
164
let view_label = if params.query.is_empty() {
184
184
-
filtered_view_label(
185
185
-
params.author.as_deref(),
186
186
-
params.category.as_deref(),
187
187
-
)
165
165
+
filtered_view_label(params.author.as_deref())
188
166
} else {
189
167
format!("Search results for \"{0}\"", params.query)
190
168
};
···
207
185
)
208
186
};
209
187
210
210
-
let categories = build_categories(&library, params.category.as_deref());
211
188
let authors = build_authors(&library);
212
189
let pager_query = build_pager_query(
213
190
¶ms.query,
214
191
params.scope,
215
215
-
params.category.as_deref(),
216
192
params.author.as_deref(),
217
193
);
218
218
-
let has_category = params.category.is_some();
219
194
let has_author = params.author.is_some();
220
195
221
196
let active = !params.query.is_empty()
222
222
-
|| params.category.is_some()
223
197
|| params.author.is_some()
224
198
|| params.offset > 0;
225
199
···
236
210
let back_href = build_index_href(
237
211
¶ms.query,
238
212
params.scope,
239
239
-
params.category.as_deref(),
240
213
params.author.as_deref(),
241
214
params.limit,
242
215
offset_used,
···
253
226
query: QueryView {
254
227
q: params.query.clone(),
255
228
scope: params.scope.to_string(),
256
256
-
category: params.category.clone().unwrap_or_default(),
257
229
author: params.author.clone().unwrap_or_default(),
258
258
-
has_category,
259
230
has_author,
260
231
active,
261
232
},
···
272
243
next_offset,
273
244
pager_query,
274
245
books,
275
275
-
categories,
276
246
authors,
277
247
git_hash,
278
248
}
···
317
287
let back_href = build_index_href(
318
288
¶ms.query,
319
289
params.scope,
320
320
-
params.category.as_deref(),
321
290
params.author.as_deref(),
322
291
params.limit,
323
292
params.offset,
···
338
307
fn from_query(query: IndexQuery) -> Self {
339
308
let query_text = query.q.unwrap_or_default();
340
309
let query_text = query_text.trim().to_string();
341
341
-
let category = cleaned_param(query.category);
342
310
let author = cleaned_param(query.author);
343
311
Self {
344
312
query: query_text,
345
313
scope: parse_scope(query.scope.as_deref()),
346
314
limit: clamp_limit(query.limit),
347
315
offset: query.offset.unwrap_or(0),
348
348
-
category_norm: category.as_deref().map(normalize_query),
349
316
author_norm: author.as_deref().map(normalize_query),
350
350
-
category,
351
317
author,
352
318
}
353
319
}
···
358
324
params: &FilterParams,
359
325
) -> Vec<&'a Book> {
360
326
if !params.query.is_empty() {
361
361
-
// Search results are narrowed by optional author/category filters.
362
327
return search_books(
363
328
library,
364
329
params.scope,
···
366
331
SEARCH_MAX_RESULTS,
367
332
)
368
333
.into_iter()
369
369
-
.filter(|book| {
370
370
-
matches_author(book, params.author_norm.as_deref())
371
371
-
&& matches_category(
372
372
-
book,
373
373
-
library,
374
374
-
params.category_norm.as_deref(),
375
375
-
)
376
376
-
})
334
334
+
.filter(|book| matches_author(book, params.author_norm.as_deref()))
377
335
.collect();
378
336
}
379
337
380
380
-
if let (Some(_author_name), Some(category_name)) =
381
381
-
(params.author.as_deref(), params.category.as_deref())
382
382
-
{
383
383
-
return library
384
384
-
.books_in_category(category_name)
385
385
-
.filter(|book| matches_author(book, params.author_norm.as_deref()))
386
386
-
.collect();
387
387
-
}
388
338
if let Some(author_name) = params.author.as_deref() {
389
339
return library.books_by_author(author_name).collect();
390
340
}
391
391
-
if let Some(category_name) = params.category.as_deref() {
392
392
-
return library.books_in_category(category_name).collect();
393
393
-
}
394
341
library.all_books().collect()
395
342
}
396
343
397
397
-
fn filtered_view_label(author: Option<&str>, category: Option<&str>) -> String {
398
398
-
match (author, category) {
399
399
-
(Some(author), Some(category)) => {
400
400
-
format!("Author: {0} · Category: {1}", author, category)
401
401
-
}
402
402
-
(Some(author), None) => format!("Author: {0}", author),
403
403
-
(None, Some(category)) => format!("Category: {0}", category),
404
404
-
(None, None) => String::from("Filtered books"),
344
344
+
fn filtered_view_label(author: Option<&str>) -> String {
345
345
+
match author {
346
346
+
Some(author) => format!("Author: {0}", author),
347
347
+
None => String::from("Filtered books"),
405
348
}
406
349
}
407
350
408
408
-
fn build_categories(
409
409
-
library: &Library,
410
410
-
active: Option<&str>,
411
411
-
) -> Vec<CategoryView> {
412
412
-
let mut categories: Vec<CategoryView> = library
413
413
-
.categories()
414
414
-
.into_iter()
415
415
-
.map(|(name, count)| {
416
416
-
let name_str = name.to_string();
417
417
-
CategoryView {
418
418
-
active: active
419
419
-
.map(|v| v.eq_ignore_ascii_case(&name_str))
420
420
-
.unwrap_or(false),
421
421
-
href: format!(
422
422
-
"/?category={0}",
423
423
-
encode_query_component(&name_str)
424
424
-
),
425
425
-
name: name_str,
426
426
-
count,
427
427
-
}
428
428
-
})
429
429
-
.collect();
430
430
-
categories.sort_by(|a, b| {
431
431
-
b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name))
432
432
-
});
433
433
-
categories
434
434
-
}
435
435
-
436
351
fn build_authors(library: &Library) -> Vec<AuthorView> {
437
352
let mut counts: ahash::AHashMap<SmolStr, (SmolStr, usize)> =
438
353
ahash::AHashMap::new();
···
473
388
(group, trimmed.to_lowercase())
474
389
}
475
390
476
476
-
fn build_pager_query(
477
477
-
q: &str,
478
478
-
scope: &str,
479
479
-
category: Option<&str>,
480
480
-
author: Option<&str>,
481
481
-
) -> String {
391
391
+
fn build_pager_query(q: &str, scope: &str, author: Option<&str>) -> String {
482
392
let mut parts = Vec::new();
483
393
if !q.is_empty() {
484
394
parts.push(format!("q={0}", encode_query_component(q)));
···
486
396
parts.push(format!("scope={0}", encode_query_component(scope)));
487
397
}
488
398
}
489
489
-
if let Some(category) = category {
490
490
-
parts.push(format!("category={0}", encode_query_component(category)));
491
491
-
}
492
399
if let Some(author) = author {
493
400
parts.push(format!("author={0}", encode_query_component(author)));
494
401
}
···
502
409
fn build_index_href(
503
410
q: &str,
504
411
scope: &str,
505
505
-
category: Option<&str>,
506
412
author: Option<&str>,
507
413
limit: usize,
508
414
offset: usize,
···
514
420
parts.push(format!("scope={0}", encode_query_component(scope)));
515
421
}
516
422
}
517
517
-
if let Some(category) = category {
518
518
-
parts.push(format!("category={0}", encode_query_component(category)));
519
519
-
}
520
423
if let Some(author) = author {
521
424
parts.push(format!("author={0}", encode_query_component(author)));
522
425
}
···
547
450
}
548
451
}
549
452
550
550
-
struct BookMeta {
551
551
-
author_href: String,
552
552
-
category: String,
553
553
-
category_href: String,
554
554
-
}
555
555
-
556
556
-
fn book_meta(book: &Book, library: &Library) -> BookMeta {
557
557
-
let category = library.category_for_hash(&book.hash()).to_string();
558
558
-
559
559
-
let category_href =
560
560
-
format!("/?category={0}", encode_query_component(&category));
561
561
-
let author_href =
562
562
-
format!("/?author={0}", encode_query_component(&book.content.author));
563
563
-
564
564
-
BookMeta {
565
565
-
author_href,
566
566
-
category,
567
567
-
category_href,
568
568
-
}
569
569
-
}
570
570
-
571
453
fn book_card(book: &Book) -> BookCard {
572
454
let hash_hex = hex::encode(book.hash());
573
455
let author_href =
···
582
464
}
583
465
}
584
466
585
585
-
fn book_detail(book: &Book, library: &Library) -> BookDetail {
586
586
-
let meta = book_meta(book, library);
467
467
+
fn book_detail(book: &Book, _library: &Library) -> BookDetail {
468
468
+
let author_href =
469
469
+
format!("/?author={0}", encode_query_component(&book.content.author));
587
470
let pages = book
588
471
.content
589
472
.pages
···
598
481
BookDetail {
599
482
title: book.content.title.clone(),
600
483
author: book.content.author.clone(),
601
601
-
author_href: meta.author_href,
484
484
+
author_href,
602
485
source: book.metadata.source.clone(),
603
603
-
category: meta.category,
604
604
-
category_href: meta.category_href,
605
486
pages,
606
487
page_count: book.content.pages.len(),
607
488
}
···
700
581
return true;
701
582
};
702
583
book.content.author.trim().eq_ignore_ascii_case(author_norm)
703
703
-
}
704
704
-
705
705
-
fn matches_category(
706
706
-
book: &Book,
707
707
-
library: &Library,
708
708
-
category_norm: Option<&str>,
709
709
-
) -> bool {
710
710
-
let Some(category_norm) = category_norm else {
711
711
-
return true;
712
712
-
};
713
713
-
library
714
714
-
.category_for_hash(&book.hash())
715
715
-
.eq_ignore_ascii_case(category_norm)
716
584
}
717
585
718
586
fn parse_hash(hex_str: &str) -> Option<[u8; 20]> {
-3
templates/book.html
reviewed
···
37
37
by <a href="{{ book.author_href }}">{{ book.author }}</a>
38
38
</p>
39
39
<div class="meta-row">
40
40
-
<a class="tag-link" href="{{ book.category_href }}"
41
41
-
><span class="tag">{{ book.category }}</span></a
42
42
-
>
43
40
<span class="tag subtle">{{ book.page_count }} pages</span>
44
41
</div>
45
42
</div>
+1
-34
templates/index.html
reviewed
···
53
53
{% endfor %}
54
54
</select>
55
55
</label>
56
56
-
<label class="field">
57
57
-
<span>Category filter</span>
58
58
-
<select name="category">
59
59
-
<option value="" {% if !query.has_category %}selected{% endif %}>All categories</option>
60
60
-
{% for c in categories %}
61
61
-
<option value="{{ c.name }}" {% if query.category==c.name %}selected{% endif %}>{{ c.name }}
62
62
-
</option>
63
63
-
{% endfor %}
64
64
-
</select>
65
65
-
</label>
66
56
<div class="actions">
67
57
<button type="submit">Search</button>
68
58
{% if query.active %}
···
72
62
</div>
73
63
</form>
74
64
75
75
-
{% if query.has_category || query.has_author %}
65
65
+
{% if query.has_author %}
76
66
<div class="active-filters">
77
77
-
{% if query.has_category %}
78
78
-
<span class="chip">Category: {{ query.category }}</span>
79
79
-
{% endif %}
80
80
-
{% if query.has_author %}
81
67
<span class="chip">Author: {{ query.author }}</span>
82
82
-
{% endif %}
83
68
</div>
84
69
{% endif %}
85
70
</header>
86
71
87
72
<section class="layout">
88
88
-
<aside class="sidebar">
89
89
-
<div class="panel">
90
90
-
<h2>Browse Categories</h2>
91
91
-
<a class="all-link" href="/">All books</a>
92
92
-
<ul class="category-list">
93
93
-
{% for c in categories %}
94
94
-
<li>
95
95
-
<a class="category-link {% if c.active %}active{% endif %}" href="{{ c.href }}">
96
96
-
<span>{{ c.name }}</span>
97
97
-
<span class="count">{{ c.count }}</span>
98
98
-
</a>
99
99
-
</li>
100
100
-
{% endfor %}
101
101
-
</ul>
102
102
-
</div>
103
103
-
</aside>
104
104
-
105
73
<section class="results">
106
74
<div class="results-header">
107
75
<div>
···
158
126
{% endif %}
159
127
</nav>
160
128
{% endif %}
161
161
-
</section>
162
129
</section>
163
130
{% endblock %}