bsc

Comprehensive codebase and cou...
Log | Files | Refs | Activity | README | LICENSE

commit 2b66fa9cb8628880119caefc82fcc6dc1d1139a0
parent cf7ecdd6d6bca17ddcc7601a3ba9f49b53510d5f
Author: Amit Dutta <mail@amit.is-a.dev>
Date:   Sat, 29 Aug 2026 12:44:43 +0530

Merge pull request #110 from notamitgamer/feature/offline-snapshots

Add offline Snapshots page (browser-local, independent of main site cache)
Diffstat:
Mdocs/.vitepress/config.mts | 3++-
Adocs/.vitepress/theme/components/SnapshotManager.vue | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/.vitepress/theme/index.ts | 7+++++++
Adocs/.vitepress/theme/lib/snapshotDb.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/public/sw-snapshot.js | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/snapshots.md | 11+++++++++++
6 files changed, 435 insertions(+), 1 deletion(-)

diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts @@ -74,6 +74,7 @@ const vitePressConfig = { ] }, { text: 'Tags', link: '/tags' }, + { text: 'Snapshots', link: '/snapshots' }, { text: 'License', link: 'https://github.com/notamitgamer/bsc/blob/main/LICENSE'}, { text: 'Changelog', link: '/changelog' }, ], @@ -190,7 +191,7 @@ const sidebarConfig = { useTitleFromFileHeading: true, useFolderTitleFromIndexFile: true, useFolderLinkFromIndexFile: true, - excludeFiles: ['tags.md'], + excludeFiles: ['tags.md', 'snapshots.md'], excludeFolders: ['stylesheets', 'overrides', 'assets', '.vitepress'], sortMenusByName: true, sortMenusOrderNumerically: true, diff --git a/docs/.vitepress/theme/components/SnapshotManager.vue b/docs/.vitepress/theme/components/SnapshotManager.vue @@ -0,0 +1,241 @@ +<script setup> +import { ref, onMounted } from 'vue' +import { listSnapshots, putSnapshot, deleteSnapshotMeta } from '../lib/snapshotDb' + +const snapshots = ref([]) +const isCapturing = ref(false) +const progress = ref({ done: 0, total: 0, label: '' }) +const error = ref('') + +onMounted(refresh) + +async function refresh() { + snapshots.value = await listSnapshots() +} + +function fmtBytes(n) { + if (!n) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + let i = 0 + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ } + return `${n.toFixed(1)} ${units[i]}` +} + +function fmtDate(ts) { + return new Date(ts).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) +} + +async function getPagePaths() { + const res = await fetch('/sitemap.xml') + if (!res.ok) throw new Error('Could not read /sitemap.xml') + const xml = await res.text() + const locs = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]) + return locs.map((loc) => { + try { + return new URL(loc).pathname + } catch { + return null + } + }).filter(Boolean) +} + +function extractAssetUrls(html) { + const urls = new Set() + const re = /(?:src|href)="([^"]+)"/g + let m + while ((m = re.exec(html))) { + const u = m[1] + if (u.startsWith('/') && !u.startsWith('//')) urls.add(u) + } + return urls +} + +async function takeSnapshot() { + error.value = '' + isCapturing.value = true + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const cacheName = `snapshot-${id}` + let sizeBytes = 0 + let assetUrls = new Set() + + try { + const pagePaths = await getPagePaths() + progress.value = { done: 0, total: pagePaths.length, label: 'Fetching pages…' } + + const cache = await caches.open(cacheName) + + for (const path of pagePaths) { + try { + const res = await fetch(path, { cache: 'no-store' }) + if (res && res.ok) { + const clone = res.clone() + const html = await clone.text() + sizeBytes += html.length + extractAssetUrls(html).forEach((u) => assetUrls.add(u)) + await cache.put(path, res) + } + } catch (_) { + /* skip page that failed, continue capturing the rest */ + } + progress.value.done++ + } + + const assetList = [...assetUrls] + progress.value = { done: 0, total: assetList.length, label: 'Fetching assets…' } + for (const url of assetList) { + try { + const res = await fetch(url, { cache: 'no-store' }) + if (res && res.ok) { + const buf = await res.clone().arrayBuffer() + sizeBytes += buf.byteLength + await cache.put(url, res) + } + } catch (_) { + /* skip */ + } + progress.value.done++ + } + + await putSnapshot({ + id, + name: `Snapshot – ${new Date().toLocaleDateString()}`, + createdAt: Date.now(), + pageCount: pagePaths.length, + assetCount: assetList.length, + sizeBytes, + }) + + await refresh() + } catch (e) { + error.value = e?.message || 'Snapshot failed.' + // best-effort cleanup of a partial cache + try { await caches.delete(cacheName) } catch (_) {} + } finally { + isCapturing.value = false + progress.value = { done: 0, total: 0, label: '' } + } +} + +function viewSnapshot(id) { + window.open(`/snapshots/view/${id}/`, '_blank', 'noopener') +} + +async function removeSnapshot(id) { + if (!confirm('Delete this snapshot? This cannot be undone.')) return + await caches.delete(`snapshot-${id}`) + await deleteSnapshotMeta(id) + await refresh() +} + +async function renameSnapshot(snap) { + const name = prompt('Snapshot name', snap.name) + if (!name) return + await putSnapshot({ ...snap, name }) + await refresh() +} +</script> + +<template> + <div class="snapshot-manager"> + <p class="intro"> + Snapshots save a fully offline, point-in-time copy of the site into your browser + (separate from normal caching — the live site keeps auto-updating as usual). + Take one before you go offline, and come back to it any time from this page. + </p> + + <button class="capture-btn" :disabled="isCapturing" @click="takeSnapshot"> + <span v-if="!isCapturing">📸 Take snapshot now</span> + <span v-else>Capturing… {{ progress.label }} ({{ progress.done }}/{{ progress.total }})</span> + </button> + + <p v-if="error" class="error">{{ error }}</p> + + <div v-if="snapshots.length" class="snapshot-list"> + <div v-for="s in snapshots" :key="s.id" class="snapshot-item"> + <div class="snapshot-info"> + <div class="snapshot-name">{{ s.name }}</div> + <div class="snapshot-meta"> + {{ fmtDate(s.createdAt) }} · {{ s.pageCount }} pages · {{ s.assetCount }} assets · {{ fmtBytes(s.sizeBytes) }} + </div> + </div> + <div class="snapshot-actions"> + <button @click="viewSnapshot(s.id)">View</button> + <button @click="renameSnapshot(s)">Rename</button> + <button class="danger" @click="removeSnapshot(s.id)">Delete</button> + </div> + </div> + </div> + <p v-else class="empty">No snapshots yet.</p> + </div> +</template> + +<style scoped> +.snapshot-manager { + max-width: 720px; +} +.intro { + color: var(--vp-c-text-2); + font-size: 14px; + margin-bottom: 16px; +} +.capture-btn { + background: var(--vp-c-brand-1); + color: #fff; + border: none; + border-radius: 8px; + padding: 10px 18px; + font-weight: 600; + cursor: pointer; + margin-bottom: 20px; +} +.capture-btn:disabled { + opacity: 0.7; + cursor: default; +} +.error { + color: var(--vp-c-danger-1, #d33); + font-size: 13px; +} +.snapshot-list { + display: flex; + flex-direction: column; + gap: 10px; +} +.snapshot-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; +} +.snapshot-name { + font-weight: 600; +} +.snapshot-meta { + font-size: 12.5px; + color: var(--vp-c-text-2); +} +.snapshot-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} +.snapshot-actions button { + font-size: 12.5px; + padding: 5px 10px; + border-radius: 6px; + border: 1px solid var(--vp-c-divider); + background: var(--vp-c-bg-soft); + cursor: pointer; +} +.snapshot-actions button.danger { + color: #d33; + border-color: #d33; +} +.empty { + color: var(--vp-c-text-2); + font-size: 14px; +} +</style> diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts @@ -37,6 +37,13 @@ export default { setup() { onMounted(() => { + if ('serviceWorker' in navigator) { + // Registered on every page, independent of the main site's own + // caching. Only handles /snapshots and /snapshots/view/* — see + // public/sw-snapshot.js. + navigator.serviceWorker.register('/sw-snapshot.js', { scope: '/snapshots' }).catch(() => {}) + } + setTimeout(() => { const searchBtn = document.querySelector('.VPNavBarSearchButton'); const askAiBtn = document.querySelector('.VPNavBarAskAiButton'); diff --git a/docs/.vitepress/theme/lib/snapshotDb.ts b/docs/.vitepress/theme/lib/snapshotDb.ts @@ -0,0 +1,60 @@ +// Lightweight IndexedDB wrapper for snapshot metadata. +// Actual page/asset bytes live in the Cache Storage API (see public/sw-snapshot.js); +// this only stores the small list you see on the /snapshots page. + +const DB_NAME = 'bsc-snapshots-db' +const STORE = 'snapshots' +const DB_VERSION = 1 + +export interface SnapshotMeta { + id: string + name: string + createdAt: number + pageCount: number + assetCount: number + sizeBytes: number +} + +function openDb(): Promise<IDBDatabase> { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION) + req.onupgradeneeded = () => { + const db = req.result + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: 'id' }) + } + } + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +export async function listSnapshots(): Promise<SnapshotMeta[]> { + const db = await openDb() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly') + const req = tx.objectStore(STORE).getAll() + req.onsuccess = () => resolve((req.result as SnapshotMeta[]).sort((a, b) => b.createdAt - a.createdAt)) + req.onerror = () => reject(req.error) + }) +} + +export async function putSnapshot(meta: SnapshotMeta): Promise<void> { + const db = await openDb() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite') + tx.objectStore(STORE).put(meta) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) +} + +export async function deleteSnapshotMeta(id: string): Promise<void> { + const db = await openDb() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite') + tx.objectStore(STORE).delete(id) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) +} diff --git a/docs/public/sw-snapshot.js b/docs/public/sw-snapshot.js @@ -0,0 +1,114 @@ +// sw-snapshot.js +// +// Registered on every page load (see theme/index.ts) regardless of which page +// the user landed on. It does two things ONLY: +// +// 1. Keeps a small "shell" cache of the /snapshots page itself, so that page +// is reachable offline even if the visitor never had the main site cached. +// 2. Serves pages/assets for /snapshots/view/<id>/... from that snapshot's +// own dedicated cache (created by SnapshotManager.vue when a snapshot is taken). +// +// It never intercepts any other request. The main site's own caching/update +// behavior (e.g. the deploy-id banner) is completely untouched. + +const SHELL_CACHE = 'bsc-snapshot-shell-v1' +const SNAPSHOT_PREFIX = 'snapshot-' +const VIEW_PATH_RE = /^\/snapshots\/view\/([^/]+)\/?(.*)$/ + +self.addEventListener('install', (event) => { + self.skipWaiting() + event.waitUntil(cacheSnapshotsShell()) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()) +}) + +async function cacheSnapshotsShell() { + try { + const cache = await caches.open(SHELL_CACHE) + const shellUrl = '/snapshots' + const res = await fetch(shellUrl, { cache: 'no-store' }) + if (!res || !res.ok) return + const html = await res.clone().text() + await cache.put(shellUrl, res) + + // Pull same-origin script/style/link assets referenced by the page so the + // whole shell (not just the bare HTML) works offline. + const urls = new Set() + const attrRe = /(?:src|href)="([^"]+)"/g + let m + while ((m = attrRe.exec(html))) { + const u = m[1] + if (u.startsWith('/') && !u.startsWith('//')) urls.add(u) + } + await Promise.all( + [...urls].map(async (u) => { + try { + const r = await fetch(u, { cache: 'no-store' }) + if (r && r.ok) await cache.put(u, r) + } catch (_) { + /* best-effort */ + } + }) + ) + } catch (_) { + /* best-effort, never block install */ + } +} + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url) + if (url.origin !== self.location.origin) return + + const viewMatch = url.pathname.match(VIEW_PATH_RE) + if (viewMatch) { + event.respondWith(handleSnapshotView(viewMatch[1], '/' + viewMatch[2])) + return + } + + if (url.pathname === '/snapshots' || url.pathname === '/snapshots/') { + event.respondWith(handleShellRequest(event.request)) + return + } + + // Everything else: don't touch it, let it behave exactly as it does today. +}) + +async function handleShellRequest(request) { + try { + return await fetch(request) + } catch (_) { + const cache = await caches.open(SHELL_CACHE) + const cached = await cache.match('/snapshots') + return cached || new Response('Offline and no cached shell available.', { status: 503 }) + } +} + +async function handleSnapshotView(id, path) { + const cacheName = SNAPSHOT_PREFIX + id + const cache = await caches.open(cacheName) + + // Normalize: "" or "/" -> "/", strip trailing slash otherwise for lookup, + // but try a couple of variants since snapshots are keyed by clean-URL paths. + const candidates = new Set([path]) + if (path === '/' ) candidates.add('/') + if (path.endsWith('/')) candidates.add(path.slice(0, -1) || '/') + else candidates.add(path + '/') + candidates.add('/') + + for (const p of candidates) { + const hit = await cache.match(p) + if (hit) return hit + } + + // Fall back to any same-name asset (covers hashed asset paths stored verbatim). + const assetHit = await cache.match(path) + if (assetHit) return assetHit + + return new Response( + `<!doctype html><meta charset="utf-8"><title>Not in snapshot</title> + <p>This page wasn't captured in this snapshot. <a href="/snapshots">Back to snapshots</a>.</p>`, + { status: 404, headers: { 'Content-Type': 'text/html' } } + ) +} diff --git a/docs/snapshots.md b/docs/snapshots.md @@ -0,0 +1,11 @@ +--- +title: 'Snapshots' +--- + +<script setup> +import SnapshotManager from './.vitepress/theme/components/SnapshotManager.vue' +</script> + +# Offline Snapshots + +<SnapshotManager />
© notamitgamer • Site Built: 2026-09-05 01:53:16 UTC • git-mirror commit: c170d72 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror