diff --git a/web/index.html b/web/index.html index 11a3844..91e096a 100644 --- a/web/index.html +++ b/web/index.html @@ -5,6 +5,7 @@ + HWLab diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png new file mode 100644 index 0000000..a678aa6 Binary files /dev/null and b/web/public/icons/icon-192.png differ diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png new file mode 100644 index 0000000..db90e5f Binary files /dev/null and b/web/public/icons/icon-512.png differ diff --git a/web/public/manifest.json b/web/public/manifest.json new file mode 100644 index 0000000..c88bde5 --- /dev/null +++ b/web/public/manifest.json @@ -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": [] +} diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..38ff09a --- /dev/null +++ b/web/public/sw.js @@ -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 + }) + ) + ) +}) diff --git a/web/scripts/gen-icons.cjs b/web/scripts/gen-icons.cjs new file mode 100644 index 0000000..1378aaf --- /dev/null +++ b/web/scripts/gen-icons.cjs @@ -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') diff --git a/web/src/App.tsx b/web/src/App.tsx index 3f5cf86..534b85f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( diff --git a/web/src/hooks/usePWA.ts b/web/src/hooks/usePWA.ts new file mode 100644 index 0000000..fd6bf08 --- /dev/null +++ b/web/src/hooks/usePWA.ts @@ -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) + }) + }) + } + }, []) +}