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 0000000..140ed42
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/icon-192.png b/public/icon-192.png
new file mode 100644
index 0000000..a0193d3
Binary files /dev/null and b/public/icon-192.png differ
diff --git a/public/icon-512.png b/public/icon-512.png
new file mode 100644
index 0000000..ce7dfeb
Binary files /dev/null and b/public/icon-512.png differ
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
Undo
@@ -267,7 +307,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
Clear
@@ -298,10 +338,10 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
{headerContent}
)}
- {/* Ruled notebook area */}
+ {/* Ruled notebook area — height grows with pageCount */}
+
+ {/* Add more notebook space */}
+
+ + Add More Space
+
);
}