Compare commits
7 Commits
287debeafc
...
6f3c203898
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f3c203898 | |||
| cf37337d6f | |||
| 717bf83459 | |||
| 07a93d2bb7 | |||
| 269ec0253c | |||
| 8f2c5ac074 | |||
| 74988ffd1f |
@ -8,6 +8,10 @@
|
||||
# Compression
|
||||
encode gzip
|
||||
|
||||
# Service worker must not be cached
|
||||
@sw path /sw.js
|
||||
header @sw Cache-Control "no-cache, no-store, must-revalidate"
|
||||
|
||||
# Cache static assets
|
||||
@static {
|
||||
path *.jpg *.jpeg *.png *.gif *.ico *.css *.js *.pdf *.svg *.woff *.woff2
|
||||
|
||||
409
site/package-lock.json
generated
409
site/package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^4.4.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
@ -21,7 +22,6 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-pdf": "^10.3.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
@ -1397,256 +1397,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz",
|
||||
"integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.88",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.88",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.88",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.88",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.88",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.88",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.88",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.88",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.88",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.88"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz",
|
||||
"integrity": "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz",
|
||||
"integrity": "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz",
|
||||
"integrity": "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz",
|
||||
"integrity": "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz",
|
||||
"integrity": "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz",
|
||||
"integrity": "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz",
|
||||
"integrity": "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz",
|
||||
"integrity": "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz",
|
||||
"integrity": "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz",
|
||||
"integrity": "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.88",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz",
|
||||
"integrity": "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@oslojs/encoding": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
||||
@ -1756,6 +1506,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
@ -4759,18 +4563,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
@ -4806,24 +4598,6 @@
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-cancellable-promise": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
|
||||
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-event-props": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
|
||||
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@ -5065,23 +4839,6 @@
|
||||
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/merge-refs": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
|
||||
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@ -5852,18 +5609,6 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/piccolore": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
|
||||
@ -5975,35 +5720,6 @@
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-pdf": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz",
|
||||
"integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"dequal": "^2.0.3",
|
||||
"make-cancellable-promise": "^2.0.0",
|
||||
"make-event-props": "^2.0.0",
|
||||
"merge-refs": "^2.0.0",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"tiny-invariant": "^1.0.0",
|
||||
"warning": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@ -6597,12 +6313,6 @@
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
@ -7185,15 +6895,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^4.4.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
@ -22,7 +23,6 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-pdf": "^10.3.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
|
||||
BIN
site/public/icons/apple-touch-icon.png
Normal file
BIN
site/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
site/public/icons/icon-192.png
Normal file
BIN
site/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
site/public/icons/icon-512-maskable.png
Normal file
BIN
site/public/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
site/public/icons/icon-512.png
Normal file
BIN
site/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
32
site/public/manifest.json
Normal file
32
site/public/manifest.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Electronics Reference Library",
|
||||
"short_name": "Mims Library",
|
||||
"description": "Classic electronics reference notebooks from Forrest M. Mims III",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f7f3ee",
|
||||
"theme_color": "#3b5998",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
66
site/public/offline.html
Normal file
66
site/public/offline.html
Normal file
@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline | Electronics Reference Library</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f7f3ee;
|
||||
color: #2c3e5a;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
p {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: #6b7f99;
|
||||
font-size: 0.95rem;
|
||||
max-width: 28rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
button {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border: 1px solid #c5cdd8;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
color: #2c3e5a;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
button:hover {
|
||||
background: #eef1f5;
|
||||
border-color: #a0aec0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#3b5998" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>You're offline</h1>
|
||||
<p>Previously viewed pages and downloaded PDFs may still be available. Check your connection and try again.</p>
|
||||
<button onclick="location.reload()">Try again</button>
|
||||
</body>
|
||||
</html>
|
||||
75
site/public/sw.js
Normal file
75
site/public/sw.js
Normal file
@ -0,0 +1,75 @@
|
||||
const CACHE_NAME = 'mims-library-v1';
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/offline.html',
|
||||
'/favicon.svg',
|
||||
'/manifest.json'
|
||||
];
|
||||
|
||||
// Install: precache essential resources
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch: tiered caching strategy
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only handle same-origin
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
// Static assets: cache-first
|
||||
if (/\.(css|js|jpg|jpeg|png|gif|svg|woff|woff2|ico)$/.test(url.pathname)) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) =>
|
||||
cached || fetch(request).then((response) => {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
return response;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// PDFs: network-first, cache on success
|
||||
if (/\.pdf$/.test(url.pathname)) {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML pages: network-first with offline fallback
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
return response;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.match(request).then((cached) => cached || caches.match('/offline.html'))
|
||||
)
|
||||
);
|
||||
});
|
||||
167
site/src/components/FilterableBookGrid.tsx
Normal file
167
site/src/components/FilterableBookGrid.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { BookData } from '@/lib/types';
|
||||
|
||||
interface FilterableBookGridProps {
|
||||
books: BookData[];
|
||||
allTopics: string[];
|
||||
collectionSlug: string;
|
||||
}
|
||||
|
||||
export default function FilterableBookGrid({ books, allTopics, collectionSlug }: FilterableBookGridProps) {
|
||||
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
|
||||
|
||||
// Read URL params on mount
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const topicsParam = params.get('topics');
|
||||
if (topicsParam) {
|
||||
setSelectedTopics(new Set(topicsParam.split(',').filter(Boolean)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync to URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (selectedTopics.size > 0) {
|
||||
params.set('topics', Array.from(selectedTopics).join(','));
|
||||
} else {
|
||||
params.delete('topics');
|
||||
}
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params}`
|
||||
: window.location.pathname;
|
||||
history.replaceState(null, '', newUrl);
|
||||
}, [selectedTopics]);
|
||||
|
||||
const toggleTopic = (topic: string) => {
|
||||
setSelectedTopics((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(topic)) {
|
||||
next.delete(topic);
|
||||
} else {
|
||||
next.add(topic);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => setSelectedTopics(new Set());
|
||||
|
||||
// OR filtering: show books matching ANY selected topic
|
||||
const filteredBooks = selectedTopics.size === 0
|
||||
? books
|
||||
: books.filter((book) => book.topics.some((t) => selectedTopics.has(t)));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Topic filter bar */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{allTopics.map((topic) => {
|
||||
const isActive = selectedTopics.has(topic);
|
||||
return (
|
||||
<button
|
||||
key={topic}
|
||||
onClick={() => toggleTopic(topic)}
|
||||
className={`inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground border-transparent'
|
||||
: 'bg-secondary text-secondary-foreground border-transparent hover:bg-secondary/80'
|
||||
}`}
|
||||
>
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedTopics.size > 0 && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Count */}
|
||||
{selectedTopics.size > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {filteredBooks.length} of {books.length} {books.length === 1 ? 'book' : 'books'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Book grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredBooks.map((book) => (
|
||||
<a
|
||||
key={book.slug}
|
||||
href={`/${book.collection}/${book.slug}`}
|
||||
className="book-card block bg-card rounded-lg border border-border overflow-hidden hover:border-primary/50"
|
||||
>
|
||||
<div className="relative">
|
||||
{book.coverImage ? (
|
||||
<img
|
||||
src={book.coverImage}
|
||||
alt={`Cover of ${book.shortTitle}`}
|
||||
className="w-full aspect-[3/4] object-cover object-top"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-[3/4] bg-muted flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{book.year && (
|
||||
<div className="absolute top-2 right-2 bg-card/90 backdrop-blur-sm text-xs font-medium px-2 py-1 rounded border border-border">
|
||||
{book.year}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground line-clamp-2 leading-tight" style={{ fontFamily: "Georgia, 'Times New Roman', serif", letterSpacing: '-0.02em' }}>
|
||||
{book.shortTitle}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{book.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{book.topics.slice(0, 3).map((topic) => (
|
||||
<Badge key={topic} variant="secondary" className="text-xs">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-2 border-t border-border flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
PDF
|
||||
</span>
|
||||
<span className="text-primary font-medium">View →</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredBooks.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p className="text-lg font-medium">No books match the selected topics</p>
|
||||
<button onClick={clearFilters} className="mt-2 text-primary hover:underline text-sm cursor-pointer">
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface PdfViewerProps {
|
||||
pdfUrl: string;
|
||||
}
|
||||
|
||||
export default function PdfViewer({ pdfUrl }: PdfViewerProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
<span>PDF Document</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="h-8"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1.5">
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3"/>
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3"/>
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3"/>
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3"/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Compact</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1.5">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Expand</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<a
|
||||
href={pdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" x2="21" y1="14" y2="3"/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Open in new tab</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={pdfUrl}
|
||||
download
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" x2="12" y1="15" y2="3"/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF Embed */}
|
||||
<div className={`bg-neutral-200 dark:bg-neutral-800 ${isFullscreen ? 'h-[85vh]' : 'h-[600px]'} transition-all duration-300`}>
|
||||
<iframe
|
||||
src={`${pdfUrl}#toolbar=1&navpanes=1&scrollbar=1`}
|
||||
className="w-full h-full border-0"
|
||||
title="PDF Viewer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fallback message */}
|
||||
<div className="p-4 text-center text-sm text-muted-foreground bg-muted/30 border-t border-border">
|
||||
<p>
|
||||
PDF not displaying? Try{' '}
|
||||
<a href={pdfUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
opening directly
|
||||
</a>{' '}
|
||||
or{' '}
|
||||
<a href={pdfUrl} download className="text-primary hover:underline">
|
||||
downloading the file
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
site/src/components/RelatedBooks.astro
Normal file
70
site/src/components/RelatedBooks.astro
Normal file
@ -0,0 +1,70 @@
|
||||
---
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface RelatedBookResult {
|
||||
book: CollectionEntry<'books'>;
|
||||
sharedTopics: string[];
|
||||
overlapCount: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
relatedBooks: RelatedBookResult[];
|
||||
currentCollection: string;
|
||||
}
|
||||
|
||||
const { relatedBooks, currentCollection } = Astro.props;
|
||||
---
|
||||
|
||||
{relatedBooks.length > 0 && (
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-lg font-semibold title-accent text-foreground">Related Notebooks</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{relatedBooks.map(({ book, sharedTopics }) => {
|
||||
const slug = book.slug.split('/').pop();
|
||||
const isCrossCollection = book.data.collection !== currentCollection;
|
||||
return (
|
||||
<a
|
||||
href={`/${book.data.collection}/${slug}`}
|
||||
class="book-card block bg-card rounded-lg border border-border overflow-hidden hover:border-primary/50"
|
||||
>
|
||||
<div class="relative">
|
||||
{book.data.coverImage ? (
|
||||
<img
|
||||
src={book.data.coverImage}
|
||||
alt={`Cover of ${book.data.shortTitle}`}
|
||||
class="w-full aspect-[3/4] object-cover object-top"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div class="w-full aspect-[3/4] bg-muted flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{isCrossCollection && (
|
||||
<div class="absolute top-2 left-2 bg-card/90 backdrop-blur-sm text-[10px] font-medium px-1.5 py-0.5 rounded border border-border">
|
||||
{book.data.collection === 'mims' ? 'Mims' : "Ugly's"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="p-3 space-y-2">
|
||||
<h3 class="text-sm font-semibold text-foreground title-accent line-clamp-2 leading-tight">
|
||||
{book.data.shortTitle}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{sharedTopics.slice(0, 2).map((topic) => (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
195
site/src/components/SearchDialog.tsx
Normal file
195
site/src/components/SearchDialog.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { BookData } from '@/lib/types';
|
||||
|
||||
interface SearchDialogProps {
|
||||
books: BookData[];
|
||||
}
|
||||
|
||||
export default function SearchDialog({ books }: SearchDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Cmd+K / Ctrl+K to open
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
// Focus input on open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
setQuery('');
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const filtered = query.trim()
|
||||
? books.filter((book) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
book.title.toLowerCase().includes(q) ||
|
||||
book.shortTitle.toLowerCase().includes(q) ||
|
||||
book.description.toLowerCase().includes(q) ||
|
||||
book.topics.some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
})
|
||||
: books;
|
||||
|
||||
// Reset index when query changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [query]);
|
||||
|
||||
const navigate = useCallback((book: BookData) => {
|
||||
setOpen(false);
|
||||
window.location.href = `/${book.collection}/${book.slug}`;
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' && filtered[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
navigate(filtered[selectedIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll selected into view
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const selected = list.children[selectedIndex] as HTMLElement | undefined;
|
||||
selected?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
aria-label="Search books"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<span className="hidden md:inline">Search</span>
|
||||
<kbd className="hidden md:inline-flex items-center gap-0.5 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-xl p-0 gap-0 overflow-hidden">
|
||||
<DialogTitle className="sr-only">Search books</DialogTitle>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground shrink-0">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search books, topics..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={listRef} className="max-h-80 overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No results found for "{query}"
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((book, index) => (
|
||||
<button
|
||||
key={book.slug}
|
||||
onClick={() => navigate(book)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors cursor-pointer ${
|
||||
index === selectedIndex ? 'bg-accent text-accent-foreground' : ''
|
||||
}`}
|
||||
>
|
||||
{book.coverImage ? (
|
||||
<img
|
||||
src={book.coverImage}
|
||||
alt=""
|
||||
className="w-10 h-13 object-cover rounded border border-border shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-13 bg-muted rounded border border-border flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{book.shortTitle}</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{book.collection === 'mims' ? 'Mims' : "Ugly's"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{book.description}</p>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground shrink-0">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hints */}
|
||||
<div className="border-t border-border px-4 py-2 flex items-center gap-4 text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 rounded border border-border bg-muted text-[10px]">↑↓</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 rounded border border-border bg-muted text-[10px]">↵</kbd>
|
||||
Open
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1 py-0.5 rounded border border-border bg-muted text-[10px]">Esc</kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
site/src/components/ThemeToggle.tsx
Normal file
85
site/src/components/ThemeToggle.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>('system');
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme | null;
|
||||
if (stored) {
|
||||
setTheme(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
function applyTheme(t: Theme) {
|
||||
if (t === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else if (t === 'light') {
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
// system
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyTheme(theme);
|
||||
|
||||
if (theme === 'system') {
|
||||
localStorage.removeItem('theme');
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => applyTheme('system');
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
} else {
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const icon = theme === 'dark' ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : theme === 'light' ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Toggle theme">
|
||||
{icon}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
<Sun className="h-4 w-4" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
<Moon className="h-4 w-4" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
<Monitor className="h-4 w-4" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
121
site/src/components/ui/dialog.tsx
Normal file
121
site/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@ -1,5 +1,9 @@
|
||||
---
|
||||
import '@/styles/global.css';
|
||||
import ThemeToggle from '@/components/ThemeToggle';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { serializeBook } from '@/lib/types';
|
||||
import SearchDialog from '@/components/SearchDialog';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@ -7,6 +11,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const { title, description = "Classic electronics reference notebooks from Forrest M. Mims III" } = Astro.props;
|
||||
|
||||
const allBooks = await getCollection('books');
|
||||
const allBooksData = allBooks.map(serializeBook);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@ -16,7 +23,19 @@ const { title, description = "Classic electronics reference notebooks from Forre
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#3b5998" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<title>{title} | Electronics Reference Library</title>
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="min-h-screen graph-paper-large">
|
||||
<header class="border-b border-border bg-card/80 backdrop-blur-sm sticky top-0 z-50">
|
||||
@ -48,6 +67,8 @@ const { title, description = "Classic electronics reference notebooks from Forre
|
||||
>
|
||||
Ugly's
|
||||
</a>
|
||||
<SearchDialog books={allBooksData} client:load />
|
||||
<ThemeToggle client:load />
|
||||
<a
|
||||
href="https://archive.org"
|
||||
target="_blank"
|
||||
@ -82,5 +103,12 @@ const { title, description = "Classic electronics reference notebooks from Forre
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script is:inline>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
45
site/src/lib/related-books.ts
Normal file
45
site/src/lib/related-books.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface RelatedBookResult {
|
||||
book: CollectionEntry<'books'>;
|
||||
sharedTopics: string[];
|
||||
overlapCount: number;
|
||||
}
|
||||
|
||||
function normalizeTopic(topic: string): string {
|
||||
return topic.toLowerCase().replace(/[\s_]+/g, '-');
|
||||
}
|
||||
|
||||
export function getRelatedBooks(
|
||||
currentBook: CollectionEntry<'books'>,
|
||||
allBooks: CollectionEntry<'books'>[],
|
||||
maxResults = 4
|
||||
): RelatedBookResult[] {
|
||||
const currentTopics = new Set(currentBook.data.topics.map(normalizeTopic));
|
||||
|
||||
return allBooks
|
||||
.filter((b) => b.slug !== currentBook.slug)
|
||||
.map((book) => {
|
||||
const bookTopics = new Set(book.data.topics.map(normalizeTopic));
|
||||
const sharedTopics = [...currentTopics].filter((t) => bookTopics.has(t));
|
||||
const union = new Set([...currentTopics, ...bookTopics]);
|
||||
const jaccard = union.size > 0 ? sharedTopics.length / union.size : 0;
|
||||
|
||||
return {
|
||||
book,
|
||||
sharedTopics: sharedTopics.map((t) =>
|
||||
book.data.topics.find((orig) => normalizeTopic(orig) === t) || t
|
||||
),
|
||||
overlapCount: sharedTopics.length,
|
||||
jaccard,
|
||||
};
|
||||
})
|
||||
.filter((r) => r.overlapCount > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.overlapCount !== a.overlapCount) return b.overlapCount - a.overlapCount;
|
||||
if (b.jaccard !== a.jaccard) return b.jaccard - a.jaccard;
|
||||
return a.book.data.sortOrder - b.book.data.sortOrder;
|
||||
})
|
||||
.slice(0, maxResults)
|
||||
.map(({ book, sharedTopics, overlapCount }) => ({ book, sharedTopics, overlapCount }));
|
||||
}
|
||||
29
site/src/lib/types.ts
Normal file
29
site/src/lib/types.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
export interface BookData {
|
||||
slug: string;
|
||||
collection: 'mims' | 'uglys' | 'other';
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
description: string;
|
||||
topics: string[];
|
||||
localPdf: string;
|
||||
coverImage?: string;
|
||||
year?: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export function serializeBook(entry: CollectionEntry<'books'>): BookData {
|
||||
return {
|
||||
slug: entry.slug.split('/').pop()!,
|
||||
collection: entry.data.collection,
|
||||
title: entry.data.title,
|
||||
shortTitle: entry.data.shortTitle,
|
||||
description: entry.data.description,
|
||||
topics: entry.data.topics,
|
||||
localPdf: entry.data.localPdf,
|
||||
coverImage: entry.data.coverImage,
|
||||
year: entry.data.year,
|
||||
sortOrder: entry.data.sortOrder,
|
||||
};
|
||||
}
|
||||
@ -3,6 +3,8 @@ import Layout from '@/layouts/Layout.astro';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import EBookReader from '@/components/EBookReader';
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
import { getRelatedBooks } from '@/lib/related-books';
|
||||
import RelatedBooks from '@/components/RelatedBooks.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const books = await getCollection('books');
|
||||
@ -30,6 +32,7 @@ const mimsBooks = allBooks
|
||||
const currentIndex = mimsBooks.findIndex(b => b.slug === book.slug);
|
||||
const prevBook = currentIndex > 0 ? mimsBooks[currentIndex - 1] : null;
|
||||
const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex + 1] : null;
|
||||
const relatedBooks = getRelatedBooks(book, allBooks);
|
||||
---
|
||||
|
||||
<Layout title={shortTitle} description={description}>
|
||||
@ -68,16 +71,13 @@ const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex +
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{topics.slice(0, 5).map((topic) => (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
{topics.map((topic) => (
|
||||
<a href={`/mims?topics=${topic}`}>
|
||||
<Badge variant="secondary" className="text-xs hover:bg-primary hover:text-primary-foreground transition-colors">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
</a>
|
||||
))}
|
||||
{topics.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{topics.length - 5} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -124,6 +124,9 @@ const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex +
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Related Books -->
|
||||
<RelatedBooks relatedBooks={relatedBooks} currentCollection="mims" />
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex items-center justify-between pt-8 border-t border-border">
|
||||
{prevBook ? (
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import BookGrid from '@/components/BookGrid.astro';
|
||||
import FilterableBookGrid from '@/components/FilterableBookGrid';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { serializeBook } from '@/lib/types';
|
||||
|
||||
const allBooks = await getCollection('books');
|
||||
const mimsBooks = allBooks
|
||||
@ -10,6 +11,7 @@ const mimsBooks = allBooks
|
||||
|
||||
// Get unique topics for filtering
|
||||
const allTopics = [...new Set(mimsBooks.flatMap(book => book.data.topics))].sort();
|
||||
const serializedBooks = mimsBooks.map(serializeBook);
|
||||
---
|
||||
|
||||
<Layout
|
||||
@ -48,17 +50,8 @@ const allTopics = [...new Set(mimsBooks.flatMap(book => book.data.topics))].sort
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Topic tags -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{allTopics.map((topic) => (
|
||||
<span class="px-3 py-1 text-xs font-medium rounded-full bg-muted text-muted-foreground">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<BookGrid books={mimsBooks} />
|
||||
<!-- Filterable grid with topic tags -->
|
||||
<FilterableBookGrid books={serializedBooks} allTopics={allTopics} collectionSlug="mims" client:load />
|
||||
|
||||
<!-- About the Author -->
|
||||
<section class="mt-12 p-6 md:p-8 rounded-lg bg-card border border-border space-y-8">
|
||||
|
||||
@ -3,6 +3,8 @@ import Layout from '@/layouts/Layout.astro';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import EBookReader from '@/components/EBookReader';
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
import { getRelatedBooks } from '@/lib/related-books';
|
||||
import RelatedBooks from '@/components/RelatedBooks.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const books = await getCollection('books');
|
||||
@ -30,6 +32,7 @@ const uglysBooks = allBooks
|
||||
const currentIndex = uglysBooks.findIndex(b => b.slug === book.slug);
|
||||
const prevBook = currentIndex > 0 ? uglysBooks[currentIndex - 1] : null;
|
||||
const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex + 1] : null;
|
||||
const relatedBooks = getRelatedBooks(book, allBooks);
|
||||
---
|
||||
|
||||
<Layout title={shortTitle} description={description}>
|
||||
@ -68,16 +71,13 @@ const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{topics.slice(0, 5).map((topic) => (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
{topics.map((topic) => (
|
||||
<a href={`/uglys?topics=${topic}`}>
|
||||
<Badge variant="secondary" className="text-xs hover:bg-primary hover:text-primary-foreground transition-colors">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
</a>
|
||||
))}
|
||||
{topics.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{topics.length - 5} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -124,6 +124,9 @@ const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Related Books -->
|
||||
<RelatedBooks relatedBooks={relatedBooks} currentCollection="uglys" />
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex items-center justify-between pt-8 border-t border-border">
|
||||
{prevBook ? (
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import BookGrid from '@/components/BookGrid.astro';
|
||||
import FilterableBookGrid from '@/components/FilterableBookGrid';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { serializeBook } from '@/lib/types';
|
||||
|
||||
const allBooks = await getCollection('books');
|
||||
const uglysBooks = allBooks
|
||||
@ -10,6 +11,7 @@ const uglysBooks = allBooks
|
||||
|
||||
// Get unique topics for filtering
|
||||
const allTopics = [...new Set(uglysBooks.flatMap(book => book.data.topics))].sort();
|
||||
const serializedBooks = uglysBooks.map(serializeBook);
|
||||
---
|
||||
|
||||
<Layout
|
||||
@ -48,19 +50,8 @@ const allTopics = [...new Set(uglysBooks.flatMap(book => book.data.topics))].sor
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Topic tags -->
|
||||
{allTopics.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{allTopics.map((topic) => (
|
||||
<span class="px-3 py-1 text-xs font-medium rounded-full bg-muted text-muted-foreground">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Grid -->
|
||||
<BookGrid books={uglysBooks} />
|
||||
<!-- Filterable grid with topic tags -->
|
||||
<FilterableBookGrid books={serializedBooks} allTopics={allTopics} collectionSlug="uglys" client:load />
|
||||
|
||||
<!-- Info box -->
|
||||
<div class="mt-12 p-6 rounded-lg bg-card border border-border">
|
||||
|
||||
@ -193,4 +193,36 @@
|
||||
height: 12px;
|
||||
background: oklch(0.55 0.15 145);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.dark .graph-paper-large {
|
||||
background-image:
|
||||
linear-gradient(to right, oklch(0.3 0.01 230 / 0.15) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.3 0.01 230 / 0.15) 1px, transparent 1px),
|
||||
linear-gradient(to right, oklch(0.35 0.02 230 / 0.25) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.35 0.02 230 / 0.25) 1px, transparent 1px);
|
||||
background-size: 10px 10px, 10px 10px, 50px 50px, 50px 50px;
|
||||
}
|
||||
|
||||
.dark .book-card:hover {
|
||||
box-shadow: 0 12px 24px -8px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
.dark .book-cover {
|
||||
border-color: oklch(1 0 0 / 10%);
|
||||
}
|
||||
|
||||
.dark .circuit-border {
|
||||
border-color: oklch(0.65 0.15 145);
|
||||
}
|
||||
|
||||
.dark .circuit-border::before,
|
||||
.dark .circuit-border::after {
|
||||
background: oklch(0.65 0.15 145);
|
||||
}
|
||||
|
||||
/* Smooth theme transition */
|
||||
html {
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user