Merge feature/dark-mode-pwa: dark mode toggle, PWA support, cleanup

This commit is contained in:
Ryan Malloy 2026-02-13 06:21:39 -07:00
commit 07a93d2bb7
13 changed files with 315 additions and 107 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

View File

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

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

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

@ -1,5 +1,6 @@
---
import '@/styles/global.css';
import ThemeToggle from '@/components/ThemeToggle';
interface Props {
title: string;
@ -16,7 +17,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 +61,7 @@ const { title, description = "Classic electronics reference notebooks from Forre
>
Ugly's
</a>
<ThemeToggle client:load />
<a
href="https://archive.org"
target="_blank"
@ -82,5 +96,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

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