tangled
alpha
login
or
join now
cherry.computer
/
website
My personal site
cherry.computer
htmx
tailwind
axum
askama
0
fork
atom
overview
issues
pulls
pipelines
feat: lazily load media
cherry.computer
3 months ago
1432094d
160e7bc0
verified
This commit was signed with the committer's
known signature
.
cherry.computer
SSH Key Fingerprint:
SHA256:SIA77Ll0IpMb8Xd3RtaGT+PBIGIePhJJg5W2r6Td7cc=
+295
-242
16 changed files
expand all
collapse all
unified
split
frontend
esbuild.js
package.json
src
css
tailwind.css
package-lock.json
server
Cargo.lock
Cargo.toml
src
index.rs
main.rs
media.rs
scrapers
apple_music.rs
backloggd.rs
cached.rs
letterboxd.rs
scrapers.rs
templates
index.html
media.html
+3
-1
frontend/esbuild.js
···
50
};
51
const url = new URL(`http://localhost${req.url}`);
52
const route =
53
-
url.pathname === "/" || url.pathname === "/dev/am-auth-flow"
0
0
54
? { hostname: "127.0.0.1", port: 8080 }
55
: { hostname: hosts[0], port };
56
const routedOptions = { ...options, ...route };
···
50
};
51
const url = new URL(`http://localhost${req.url}`);
52
const route =
53
+
url.pathname === "/" ||
54
+
url.pathname === "/dev/am-auth-flow" ||
55
+
url.pathname.startsWith("/media/")
56
? { hostname: "127.0.0.1", port: 8080 }
57
: { hostname: hosts[0], port };
58
const routedOptions = { ...options, ...route };
+1
-1
frontend/package.json
···
32
"url": "https://github.com/ivomurrell/myivo.git"
33
},
34
"dependencies": {
35
-
"htmx.org": "^2.0.6",
36
"tailwindcss": "^4.1.12"
37
},
38
"volta": {
···
32
"url": "https://github.com/ivomurrell/myivo.git"
33
},
34
"dependencies": {
35
+
"htmx.org": "^2.0.8",
36
"tailwindcss": "^4.1.12"
37
},
38
"volta": {
+2
-2
frontend/src/css/tailwind.css
···
3
@source ".";
4
5
@source inline("hoverable:grid-cols-{1..3}");
6
-
@source inline("peer/{1..3}");
7
-
@source inline("peer-hover/{1..3}:block");
8
9
@custom-variant hoverable (@media (hover: hover));
···
3
@source ".";
4
5
@source inline("hoverable:grid-cols-{1..3}");
6
+
@source inline("peer/{game,film,song}");
7
+
@source inline("peer-hover/{game,film,song}:block");
8
9
@custom-variant hoverable (@media (hover: hover));
+15
-13
package-lock.json
···
45
"version": "1.0.0",
46
"license": "MIT",
47
"dependencies": {
48
-
"htmx.org": "^2.0.6",
49
"tailwindcss": "^4.1.12"
50
},
51
"devDependencies": {
···
62
"typescript": "^5.9.2",
63
"typescript-eslint": "^8.41.0"
64
}
0
0
0
0
0
0
65
},
66
"node_modules/@esbuild/aix-ppc64": {
67
"version": "0.25.9",
···
2445
"engines": {
2446
"node": ">=8"
2447
}
2448
-
},
2449
-
"node_modules/htmx.org": {
2450
-
"version": "2.0.6",
2451
-
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
2452
-
"integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==",
2453
-
"license": "0BSD"
2454
},
2455
"node_modules/ignore": {
2456
"version": "5.3.2",
···
4461
"eslint": "^9.34.0",
4462
"eslint-config-prettier": "^10.1.8",
4463
"globals": "^16.3.0",
4464
-
"htmx.org": "^2.0.6",
4465
"minimist": "^1.2.8",
4466
"tailwindcss": "^4.1.12",
4467
"typescript": "^5.9.2",
4468
"typescript-eslint": "^8.41.0"
0
0
0
0
0
0
0
4469
}
4470
},
4471
"balanced-match": {
···
4906
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
4907
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
4908
"dev": true
4909
-
},
4910
-
"htmx.org": {
4911
-
"version": "2.0.6",
4912
-
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
4913
-
"integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA=="
4914
},
4915
"ignore": {
4916
"version": "5.3.2",
···
45
"version": "1.0.0",
46
"license": "MIT",
47
"dependencies": {
48
+
"htmx.org": "^2.0.8",
49
"tailwindcss": "^4.1.12"
50
},
51
"devDependencies": {
···
62
"typescript": "^5.9.2",
63
"typescript-eslint": "^8.41.0"
64
}
65
+
},
66
+
"frontend/node_modules/htmx.org": {
67
+
"version": "2.0.8",
68
+
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
69
+
"integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==",
70
+
"license": "0BSD"
71
},
72
"node_modules/@esbuild/aix-ppc64": {
73
"version": "0.25.9",
···
2451
"engines": {
2452
"node": ">=8"
2453
}
0
0
0
0
0
0
2454
},
2455
"node_modules/ignore": {
2456
"version": "5.3.2",
···
4461
"eslint": "^9.34.0",
4462
"eslint-config-prettier": "^10.1.8",
4463
"globals": "^16.3.0",
4464
+
"htmx.org": "^2.0.8",
4465
"minimist": "^1.2.8",
4466
"tailwindcss": "^4.1.12",
4467
"typescript": "^5.9.2",
4468
"typescript-eslint": "^8.41.0"
4469
+
},
4470
+
"dependencies": {
4471
+
"htmx.org": {
4472
+
"version": "2.0.8",
4473
+
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
4474
+
"integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q=="
4475
+
}
4476
}
4477
},
4478
"balanced-match": {
···
4913
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
4914
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
4915
"dev": true
0
0
0
0
0
4916
},
4917
"ignore": {
4918
"version": "5.3.2",
+25
-159
server/Cargo.lock
···
18
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19
20
[[package]]
21
-
name = "ahash"
22
-
version = "0.8.12"
23
-
source = "registry+https://github.com/rust-lang/crates.io-index"
24
-
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
25
-
dependencies = [
26
-
"cfg-if",
27
-
"once_cell",
28
-
"version_check",
29
-
"zerocopy",
30
-
]
31
-
32
-
[[package]]
33
name = "alloc-no-stdlib"
34
version = "2.0.4"
35
source = "registry+https://github.com/rust-lang/crates.io-index"
···
43
dependencies = [
44
"alloc-no-stdlib",
45
]
46
-
47
-
[[package]]
48
-
name = "allocator-api2"
49
-
version = "0.2.21"
50
-
source = "registry+https://github.com/rust-lang/crates.io-index"
51
-
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
52
53
[[package]]
54
name = "anyhow"
···
114
"tokio",
115
"zstd",
116
"zstd-safe",
117
-
]
118
-
119
-
[[package]]
120
-
name = "async-trait"
121
-
version = "0.1.89"
122
-
source = "registry+https://github.com/rust-lang/crates.io-index"
123
-
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
124
-
dependencies = [
125
-
"proc-macro2",
126
-
"quote",
127
-
"syn",
128
]
129
130
[[package]]
···
269
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
270
271
[[package]]
272
-
name = "cached"
273
-
version = "0.56.0"
274
-
source = "registry+https://github.com/rust-lang/crates.io-index"
275
-
checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c"
276
-
dependencies = [
277
-
"ahash",
278
-
"async-trait",
279
-
"cached_proc_macro",
280
-
"cached_proc_macro_types",
281
-
"futures",
282
-
"hashbrown",
283
-
"once_cell",
284
-
"thiserror",
285
-
"tokio",
286
-
"web-time",
287
-
]
288
-
289
-
[[package]]
290
-
name = "cached_proc_macro"
291
-
version = "0.25.0"
292
-
source = "registry+https://github.com/rust-lang/crates.io-index"
293
-
checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201"
294
-
dependencies = [
295
-
"darling",
296
-
"proc-macro2",
297
-
"quote",
298
-
"syn",
299
-
]
300
-
301
-
[[package]]
302
-
name = "cached_proc_macro_types"
303
-
version = "0.1.1"
304
-
source = "registry+https://github.com/rust-lang/crates.io-index"
305
-
checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
306
-
307
-
[[package]]
308
name = "cc"
309
version = "1.2.34"
310
source = "registry+https://github.com/rust-lang/crates.io-index"
···
392
]
393
394
[[package]]
395
-
name = "darling"
396
-
version = "0.20.11"
397
-
source = "registry+https://github.com/rust-lang/crates.io-index"
398
-
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
399
-
dependencies = [
400
-
"darling_core",
401
-
"darling_macro",
402
-
]
403
-
404
-
[[package]]
405
-
name = "darling_core"
406
-
version = "0.20.11"
407
-
source = "registry+https://github.com/rust-lang/crates.io-index"
408
-
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
409
-
dependencies = [
410
-
"fnv",
411
-
"ident_case",
412
-
"proc-macro2",
413
-
"quote",
414
-
"strsim",
415
-
"syn",
416
-
]
417
-
418
-
[[package]]
419
-
name = "darling_macro"
420
-
version = "0.20.11"
421
-
source = "registry+https://github.com/rust-lang/crates.io-index"
422
-
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
423
-
dependencies = [
424
-
"darling_core",
425
-
"quote",
426
-
"syn",
427
-
]
428
-
429
-
[[package]]
430
name = "deranged"
431
version = "0.4.0"
432
source = "registry+https://github.com/rust-lang/crates.io-index"
···
535
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
536
537
[[package]]
538
-
name = "foldhash"
539
-
version = "0.1.5"
540
-
source = "registry+https://github.com/rust-lang/crates.io-index"
541
-
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
542
-
543
-
[[package]]
544
name = "foreign-types"
545
version = "0.3.2"
546
source = "registry+https://github.com/rust-lang/crates.io-index"
···
575
]
576
577
[[package]]
578
-
name = "futures"
579
-
version = "0.3.31"
580
-
source = "registry+https://github.com/rust-lang/crates.io-index"
581
-
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
582
-
dependencies = [
583
-
"futures-channel",
584
-
"futures-core",
585
-
"futures-io",
586
-
"futures-sink",
587
-
"futures-task",
588
-
"futures-util",
589
-
]
590
-
591
-
[[package]]
592
name = "futures-channel"
593
version = "0.3.31"
594
source = "registry+https://github.com/rust-lang/crates.io-index"
595
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
596
dependencies = [
597
"futures-core",
598
-
"futures-sink",
599
]
600
601
[[package]]
···
605
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
606
607
[[package]]
608
-
name = "futures-io"
609
-
version = "0.3.31"
610
-
source = "registry+https://github.com/rust-lang/crates.io-index"
611
-
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
612
-
613
-
[[package]]
614
name = "futures-sink"
615
version = "0.3.31"
616
source = "registry+https://github.com/rust-lang/crates.io-index"
···
629
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
630
dependencies = [
631
"futures-core",
632
-
"futures-sink",
633
"futures-task",
634
"pin-project-lite",
635
"pin-utils",
···
708
version = "0.15.5"
709
source = "registry+https://github.com/rust-lang/crates.io-index"
710
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
711
-
dependencies = [
712
-
"allocator-api2",
713
-
"equivalent",
714
-
"foldhash",
715
-
]
0
716
717
[[package]]
718
name = "html5ever"
···
945
]
946
947
[[package]]
948
-
name = "ident_case"
949
-
version = "1.0.1"
950
-
source = "registry+https://github.com/rust-lang/crates.io-index"
951
-
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
952
-
953
-
[[package]]
954
name = "idna"
955
version = "1.1.0"
956
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1172
"anyhow",
1173
"askama",
1174
"axum",
1175
-
"cached",
1176
"jsonwebtoken",
1177
"rand 0.9.2",
1178
"reqwest",
1179
"scraper",
1180
"serde",
0
1181
"tokio",
1182
"tower",
1183
"tower-http",
···
1888
]
1889
1890
[[package]]
1891
-
name = "strsim"
1892
-
version = "0.11.1"
1893
source = "registry+https://github.com/rust-lang/crates.io-index"
1894
-
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1895
1896
[[package]]
1897
name = "subtle"
···
2291
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2292
2293
[[package]]
2294
-
name = "version_check"
2295
-
version = "0.9.5"
2296
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2297
-
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2298
-
2299
-
[[package]]
2300
name = "want"
2301
version = "0.3.1"
2302
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2396
version = "0.3.77"
2397
source = "registry+https://github.com/rust-lang/crates.io-index"
2398
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
2399
-
dependencies = [
2400
-
"js-sys",
2401
-
"wasm-bindgen",
2402
-
]
2403
-
2404
-
[[package]]
2405
-
name = "web-time"
2406
-
version = "1.1.0"
2407
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2408
-
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2409
dependencies = [
2410
"js-sys",
2411
"wasm-bindgen",
···
18
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19
20
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
21
name = "alloc-no-stdlib"
22
version = "2.0.4"
23
source = "registry+https://github.com/rust-lang/crates.io-index"
···
31
dependencies = [
32
"alloc-no-stdlib",
33
]
0
0
0
0
0
0
34
35
[[package]]
36
name = "anyhow"
···
96
"tokio",
97
"zstd",
98
"zstd-safe",
0
0
0
0
0
0
0
0
0
0
0
99
]
100
101
[[package]]
···
240
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
241
242
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
243
name = "cc"
244
version = "1.2.34"
245
source = "registry+https://github.com/rust-lang/crates.io-index"
···
327
]
328
329
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
330
name = "deranged"
331
version = "0.4.0"
332
source = "registry+https://github.com/rust-lang/crates.io-index"
···
435
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
436
437
[[package]]
0
0
0
0
0
0
438
name = "foreign-types"
439
version = "0.3.2"
440
source = "registry+https://github.com/rust-lang/crates.io-index"
···
469
]
470
471
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
472
name = "futures-channel"
473
version = "0.3.31"
474
source = "registry+https://github.com/rust-lang/crates.io-index"
475
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
476
dependencies = [
477
"futures-core",
0
478
]
479
480
[[package]]
···
484
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
485
486
[[package]]
0
0
0
0
0
0
487
name = "futures-sink"
488
version = "0.3.31"
489
source = "registry+https://github.com/rust-lang/crates.io-index"
···
502
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
503
dependencies = [
504
"futures-core",
0
505
"futures-task",
506
"pin-project-lite",
507
"pin-utils",
···
580
version = "0.15.5"
581
source = "registry+https://github.com/rust-lang/crates.io-index"
582
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
583
+
584
+
[[package]]
585
+
name = "heck"
586
+
version = "0.5.0"
587
+
source = "registry+https://github.com/rust-lang/crates.io-index"
588
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
589
590
[[package]]
591
name = "html5ever"
···
818
]
819
820
[[package]]
0
0
0
0
0
0
821
name = "idna"
822
version = "1.1.0"
823
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1039
"anyhow",
1040
"askama",
1041
"axum",
0
1042
"jsonwebtoken",
1043
"rand 0.9.2",
1044
"reqwest",
1045
"scraper",
1046
"serde",
1047
+
"strum",
1048
"tokio",
1049
"tower",
1050
"tower-http",
···
1755
]
1756
1757
[[package]]
1758
+
name = "strum"
1759
+
version = "0.27.2"
1760
source = "registry+https://github.com/rust-lang/crates.io-index"
1761
+
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
1762
+
dependencies = [
1763
+
"strum_macros",
1764
+
]
1765
+
1766
+
[[package]]
1767
+
name = "strum_macros"
1768
+
version = "0.27.2"
1769
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1770
+
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
1771
+
dependencies = [
1772
+
"heck",
1773
+
"proc-macro2",
1774
+
"quote",
1775
+
"syn",
1776
+
]
1777
1778
[[package]]
1779
name = "subtle"
···
2173
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2174
2175
[[package]]
0
0
0
0
0
0
2176
name = "want"
2177
version = "0.3.1"
2178
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2272
version = "0.3.77"
2273
source = "registry+https://github.com/rust-lang/crates.io-index"
2274
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
0
0
0
0
0
0
0
0
0
0
2275
dependencies = [
2276
"js-sys",
2277
"wasm-bindgen",
+1
-1
server/Cargo.toml
···
10
anyhow = "1.0.57"
11
askama = "0.14.0"
12
axum = "0.8.1"
13
-
cached = { version = "0.56.0", features = ["async"] }
14
jsonwebtoken = "9.3.1"
15
rand = "0.9.2"
16
reqwest = { version = "0.12.23", features = ["json"] }
17
scraper = "0.24.0"
18
serde = { version = "1.0.219", features = ["derive"] }
0
19
tokio = { version = "1.18.2", features = ["full"] }
20
tower = "0.5.2"
21
tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] }
···
10
anyhow = "1.0.57"
11
askama = "0.14.0"
12
axum = "0.8.1"
0
13
jsonwebtoken = "9.3.1"
14
rand = "0.9.2"
15
reqwest = { version = "0.12.23", features = ["json"] }
16
scraper = "0.24.0"
17
serde = { version = "1.0.219", features = ["derive"] }
18
+
strum = { version = "0.27.2", features = ["derive"] }
19
tokio = { version = "1.18.2", features = ["full"] }
20
tower = "0.5.2"
21
tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] }
+25
-26
server/src/index.rs
···
1
-
use crate::scrapers::{
2
-
Media,
3
-
apple_music::{self, AppleMusicClient},
4
-
backloggd, letterboxd,
5
-
};
6
7
use askama::Template;
8
use rand::seq::IndexedRandom;
···
15
mock: bool,
16
}
17
0
0
18
#[derive(Template, Debug, Clone)]
19
#[template(path = "index.html")]
20
pub struct RootTemplate {
21
-
media: Vec<Media>,
22
consumption_verb: &'static str,
23
}
24
25
impl RootTemplate {
26
-
pub async fn new(
27
-
apple_music_client: &AppleMusicClient,
28
-
#[allow(unused_variables)] options: IndexOptions,
29
) -> RootTemplate {
30
#[cfg(debug_assertions)]
31
let media = if options.mock {
32
mocked_media()
33
} else {
34
-
Self::fetch_media(apple_music_client).await
35
};
36
#[cfg(not(debug_assertions))]
37
-
let media = Self::fetch_media(apple_music_client).await;
38
39
let consumption_verb = Self::random_consumption_verb();
40
···
44
}
45
}
46
47
-
async fn fetch_media(apple_music_client: &AppleMusicClient) -> Vec<Media> {
48
-
let (game, movie, song) = tokio::join!(
49
-
backloggd::cached_fetch(),
50
-
letterboxd::cached_fetch(),
51
-
apple_music::cached_fetch(apple_music_client)
52
-
);
53
-
[game, movie, song].into_iter().flatten().collect()
54
}
55
56
fn random_consumption_verb() -> &'static str {
···
61
}
62
63
#[cfg(debug_assertions)]
64
-
fn mocked_media() -> Vec<Media> {
65
-
vec![
66
-
Media {
67
name: "Cyberpunk 2077: Ultimate Edition".to_owned(),
68
image: "https://images.igdb.com/igdb/image/upload/t_cover_big/co7iy1.jpg".to_owned(),
69
context: "Nintendo Switch 2".to_owned(),
70
url: Some("https://backloggd.com/u/cherryfunk/logs/cyberpunk-2077-ultimate-edition/".to_owned())
71
-
},
72
-
Media {
73
name: "The Thursday Murder Club".to_owned(),
74
image: "https://a.ltrbxd.com/resized/film-poster/6/6/6/2/8/6/666286-the-thursday-murder-club-0-230-0-345-crop.jpg?v=4bfeae38a7".to_owned(),
75
context: "1 star".to_owned(),
76
url: Some("https://letterboxd.com/ivom/film/the-thursday-murder-club/".to_owned())
77
-
},
78
-
Media {
79
name: "We Might Feel Unsound".to_owned(),
80
image: "https://is1-ssl.mzstatic.com/image/thumb/Music124/v4/f4/b2/8e/f4b28ee4-01c6-232c-56a7-b97fd5b0e0ae/00602527857671.rgb.jpg/240x240bb.jpg".to_owned(),
81
context: "James Blake — James Blake".to_owned(),
82
url: None
83
-
},
84
]
85
}
···
1
+
use std::sync::Arc;
2
+
3
+
use crate::scrapers::{Media, MediaType, apple_music::AppleMusicClient, backloggd, letterboxd};
0
0
4
5
use askama::Template;
6
use rand::seq::IndexedRandom;
···
13
mock: bool,
14
}
15
16
+
type MediaList = [(MediaType, Option<Media>); 3];
17
+
18
#[derive(Template, Debug, Clone)]
19
#[template(path = "index.html")]
20
pub struct RootTemplate {
21
+
media: MediaList,
22
consumption_verb: &'static str,
23
}
24
25
impl RootTemplate {
26
+
pub fn new(
27
+
apple_music_client: Arc<AppleMusicClient>,
28
+
#[allow(unused_variables)] options: &IndexOptions,
29
) -> RootTemplate {
30
#[cfg(debug_assertions)]
31
let media = if options.mock {
32
mocked_media()
33
} else {
34
+
Self::fetch_media(apple_music_client)
35
};
36
#[cfg(not(debug_assertions))]
37
+
let media = Self::fetch_media(apple_music_client);
38
39
let consumption_verb = Self::random_consumption_verb();
40
···
44
}
45
}
46
47
+
fn fetch_media(apple_music_client: Arc<AppleMusicClient>) -> MediaList {
48
+
[
49
+
(MediaType::Game, backloggd::try_cached_fetch()),
50
+
(MediaType::Film, letterboxd::try_cached_fetch()),
51
+
(MediaType::Song, apple_music_client.try_cached_fetch()),
52
+
]
0
53
}
54
55
fn random_consumption_verb() -> &'static str {
···
60
}
61
62
#[cfg(debug_assertions)]
63
+
fn mocked_media() -> MediaList {
64
+
[
65
+
(MediaType::Game, Some(Media {
66
name: "Cyberpunk 2077: Ultimate Edition".to_owned(),
67
image: "https://images.igdb.com/igdb/image/upload/t_cover_big/co7iy1.jpg".to_owned(),
68
context: "Nintendo Switch 2".to_owned(),
69
url: Some("https://backloggd.com/u/cherryfunk/logs/cyberpunk-2077-ultimate-edition/".to_owned())
70
+
})),
71
+
(MediaType::Film, Some(Media {
72
name: "The Thursday Murder Club".to_owned(),
73
image: "https://a.ltrbxd.com/resized/film-poster/6/6/6/2/8/6/666286-the-thursday-murder-club-0-230-0-345-crop.jpg?v=4bfeae38a7".to_owned(),
74
context: "1 star".to_owned(),
75
url: Some("https://letterboxd.com/ivom/film/the-thursday-murder-club/".to_owned())
76
+
})),
77
+
(MediaType::Song, Some(Media {
78
name: "We Might Feel Unsound".to_owned(),
79
image: "https://is1-ssl.mzstatic.com/image/thumb/Music124/v4/f4/b2/8e/f4b28ee4-01c6-232c-56a7-b97fd5b0e0ae/00602527857671.rgb.jpg/240x240bb.jpg".to_owned(),
80
context: "James Blake — James Blake".to_owned(),
81
url: None
82
+
})),
83
]
84
}
+22
-4
server/src/main.rs
···
1
#[cfg(debug_assertions)]
2
mod am_auth_flow;
3
mod index;
0
4
mod scrapers;
5
6
use std::{net::SocketAddr, sync::Arc};
···
8
#[cfg(debug_assertions)]
9
use crate::am_auth_flow::AuthFlowTemplate;
10
use crate::index::{IndexOptions, RootTemplate};
11
-
use crate::scrapers::apple_music::AppleMusicClient;
0
12
13
use askama::Template;
14
use axum::{
15
Router,
16
-
extract::{Query, State},
17
http::{HeaderName, HeaderValue, StatusCode},
18
response::{Html, IntoResponse},
19
routing::{get, get_service},
···
36
let apple_music_client = Arc::new(AppleMusicClient::new()?);
37
let state = AppState { apple_music_client };
38
39
-
let app = Router::new().route("/", get(render_index_handler));
0
0
40
#[cfg(debug_assertions)]
41
let app = app.route("/dev/am-auth-flow", get(render_apple_music_auth_flow));
42
let app = app
···
64
Query(options): Query<IndexOptions>,
65
State(state): State<AppState>,
66
) -> impl IntoResponse {
67
-
let template = RootTemplate::new(&state.apple_music_client, options).await;
68
template.render().map(Html).map_err(|err| {
69
tracing::error!("failed to render index: {err:?}");
0
0
0
0
0
0
0
0
0
0
0
0
0
0
70
StatusCode::INTERNAL_SERVER_ERROR
71
})
72
}
···
1
#[cfg(debug_assertions)]
2
mod am_auth_flow;
3
mod index;
4
+
mod media;
5
mod scrapers;
6
7
use std::{net::SocketAddr, sync::Arc};
···
9
#[cfg(debug_assertions)]
10
use crate::am_auth_flow::AuthFlowTemplate;
11
use crate::index::{IndexOptions, RootTemplate};
12
+
use crate::media::{MediaTemplate, fetch_media_of_type};
13
+
use crate::scrapers::{MediaType, apple_music::AppleMusicClient};
14
15
use askama::Template;
16
use axum::{
17
Router,
18
+
extract::{Path, Query, State},
19
http::{HeaderName, HeaderValue, StatusCode},
20
response::{Html, IntoResponse},
21
routing::{get, get_service},
···
38
let apple_music_client = Arc::new(AppleMusicClient::new()?);
39
let state = AppState { apple_music_client };
40
41
+
let app = Router::new()
42
+
.route("/", get(render_index_handler))
43
+
.route("/media/{media_type}", get(render_media_partial_handler));
44
#[cfg(debug_assertions)]
45
let app = app.route("/dev/am-auth-flow", get(render_apple_music_auth_flow));
46
let app = app
···
68
Query(options): Query<IndexOptions>,
69
State(state): State<AppState>,
70
) -> impl IntoResponse {
71
+
let template = RootTemplate::new(state.apple_music_client, &options);
72
template.render().map(Html).map_err(|err| {
73
tracing::error!("failed to render index: {err:?}");
74
+
StatusCode::INTERNAL_SERVER_ERROR
75
+
})
76
+
}
77
+
78
+
async fn render_media_partial_handler(
79
+
Path(media_type): Path<MediaType>,
80
+
State(state): State<AppState>,
81
+
) -> impl IntoResponse {
82
+
let media = fetch_media_of_type(media_type, state.apple_music_client)
83
+
.await
84
+
.unwrap();
85
+
let template = MediaTemplate { media_type, media };
86
+
template.render().map(Html).map_err(|err| {
87
+
tracing::error!("failed to render {media_type} media: {err:?}");
88
StatusCode::INTERNAL_SERVER_ERROR
89
})
90
}
+23
server/src/media.rs
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
use std::sync::Arc;
2
+
3
+
use crate::scrapers::{Media, MediaType, apple_music::AppleMusicClient, backloggd, letterboxd};
4
+
5
+
use askama::Template;
6
+
7
+
#[derive(Template, Debug, Clone)]
8
+
#[template(path = "media.html")]
9
+
pub struct MediaTemplate {
10
+
pub media_type: MediaType,
11
+
pub media: Media,
12
+
}
13
+
14
+
pub async fn fetch_media_of_type(
15
+
media_type: MediaType,
16
+
apple_music_client: Arc<AppleMusicClient>,
17
+
) -> Option<Media> {
18
+
match media_type {
19
+
MediaType::Game => backloggd::cached_fetch().await,
20
+
MediaType::Film => letterboxd::cached_fetch().await,
21
+
MediaType::Song => apple_music_client.cached_fetch().await,
22
+
}
23
+
}
+14
server/src/scrapers.rs
···
0
0
1
pub mod apple_music;
2
pub mod backloggd;
3
pub mod letterboxd;
4
0
0
0
5
#[derive(Debug, Clone)]
6
pub struct Media {
7
pub name: String,
···
9
pub context: String,
10
pub url: Option<String>,
11
}
0
0
0
0
0
0
0
0
0
···
1
+
pub mod cached;
2
+
3
pub mod apple_music;
4
pub mod backloggd;
5
pub mod letterboxd;
6
7
+
use serde::Deserialize;
8
+
use strum::{Display, EnumString};
9
+
10
#[derive(Debug, Clone)]
11
pub struct Media {
12
pub name: String,
···
14
pub context: String,
15
pub url: Option<String>,
16
}
17
+
18
+
#[derive(Debug, Clone, Copy, Display, EnumString, Deserialize)]
19
+
#[strum(serialize_all = "snake_case")]
20
+
#[serde(rename_all = "snake_case")]
21
+
pub enum MediaType {
22
+
Game,
23
+
Film,
24
+
Song,
25
+
}
+19
-11
server/src/scrapers/apple_music.rs
···
1
-
use std::{env, fs, time::Duration};
2
3
use anyhow::Context;
4
-
use cached::proc_macro::once;
5
use jsonwebtoken::{Algorithm, EncodingKey, Header};
6
use reqwest::Client;
7
use serde::{Deserialize, Serialize};
0
8
9
-
use super::Media;
0
0
0
0
0
10
11
#[derive(Serialize, Debug, Clone)]
12
struct Claims {
···
51
}
52
53
pub struct AppleMusicClient {
0
54
http_client: Client,
55
key_id: String,
56
team_id: String,
···
60
61
impl AppleMusicClient {
62
pub fn new() -> anyhow::Result<Self> {
0
63
let key_id =
64
env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?;
65
let team_id =
···
70
let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?;
71
72
Ok(Self {
0
73
http_client: Client::new(),
74
key_id,
75
team_id,
···
113
})
114
}
115
0
0
0
0
0
0
0
0
116
pub fn build_developer_token(&self) -> anyhow::Result<String> {
117
let mut header = Header::new(Algorithm::ES256);
118
header.kid = Some(self.key_id.clone());
···
122
.context("failed to encode apple developer JWT")
123
}
124
}
125
-
126
-
#[once(time = 30, option = false)]
127
-
pub async fn cached_fetch(this: &AppleMusicClient) -> Option<Media> {
128
-
this.fetch()
129
-
.await
130
-
.map_err(|error| tracing::warn!(?error, "failed to call Apple Music"))
131
-
.ok()
132
-
}
···
1
+
use std::{env, fs, sync::Arc, time::Duration};
2
3
use anyhow::Context;
0
4
use jsonwebtoken::{Algorithm, EncodingKey, Header};
5
use reqwest::Client;
6
use serde::{Deserialize, Serialize};
7
+
use tokio::sync::RwLock;
8
9
+
use super::{
10
+
Media,
11
+
cached::{MediaCache, cache_or_fetch, try_cache_or_fetch},
12
+
};
13
+
14
+
static TTL: Duration = Duration::from_secs(30);
15
16
#[derive(Serialize, Debug, Clone)]
17
struct Claims {
···
56
}
57
58
pub struct AppleMusicClient {
59
+
cache: MediaCache,
60
http_client: Client,
61
key_id: String,
62
team_id: String,
···
66
67
impl AppleMusicClient {
68
pub fn new() -> anyhow::Result<Self> {
69
+
let cache = Arc::new(RwLock::new(None));
70
let key_id =
71
env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?;
72
let team_id =
···
77
let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?;
78
79
Ok(Self {
80
+
cache,
81
http_client: Client::new(),
82
key_id,
83
team_id,
···
121
})
122
}
123
124
+
pub fn try_cached_fetch(self: Arc<Self>) -> Option<Media> {
125
+
try_cache_or_fetch(&self.cache.clone(), TTL, async move || self.fetch().await)
126
+
}
127
+
128
+
pub async fn cached_fetch(self: Arc<Self>) -> Option<Media> {
129
+
cache_or_fetch(&self.cache.clone(), TTL, async move || self.fetch().await).await
130
+
}
131
+
132
pub fn build_developer_token(&self) -> anyhow::Result<String> {
133
let mut header = Header::new(Algorithm::ES256);
134
header.kid = Some(self.key_id.clone());
···
138
.context("failed to encode apple developer JWT")
139
}
140
}
0
0
0
0
0
0
0
0
+15
-8
server/src/scrapers/backloggd.rs
···
1
-
use std::{sync::LazyLock, time::Duration};
0
0
0
2
3
use anyhow::Context;
4
-
use cached::proc_macro::once;
5
use reqwest::Url;
6
use scraper::{Html, Selector};
0
7
8
-
use super::Media;
0
0
0
9
10
pub async fn fetch() -> anyhow::Result<Media> {
11
static FIRST_ENTRY_SEL: LazyLock<Selector> =
···
72
})
73
}
74
75
-
#[once(time = 300, option = false)]
0
0
0
0
76
pub async fn cached_fetch() -> Option<Media> {
77
-
fetch()
78
-
.await
79
-
.map_err(|error| tracing::warn!(?error, "failed to scrape Backloggd"))
80
-
.ok()
81
}
···
1
+
use std::{
2
+
sync::{Arc, LazyLock},
3
+
time::Duration,
4
+
};
5
6
use anyhow::Context;
0
7
use reqwest::Url;
8
use scraper::{Html, Selector};
9
+
use tokio::sync::RwLock;
10
11
+
use super::{
12
+
Media,
13
+
cached::{MediaCache, cache_or_fetch, try_cache_or_fetch},
14
+
};
15
16
pub async fn fetch() -> anyhow::Result<Media> {
17
static FIRST_ENTRY_SEL: LazyLock<Selector> =
···
78
})
79
}
80
81
+
static CACHE: LazyLock<MediaCache> = LazyLock::new(|| Arc::new(RwLock::new(None)));
82
+
static TTL: Duration = Duration::from_secs(300);
83
+
pub fn try_cached_fetch() -> Option<Media> {
84
+
try_cache_or_fetch(&CACHE, TTL, fetch)
85
+
}
86
pub async fn cached_fetch() -> Option<Media> {
87
+
cache_or_fetch(&CACHE, TTL, fetch).await
0
0
0
88
}
+90
server/src/scrapers/cached.rs
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
use std::{
2
+
sync::Arc,
3
+
time::{Duration, Instant},
4
+
};
5
+
use tokio::sync::RwLock;
6
+
7
+
use super::Media;
8
+
9
+
#[derive(Debug)]
10
+
pub struct CachedMediaResult {
11
+
media_result: Option<Media>,
12
+
cache_time: Instant,
13
+
ttl: Duration,
14
+
}
15
+
16
+
impl CachedMediaResult {
17
+
fn has_expired(&self) -> bool {
18
+
let ttl = if self.media_result.is_some() {
19
+
self.ttl
20
+
} else {
21
+
Duration::from_secs(30)
22
+
};
23
+
self.cache_time.elapsed() >= ttl
24
+
}
25
+
}
26
+
27
+
pub type MediaCache = Arc<RwLock<Option<CachedMediaResult>>>;
28
+
29
+
pub fn try_cache_or_fetch<F, Fut>(cache: &MediaCache, ttl: Duration, fetcher: F) -> Option<Media>
30
+
where
31
+
F: FnOnce() -> Fut + Send + 'static,
32
+
Fut: Future<Output = anyhow::Result<Media>> + Send,
33
+
{
34
+
{
35
+
let cached = cache.try_read().ok()?;
36
+
if let Some(cached) = &*cached
37
+
&& !cached.has_expired()
38
+
{
39
+
return cached.media_result.clone();
40
+
}
41
+
}
42
+
43
+
let cache = cache.clone();
44
+
tokio::spawn(async move {
45
+
synced_fetch(&cache, ttl, fetcher).await;
46
+
});
47
+
48
+
None
49
+
}
50
+
51
+
pub async fn cache_or_fetch<F, Fut>(cache: &MediaCache, ttl: Duration, fetcher: F) -> Option<Media>
52
+
where
53
+
F: FnOnce() -> Fut,
54
+
Fut: Future<Output = anyhow::Result<Media>>,
55
+
{
56
+
{
57
+
let cached = cache.read().await;
58
+
if let Some(cached) = &*cached
59
+
&& !cached.has_expired()
60
+
{
61
+
return cached.media_result.clone();
62
+
}
63
+
}
64
+
65
+
synced_fetch(cache, ttl, fetcher).await
66
+
}
67
+
68
+
async fn synced_fetch<F, Fut>(cache: &MediaCache, ttl: Duration, fetcher: F) -> Option<Media>
69
+
where
70
+
F: FnOnce() -> Fut,
71
+
Fut: Future<Output = anyhow::Result<Media>>,
72
+
{
73
+
let mut cached = cache.write().await;
74
+
if let Some(cached) = &*cached
75
+
&& !cached.has_expired()
76
+
{
77
+
return cached.media_result.clone();
78
+
}
79
+
80
+
let result = fetcher()
81
+
.await
82
+
.map_err(|error| tracing::warn!(?error, "failed to scrape backend"))
83
+
.ok();
84
+
*cached = Some(CachedMediaResult {
85
+
media_result: result.clone(),
86
+
cache_time: Instant::now(),
87
+
ttl,
88
+
});
89
+
result
90
+
}
+15
-8
server/src/scrapers/letterboxd.rs
···
1
-
use std::{sync::LazyLock, time::Duration};
0
0
0
2
3
use anyhow::Context;
4
-
use cached::proc_macro::once;
5
use reqwest::{Client, Url};
6
use scraper::{ElementRef, Html, Selector};
7
use serde::Deserialize;
0
8
9
-
use super::Media;
0
0
0
10
11
#[derive(Deserialize, Debug, Clone)]
12
pub struct ImageUrlMetadata {
···
135
Ok(image_url)
136
}
137
138
-
#[once(time = 1800, option = false)]
0
0
0
0
139
pub async fn cached_fetch() -> Option<Media> {
140
-
fetch()
141
-
.await
142
-
.map_err(|error| tracing::warn!(?error, "failed to scrape Letterboxd"))
143
-
.ok()
144
}
···
1
+
use std::{
2
+
sync::{Arc, LazyLock},
3
+
time::Duration,
4
+
};
5
6
use anyhow::Context;
0
7
use reqwest::{Client, Url};
8
use scraper::{ElementRef, Html, Selector};
9
use serde::Deserialize;
10
+
use tokio::sync::RwLock;
11
12
+
use super::{
13
+
Media,
14
+
cached::{MediaCache, cache_or_fetch, try_cache_or_fetch},
15
+
};
16
17
#[derive(Deserialize, Debug, Clone)]
18
pub struct ImageUrlMetadata {
···
141
Ok(image_url)
142
}
143
144
+
static CACHE: LazyLock<MediaCache> = LazyLock::new(|| Arc::new(RwLock::new(None)));
145
+
static TTL: Duration = Duration::from_secs(1800);
146
+
pub fn try_cached_fetch() -> Option<Media> {
147
+
try_cache_or_fetch(&CACHE, TTL, fetch)
148
+
}
149
pub async fn cached_fetch() -> Option<Media> {
150
+
cache_or_fetch(&CACHE, TTL, fetch).await
0
0
0
151
}
+21
-6
server/templates/index.html
···
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
<link rel="stylesheet" href="build/app.css" />
7
<link rel="me" href="https://hachyderm.io/@cherry" />
8
-
<script defer src="build/app.js" type="module"></script>
9
</head>
10
<body class="bg-gray-900">
11
<div class="flex flex-col">
···
14
<span class="text-pink-700">cherry</span>.computer
15
</h1>
16
</div>
17
-
<div class="m-4 flex max-w-2xl flex-col items-center gap-4 self-center">
0
0
18
<div
19
class="max-w-xl text-justify font-mono text-2xl text-pink-100 uppercase"
20
>
···
29
interrogate the apparent complex further.
30
</p>
31
</div>
32
-
{% if media.len() > 0 -%}
33
<h2 class="self-start text-3xl text-pink-50">
34
Here is what I've {{ consumption_verb }} most recently:
35
</h2>
36
-
<div class="grid grid-cols-2 hoverable:grid-cols-{{ media.len() }}">
37
-
{% for media in media -%} {% include "media.html" %} {%- endfor %}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
38
</div>
39
-
{%- endif %}
40
<p class="font-mono text-3xl text-pink-50">Free Palestine 🇵🇸</p>
41
</div>
42
</div>
···
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
<link rel="stylesheet" href="build/app.css" />
7
<link rel="me" href="https://hachyderm.io/@cherry" />
8
+
<script src="build/app.js" type="module"></script>
9
</head>
10
<body class="bg-gray-900">
11
<div class="flex flex-col">
···
14
<span class="text-pink-700">cherry</span>.computer
15
</h1>
16
</div>
17
+
<div
18
+
class="flex w-full max-w-2xl flex-col items-center gap-4 self-center p-4"
19
+
>
20
<div
21
class="max-w-xl text-justify font-mono text-2xl text-pink-100 uppercase"
22
>
···
31
interrogate the apparent complex further.
32
</p>
33
</div>
0
34
<h2 class="self-start text-3xl text-pink-50">
35
Here is what I've {{ consumption_verb }} most recently:
36
</h2>
37
+
<div
38
+
class="grid gap-4 w-full grid-cols-2 hoverable:grid-cols-{{ media.len() }}"
39
+
>
40
+
{% for media in media -%} {% match media %} {% when (media_type,
41
+
Some(media)) %} {% include "media.html" %} {% when (media_type, None)
42
+
%}
43
+
<div
44
+
class="aspect-square w-full max-w-50 animate-pulse justify-self-center rounded-xs bg-gray-800 hoverable:max-w-none"
45
+
hx-get="/media/{{ media_type }}"
46
+
hx-swap="outerHTML"
47
+
hx-trigger="load"
48
+
></div>
49
+
<div
50
+
id="media-description-{{ media_type }}"
51
+
class="hoverable:hidden"
52
+
></div>
53
+
{% endmatch %} {%- endfor %}
54
</div>
0
55
<p class="font-mono text-3xl text-pink-50">Free Palestine 🇵🇸</p>
56
</div>
57
</div>
+4
-2
server/templates/media.html
···
3
{% else %}
4
<div
5
{% endif %}
6
-
class="peer/{{ loop.index }} relative m-2 aspect-square max-h-50 justify-self-center">
7
<img
8
class="absolute inset-0 aspect-square rounded-xs object-fill"
9
aria-hidden="true"
···
26
{% else %}
27
<div
28
{% endif %}
29
-
class="mx-2 mt-4 flex flex-col self-center peer-hover/{{ loop.index }}:block hoverable:col-span-full hoverable:row-2 hoverable:hidden"
0
0
30
>
31
<p class="text-2xl text-white">{{ media.name }}</p>
32
<p class="text-xl text-gray-700 italic">{{ media.context }}</p>
···
3
{% else %}
4
<div
5
{% endif %}
6
+
class="peer/{{ media_type }} relative aspect-square max-h-50 justify-self-center hoverable:max-h-none">
7
<img
8
class="absolute inset-0 aspect-square rounded-xs object-fill"
9
aria-hidden="true"
···
26
{% else %}
27
<div
28
{% endif %}
29
+
id="media-description-{{ media_type }}"
30
+
class="mx-2 mt-4 flex flex-col self-center peer-hover/{{ media_type }}:block hoverable:col-span-full hoverable:row-2 hoverable:hidden"
31
+
hx-swap-oob="true"
32
>
33
<p class="text-2xl text-white">{{ media.name }}</p>
34
<p class="text-xl text-gray-700 italic">{{ media.context }}</p>