Add dark mode toggle, PWA support, remove unused PdfViewer
This commit is contained in:
parent
287debeafc
commit
8f2c5ac074
@ -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
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
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'))
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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