From 692595c629db6ade403c376e49d2613f1ec06989 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 12 Aug 2026 11:28:23 -0400 Subject: [PATCH] Add PWA support and expandable draw canvas PWA: - manifest.json with name, icons, theme/background color, standalone display - PNG icons (192, 512, 180 apple-touch) generated via pure Node.js (no deps) - index.html: manifest link, theme-color, apple-mobile-web-app-* meta tags, viewport-fit=cover for iPhone notch/Dynamic Island - service worker extended to cache app shell (static assets + index.html) with network-first for navigation, cache-first for hashed assets, network-first-with-cache-fallback for external Bible APIs Expandable canvas: - "Add More Space" button below each draw canvas adds a full notebook page - Existing ink rescales to preserve visual pixel positions (no stretching) - Page count embedded as metadata in the strokes array so it survives navigation without a separate state key Co-Authored-By: Claude Sonnet 4.6 --- index.html | 17 +++++- public/apple-touch-icon.png | Bin 0 -> 694 bytes public/icon-192.png | Bin 0 -> 757 bytes public/icon-512.png | Bin 0 -> 3115 bytes public/manifest.json | 25 ++++++++ public/sw.js | 96 ++++++++++++++++++++++-------- scripts/generate-icons.mjs | 115 ++++++++++++++++++++++++++++++++++++ src/pages/DrawCanvas.jsx | 95 +++++++++++++++++++++-------- 8 files changed, 298 insertions(+), 50 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/manifest.json create mode 100644 scripts/generate-icons.mjs diff --git a/index.html b/index.html index 422698c..fc2ebcd 100644 --- a/index.html +++ b/index.html @@ -2,8 +2,23 @@ - + Bible Study Project + + + + + + + + + + + + + + +
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..140ed42000ad590d7e39bcf04de3c719c7e726e6 GIT binary patch literal 694 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6V5;?WaSW-r_4dv|&ISV!)`KMv zF3P>xZ+}#*#U=9jVY3fLak7t(-v0JDWc|nKpKmlc>+En8yP?3HGeNZFt>YAjw}Mj| zb1PB;X-~-u zY(ZRG3xQk-AlKEwJDw$10LXH7@OJQ?>?rM|dBv^b-kQz27aX`V7Bq@oaA4*&fw0Y( zAT*m)#sXHT7>L~nBp88;n4oN+Kon3{iF|K%U5Zy^iv{5JeyxdKnNlVX<`!+<1uNb&;$m!qG78Ko5X)g8TvW zDU1y>3=}FbHj$wLGZ^GMWbNC)0nO@oKb<4h0BES{f~Bqt7KU*dUto&*Wt6dE>LH#j zMG%2gjcrkyXB@QIrLvY9IIopSTQHTGcWcm$g<*WgS576iMOmH+&~BH^dJ2|YG4(Ld zwxS6O!-R})oN{D~_B@fG%_*6uD#*FVdQ&MBb@04<5| ARR910 literal 0 HcmV?d00001 diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..a0193d38c4456d686314fd2ccea5d892010930eb GIT binary patch literal 757 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;VA|v9;uunK>+PM5ybT6C437B@ zC;8s&m+#;WYH;|q`3twjJ~Lza^V{T?@A56Q*;6E8vtg2i&9X@XHuHdZ8W8tw60qsm z#9`Aii9;tol0$5r4pVoSSR<=;_kpCTt_cghlsEXKGDa-f#Gw-q$s)GggvmQttf5u2 z_rRtptO~^b7#A$BDt;r* zz`z1@H3I`9LjwbY0|SEs1A_nq0|$0th6GM@6%9uMXX=G8@Gvk)FfbS}FeHG(q=Auv zfti5;7%l`P7*N0;?kSc!C?aHWDEAei;2_Ic=$dZmqXqN?1|Xcqa3@*tl$<>S(*dIp zHipJa$N3u=Hi%4RbXf4^Bl7|q297CqbqrJ3Au^vB6as$zU<{CBVDXZ-XE_DI45yeI z99}+dcle2d*&Tl#Zg=RDXR@plc=EwpO(#6JuHLl5hLXpAvYhLBVb)eIGrwQe@PH_Nt*yGxg9JNG*$oz4WEv_0%&Av(97s&Z|d87K;_-xX#d%%AAbFAGc-GT zJlg-f_f6BCmA-2|OSm}k-dpZ#pa0x>=k6CjeNcO2y>F#!4|-tI6Q;cF1}r_X8nn%& zn}%wL3^k*Dyt+@Bk;y%=uHtKkB@^30TVGl;=;2&5TF8w7Cp?WSsw<6oOeTe?-Sz?m z+zr|?1Kemv&G9LfsWoHVO49~z6g8N34qH~3%;tGd??s}8*q@@Z!XY79v*n4*&J7nqeWs@eSBH$!aR^35tXKlRN@ zHb3&s3O1K~vz*O&Sb+Kio`QJ|i$N^Ii}_|5cX7;sQO3BjERLiSPO{SkoO776puea# z1O%W1X7Hq=VE$E6_{wFn~siJx=9r;r-Eosf?F!aC+MaS>7bj9r8wOz zBu2PsD$f6C8T*KYk>pEA1<$1QBN?`MhP#7&3`$=-Rs#7Unpg!XWKFD>6tepLc#Hc2 zG9X`gZ6=Mjl9GC~xckNMYnaVb^9#krI9RqrB5*Fns%@`#St`H9yo-x%%>QmKcrJ^w z6$?-GK5WVgCF8a{bf)v8QG2P#M~Ft-Q>TuRx+6s^Fj~bX;uTp?-!oYiaz+e=mg^JPkha(C+iGlUvom1M$d zEVNX;NuPnxn`Ve&lRgV!cBe`7hb%fL!hIOAZ608wpnJ)y6tn<^@5>cL^ga9ZIqNc< z8e*xP-(P^UTQA@;Wh^xabTU3XLzsmXD!|5h3AJ-V!r6WRdxu~tf}MN4hMX4$fVnNI z&B98#KzO=VBX1kizof(UuA8*>R14Hv26zRiX5ie`NEJHoO$@NRHh_gM;|+-IeJzE$ pe4$N8_)r|=sy4e literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..b2848f0 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Bible Study Project", + "short_name": "Bible Study", + "description": "Your personal verse-by-verse Bible study companion", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#f8fafc", + "theme_color": "#0f172a", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/public/sw.js b/public/sw.js index 3487066..c8798a5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,36 +1,80 @@ -const CACHE = 'bible-api-v1'; -const CACHEABLE = ['https://bible.helloao.org', 'https://bolls.life']; +const SHELL = 'bible-shell-v1'; +const API = 'bible-api-v1'; +const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life']; -self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(SHELL) + .then(c => c.addAll(['/', '/index.html'])) + .then(() => self.skipWaiting()) + ); +}); -self.addEventListener('activate', (event) => { - event.waitUntil( +self.addEventListener('activate', (e) => { + e.waitUntil( caches.keys() - .then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))) + .then(keys => Promise.all( + keys.filter(k => k !== SHELL && k !== API).map(k => caches.delete(k)) + )) .then(() => self.clients.claim()) ); }); -// Network-first, fall back to cache for external Bible API requests. -self.addEventListener('fetch', (event) => { - if (!CACHEABLE.some(origin => event.request.url.startsWith(origin))) return; +self.addEventListener('fetch', (e) => { + const { request } = e; + const url = request.url; - event.respondWith( - fetch(event.request) - .then(response => { - if (response.ok) { - const clone = response.clone(); - caches.open(CACHE).then(cache => cache.put(event.request, clone)); - } - return response; - }) - .catch(() => - caches.open(CACHE) - .then(cache => cache.match(event.request)) - .then(cached => cached ?? new Response('{"error":"offline"}', { - status: 503, - headers: { 'Content-Type': 'application/json' }, - })) + // Never intercept API routes — let Express handle them + if (new URL(url).pathname.startsWith('/api/')) return; + + // External Bible API: network-first, fall back to cache + if (BIBLE_ORIGINS.some(o => url.startsWith(o))) { + e.respondWith( + fetch(request) + .then(res => { + if (res.ok) caches.open(API).then(c => c.put(request, res.clone())); + return res; + }) + .catch(() => + caches.open(API).then(c => c.match(request)).then( + hit => hit ?? new Response('{"error":"offline"}', { + status: 503, headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + ); + return; + } + + // App-shell navigation: network-first, fall back to cached index.html + if (request.mode === 'navigate') { + e.respondWith( + fetch(request) + .then(res => { + if (res.ok) caches.open(SHELL).then(c => c.put(request, res.clone())); + return res; + }) + .catch(() => + caches.open(SHELL).then(c => + c.match('/index.html').then(hit => hit ?? c.match('/')) + ) + ) + ); + return; + } + + // Same-origin static assets (hashed JS/CSS/images): cache-first + if (url.startsWith(self.location.origin)) { + e.respondWith( + caches.open(SHELL).then(c => + c.match(request).then(hit => { + if (hit) return hit; + return fetch(request).then(res => { + if (res.ok) c.put(request, res.clone()); + return res; + }); + }) ) - ); + ); + } }); diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..27d4001 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,115 @@ +// Generates PWA icon PNGs using only Node.js built-ins (no dependencies). +import { deflateSync } from 'zlib'; +import { writeFileSync, mkdirSync } from 'fs'; + +function crc32(buf) { + const table = new Uint32Array(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); + table[i] = c; + } + let crc = 0xffffffff; + for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8); + return ((crc ^ 0xffffffff) >>> 0); +} + +function u32(n) { + const b = Buffer.alloc(4); + b.writeUInt32BE(n, 0); + return b; +} + +function chunk(type, data) { + const t = Buffer.from(type, 'ascii'); + return Buffer.concat([u32(data.length), t, data, u32(crc32(Buffer.concat([t, data])))]); +} + +function makePNG(size, draw) { + const px = new Uint8ClampedArray(size * size * 4); // RGBA + draw(px, size); + + const rows = []; + for (let y = 0; y < size; y++) { + rows.push(0); // PNG filter byte: None + for (let x = 0; x < size; x++) { + const i = (y * size + x) * 4; + rows.push(px[i], px[i + 1], px[i + 2], px[i + 3]); + } + } + + const raw = Buffer.from(rows); + const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const ihdr = chunk('IHDR', Buffer.concat([u32(size), u32(size), Buffer.from([8, 6, 0, 0, 0])])); + const idat = chunk('IDAT', deflateSync(raw, { level: 9 })); + const iend = chunk('IEND', Buffer.alloc(0)); + return Buffer.concat([sig, ihdr, idat, iend]); +} + +function setPixel(px, size, x, y, r, g, b, a = 255) { + if (x < 0 || x >= size || y < 0 || y >= size) return; + const i = (y * size + x) * 4; + px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a; +} + +function fillRect(px, size, x1, y1, x2, y2, r, g, b, a = 255) { + for (let y = Math.max(0, y1); y < Math.min(size, y2); y++) + for (let x = Math.max(0, x1); x < Math.min(size, x2); x++) + setPixel(px, size, x, y, r, g, b, a); +} + +function drawIcon(px, S) { + const rad = Math.round(S * 0.22); // corner radius + + // Rounded background: #0f172a (15, 23, 42) + for (let y = 0; y < S; y++) { + for (let x = 0; x < S; x++) { + const cx = Math.min(x, S - 1 - x); + const cy = Math.min(y, S - 1 - y); + if (cx < rad && cy < rad) { + const dx = rad - cx - 1; + const dy = rad - cy - 1; + if (dx * dx + dy * dy > rad * rad) { setPixel(px, S, x, y, 0, 0, 0, 0); continue; } + } + setPixel(px, S, x, y, 15, 23, 42); + } + } + + // Cross: vertical bar (center-ish, top-of-cross higher than center) + const cw = Math.round(S * 0.12); // cross bar thickness + const cx = Math.round(S / 2 - cw / 2); + const vTop = Math.round(S * 0.18); + const vBot = Math.round(S * 0.82); + fillRect(px, S, cx, vTop, cx + cw, vBot, 255, 255, 255); + + // Horizontal bar (slightly above center) + const hh = Math.round(S * 0.12); + const hy = Math.round(S * 0.36 - hh / 2); + const hLeft = Math.round(S * 0.22); + const hRight = Math.round(S * 0.78); + fillRect(px, S, hLeft, hy, hRight, hy + hh, 255, 255, 255); + + // Subtle glow/shine at cross intersection (slightly lighter center) + const glowR = Math.round(S * 0.07); + const gcx = Math.round(S / 2); + const gcy = Math.round(S * 0.36); + for (let y = gcy - glowR; y <= gcy + glowR; y++) { + for (let x = gcx - glowR; x <= gcx + glowR; x++) { + const d2 = (x - gcx) ** 2 + (y - gcy) ** 2; + if (d2 <= glowR * glowR) { + const i = (Math.max(0, Math.min(S - 1, y)) * S + Math.max(0, Math.min(S - 1, x))) * 4; + if (px[i + 3] === 255) { + px[i] = Math.min(255, px[i] + 20); + px[i + 1] = Math.min(255, px[i + 1] + 20); + px[i + 2] = Math.min(255, px[i + 2] + 20); + } + } + } + } +} + +mkdirSync('public', { recursive: true }); +writeFileSync('public/icon-192.png', makePNG(192, drawIcon)); +writeFileSync('public/icon-512.png', makePNG(512, drawIcon)); +writeFileSync('public/apple-touch-icon.png', makePNG(180, drawIcon)); +console.log('Icons written to public/'); diff --git a/src/pages/DrawCanvas.jsx b/src/pages/DrawCanvas.jsx index 05fe5e7..c941dbe 100644 --- a/src/pages/DrawCanvas.jsx +++ b/src/pages/DrawCanvas.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef, useCallback, useState } from 'react'; import { useApp } from '../context/AppContext.js'; import { renderInkToCanvas } from '../utils/inkRender.js'; @@ -8,9 +8,22 @@ const INK_SIZES = [ { label: 'M', value: 0.012 }, { label: 'L', value: 0.025 }, ]; +const PAGE_HEIGHT = 640; // px per notebook page + +// Page count is persisted as a hidden metadata stroke at strokes[0] +// so the canvas size survives navigation without a separate state key. +function extractMeta(strokes) { + if (strokes.length > 0 && strokes[0]?._meta) { + return { pageCount: strokes[0].pageCount ?? 1, realStrokes: strokes.slice(1) }; + } + return { pageCount: 1, realStrokes: strokes }; +} +function packStrokes(realStrokes, pageCount) { + return [{ _meta: true, pageCount }, ...realStrokes]; +} // Standalone notebook-style draw canvas. -// strokes: array of saved stroke objects +// strokes: array of saved stroke objects (may include a leading metadata object) // onStrokesChange: (newStrokes) => void // onDone: optional () => void — shows "Done" button when provided // headerContent: optional JSX rendered above the notebook area. @@ -18,18 +31,26 @@ const INK_SIZES = [ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerContent }) { const { drawTool, setDrawTool, drawColor, setDrawColor, drawSize, setDrawSize } = useApp(); + const { pageCount: initPages } = extractMeta(strokes); + const [pageCount, setPageCount] = useState(initPages); + + // realStrokes = strokes without the metadata header + const { realStrokes } = extractMeta(strokes); + const canvasRef = useRef(null); const activeStrokeRef = useRef([]); const isDrawingRef = useRef(false); // Refs prevent stale closures in stable callbacks - const strokesRef = useRef(strokes); + const strokesRef = useRef(realStrokes); const onStrokesChangeRef = useRef(onStrokesChange); const drawToolRef = useRef(drawTool); const drawColorRef = useRef(drawColor); const drawSizeRef = useRef(drawSize); - strokesRef.current = strokes; + strokesRef.current = realStrokes; onStrokesChangeRef.current = onStrokesChange; + const pageCountRef = useRef(pageCount); + pageCountRef.current = pageCount; drawToolRef.current = drawTool; drawColorRef.current = drawColor; drawSizeRef.current = drawSize; @@ -47,7 +68,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon ); }, []); - useEffect(() => { render(); }, [strokes, drawTool, drawColor, drawSize, render]); + useEffect(() => { render(); }, [realStrokes, drawTool, drawColor, drawSize, render]); useEffect(() => { const canvas = canvasRef.current; @@ -75,16 +96,19 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon function commitStroke() { const pts = activeStrokeRef.current; if (drawToolRef.current !== 'eraser' && pts.length > 1) { - onStrokesChangeRef.current([ - ...strokesRef.current, - { - id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`, - tool: drawToolRef.current, - color: drawColorRef.current, - size: drawSizeRef.current, - points: pts, - }, - ]); + onStrokesChangeRef.current(packStrokes( + [ + ...strokesRef.current, + { + id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`, + tool: drawToolRef.current, + color: drawColorRef.current, + size: drawSizeRef.current, + points: pts, + }, + ], + pageCountRef.current, + )); } activeStrokeRef.current = []; isDrawingRef.current = false; @@ -98,7 +122,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon (s) => !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r), ); if (remaining.length !== strokesRef.current.length) - onStrokesChangeRef.current(remaining); + onStrokesChangeRef.current(packStrokes(remaining, pageCountRef.current)); } // iOS Safari: Apple Pencil fires Touch Events with touchType === 'stylus'. @@ -197,11 +221,27 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon }; }, [getPoint, render]); - const undo = () => strokes.length > 0 && onStrokesChange(strokes.slice(0, -1)); + const undo = () => + realStrokes.length > 0 && + onStrokesChange(packStrokes(realStrokes.slice(0, -1), pageCount)); + const clear = () => - strokes.length > 0 && + realStrokes.length > 0 && window.confirm('Clear all ink notes?') && - onStrokesChange([]); + onStrokesChange(packStrokes([], pageCount)); + + // Add another notebook page below existing content. + // Rescales all stroke y-coords so existing ink stays at the same pixel position. + const addSpace = () => { + const newCount = pageCount + 1; + const ratio = pageCount / newCount; + const rescaled = realStrokes.map((s) => ({ + ...s, + points: s.points.map(([x, y, p]) => [x, y * ratio, p]), + })); + onStrokesChange(packStrokes(rescaled, newCount)); + setPageCount(newCount); + }; return (
@@ -259,7 +299,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
)} - {/* Ruled notebook area */} + {/* Ruled notebook area — height grows with pageCount */}
+ + {/* Add more notebook space */} + ); }