feat(03-05): PWA manifest, service worker, icons, and registration hook
- web/public/manifest.json with display=standalone, theme_color=#faff69, 192+512 icons - web/public/sw.js with app-shell cache strategy (API calls network-only) - web/public/icons/icon-192.png and icon-512.png generated via gen-icons.cjs - web/scripts/gen-icons.cjs pure-Node.js PNG icon generator (black canvas, volt H monogram) - web/src/hooks/usePWA.ts registers service worker on app load - web/index.html: added theme-color meta tag - web/src/App.tsx: calls usePWA() hook
This commit is contained in:
parent
86d0a949c5
commit
95a50f4abd
8 changed files with 194 additions and 0 deletions
|
|
@ -5,6 +5,7 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#faff69" />
|
||||
<title>HWLab</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||
|
|
|
|||
BIN
web/public/icons/icon-192.png
Normal file
BIN
web/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 698 B |
BIN
web/public/icons/icon-512.png
Normal file
BIN
web/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
26
web/public/manifest.json
Normal file
26
web/public/manifest.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "HWLab",
|
||||
"short_name": "HWLab",
|
||||
"description": "Self-hosted hardware inventory with AI photo intake",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#faff69",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "productivity"],
|
||||
"screenshots": []
|
||||
}
|
||||
56
web/public/sw.js
Normal file
56
web/public/sw.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
const CACHE_NAME = 'hwlab-shell-v1'
|
||||
|
||||
// App shell assets to cache on install
|
||||
const SHELL_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
]
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS))
|
||||
)
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
// Remove old caches
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
)
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// API requests: network only — never cache
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Navigation requests: serve from cache, fall back to network (app shell)
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
caches.match('/').then((cached) => cached ?? fetch(event.request))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Static assets: cache-first
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(
|
||||
(cached) => cached ?? fetch(event.request).then((response) => {
|
||||
if (response.ok && event.request.method === 'GET') {
|
||||
const clone = response.clone()
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
|
||||
}
|
||||
return response
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
91
web/scripts/gen-icons.cjs
Normal file
91
web/scripts/gen-icons.cjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env node
|
||||
// Generates minimal PNG icons for the PWA manifest.
|
||||
// Uses pure Node.js — no canvas or image library needed.
|
||||
// Output: valid PNG icons with an "H" monogram on black background with volt (#faff69) color.
|
||||
// For production, replace with properly designed icons.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const zlib = require('zlib')
|
||||
|
||||
function makePNG(size, bgHex, fgHex) {
|
||||
// PNG signature
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
|
||||
// IHDR chunk
|
||||
const ihdrData = Buffer.alloc(13)
|
||||
ihdrData.writeUInt32BE(size, 0)
|
||||
ihdrData.writeUInt32BE(size, 4)
|
||||
ihdrData[8] = 8 // bit depth
|
||||
ihdrData[9] = 2 // color type RGB
|
||||
ihdrData[10] = 0 // compression
|
||||
ihdrData[11] = 0 // filter
|
||||
ihdrData[12] = 0 // interlace
|
||||
const ihdr = makeChunk('IHDR', ihdrData)
|
||||
|
||||
// IDAT chunk — raw RGB pixel data, filtered (filter byte 0 = none per row)
|
||||
const bg = hexToRgb(bgHex)
|
||||
const fg = hexToRgb(fgHex)
|
||||
const rawRows = []
|
||||
const margin = Math.floor(size * 0.2)
|
||||
for (let y = 0; y < size; y++) {
|
||||
const row = [0] // filter byte
|
||||
for (let x = 0; x < size; x++) {
|
||||
// Simple "H" monogram in the center
|
||||
const inMargin = x >= margin && x < size - margin && y >= margin && y < size - margin
|
||||
const isStroke =
|
||||
inMargin &&
|
||||
((x < margin + Math.floor(size * 0.08) || x > size - margin - Math.floor(size * 0.08)) ||
|
||||
(Math.abs(y - size / 2) < Math.floor(size * 0.06)))
|
||||
const c = isStroke ? fg : bg
|
||||
row.push(c[0], c[1], c[2])
|
||||
}
|
||||
rawRows.push(Buffer.from(row))
|
||||
}
|
||||
const rawData = Buffer.concat(rawRows)
|
||||
const compressed = zlib.deflateSync(rawData)
|
||||
const idat = makeChunk('IDAT', compressed)
|
||||
|
||||
// IEND
|
||||
const iend = makeChunk('IEND', Buffer.alloc(0))
|
||||
|
||||
return Buffer.concat([sig, ihdr, idat, iend])
|
||||
}
|
||||
|
||||
function makeChunk(type, data) {
|
||||
const len = Buffer.alloc(4)
|
||||
len.writeUInt32BE(data.length)
|
||||
const typeBytes = Buffer.from(type)
|
||||
const crc = crc32(Buffer.concat([typeBytes, data]))
|
||||
const crcBuf = Buffer.alloc(4)
|
||||
crcBuf.writeInt32BE(crc)
|
||||
return Buffer.concat([len, typeBytes, data, crcBuf])
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const n = parseInt(hex.replace('#', ''), 16)
|
||||
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
|
||||
}
|
||||
|
||||
function crc32(buf) {
|
||||
let crc = 0xffffffff
|
||||
const table = makeCRCTable()
|
||||
for (const b of buf) crc = (crc >>> 8) ^ table[(crc ^ b) & 0xff]
|
||||
return (crc ^ 0xffffffff) | 0
|
||||
}
|
||||
|
||||
function makeCRCTable() {
|
||||
const t = new Int32Array(256)
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i
|
||||
for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
t[i] = c
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
const iconsDir = path.join(__dirname, '..', 'public', 'icons')
|
||||
fs.mkdirSync(iconsDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(iconsDir, 'icon-192.png'), makePNG(192, '#000000', '#faff69'))
|
||||
fs.writeFileSync(path.join(iconsDir, 'icon-512.png'), makePNG(512, '#000000', '#faff69'))
|
||||
console.log('Icons generated: icon-192.png, icon-512.png')
|
||||
|
|
@ -3,8 +3,10 @@ import { RouterProvider } from '@tanstack/react-router'
|
|||
import { Toaster } from 'react-hot-toast'
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { router } from '@/router'
|
||||
import { usePWA } from '@/hooks/usePWA'
|
||||
|
||||
export function App() {
|
||||
usePWA()
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
|
|
|
|||
18
web/src/hooks/usePWA.ts
Normal file
18
web/src/hooks/usePWA.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
export function usePWA() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then((reg) => {
|
||||
console.log('[PWA] Service worker registered:', reg.scope)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[PWA] Service worker registration failed:', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue