Compare commits

...

7 Commits

24 changed files with 1039 additions and 505 deletions

View File

@ -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
View File

@ -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",

View File

@ -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"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

32
site/public/manifest.json Normal file
View 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
View 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
View 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'))
)
);
});

View 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>
);
}

View File

@ -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>
);
}

View 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>
)}

View 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>
</>
);
}

View 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>
);
}

View 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,
}

View File

@ -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>

View 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
View 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,
};
}

View File

@ -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 ? (

View File

@ -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">

View File

@ -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 ? (

View File

@ -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">

View File

@ -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;
}