diff --git a/README.md b/README.md
index 806a86d..ef08fe9 100644
--- a/README.md
+++ b/README.md
@@ -166,8 +166,8 @@ Open `convert.html` on the served site (or `node tools/pptx-to-html.mjs talk.ppt
converter could not handle.
- **Download** a single HTML file you can double-click, or the deck folder as a ZIP.
- **Save in this browser** to come back to a conversion without downloading it. Saved
- decks are listed on the page and kept in this browser on this computer until you remove
- them or clear site data. They are not synced anywhere.
+ decks appear under "Your decks" on the main page, with Open and Remove. They are kept in
+ this browser on this computer until you remove them or clear site data.
The folder holds `slides.html`, `strings.json` and `images/`. `strings.json` lists
every paragraph, speaker note and alt text under a stable ID, in the format described
diff --git a/assets/saved-decks.mjs b/assets/saved-decks.mjs
new file mode 100644
index 0000000..e864057
--- /dev/null
+++ b/assets/saved-decks.mjs
@@ -0,0 +1,62 @@
+/**
+ * Decks converted in the browser and saved there, shared by the landing page and
+ * the converter. Each is kept as its original .pptx in IndexedDB (too large for
+ * sessionStorage) and converted again when opened. Nothing leaves the browser.
+ */
+const DB_NAME = 'decks-session';
+const STORE = 'decks';
+
+export const hasStorage = typeof indexedDB !== 'undefined';
+
+const openDb = () => new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, 1);
+ request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'id' });
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+});
+
+async function run(mode, action) {
+ const db = await openDb();
+ try {
+ return await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE, mode);
+ const request = action(transaction.objectStore(STORE));
+ transaction.oncomplete = () => resolve(request?.result);
+ transaction.onerror = () => reject(transaction.error);
+ transaction.onabort = () => reject(transaction.error);
+ });
+ } finally {
+ db.close();
+ }
+}
+
+/** Newest first. Never throws: blocked storage just means an empty list. */
+export async function listSaved() {
+ if (!hasStorage) return [];
+ try {
+ return ((await run('readonly', (s) => s.getAll())) ?? []).sort((a, b) => b.savedAt - a.savedAt);
+ } catch {
+ return [];
+ }
+}
+
+export const getSaved = (id) => run('readonly', (s) => s.get(id));
+export const removeSaved = (id) => run('readwrite', (s) => s.delete(id));
+
+/** `bytes` is the original .pptx as a Uint8Array. */
+export async function saveDeck({ name, bytes, title, slug, author, slides }) {
+ // Ask the browser not to evict saved decks under storage pressure; harmless if refused.
+ navigator.storage?.persist?.().catch(() => {});
+ const record = {
+ id: `${name}:${bytes.byteLength}`,
+ name,
+ title,
+ slug,
+ author,
+ slides,
+ bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
+ savedAt: Date.now(),
+ };
+ await run('readwrite', (s) => s.put(record));
+ return record.id;
+}
diff --git a/convert.html b/convert.html
index a051ef0..3fc2c5d 100644
--- a/convert.html
+++ b/convert.html
@@ -118,23 +118,6 @@
#preview.hidden { display: none; }
#preview iframe { width: 100%; height: 100%; border: 0; display: block; }
#closePreview { position: fixed; top: 14px; right: 16px; z-index: 60; background: rgba(0, 0, 0, 0.6); }
-
- .saved-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; }
- .saved-head h2 { margin: 0; flex: 1; }
- .saved-head button { padding: 5px 11px; font-size: 0.82rem; }
- .saved { list-style: none; margin: 0; padding: 0; }
- .saved li {
- display: flex;
- align-items: center;
- gap: 14px;
- padding: 10px 0;
- border-top: 1px solid var(--brand-border);
- }
- .saved li:first-child { border-top: 0; padding-top: 0; }
- .saved .what { flex: 1; min-width: 0; }
- .saved .what b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .saved .what span { color: var(--brand-muted); font-size: 0.82rem; }
- .saved button { padding: 6px 12px; font-size: 0.85rem; }
@@ -158,19 +141,6 @@
Convert a PowerPoint to HTML
-
-
-
Saved in this browser
-
-
-
-
-
- Kept in this browser only, on this computer. They stay until you remove them or
- clear site data, and are not synced anywhere, so download anything you need
- elsewhere.
-
-
Converted
@@ -255,6 +225,7 @@
This page needs a web server
import { pptxToHtml } from './tools/pptx-html.mjs';
import { readPptx, makeZip } from './tools/zip-tools.mjs';
import { assembleStandaloneHtml } from './tools/standalone-html.mjs';
+ import { hasStorage, saveDeck, getSaved } from './assets/saved-decks.mjs';
const REVEAL_VERSION = '5.2.1';
const BASE = window.DECK_ASSET_BASE;
@@ -333,7 +304,7 @@
$('tabStrings').addEventListener('click', () => showTab('strings'));
$('tabHtml').addEventListener('click', () => showTab('html'));
$('tabWarn').addEventListener('click', () => showTab('warn'));
+
+ // ?open= comes from "Open" on a saved deck on the main page: convert it again and preview it.
+ const openId = new URLSearchParams(window.location.search).get('open');
+ if (openId && hasStorage) {
+ const record = await getSaved(openId).catch(() => null);
+ if (record) {
+ await loadBytes(record.name, new Uint8Array(record.bytes), record);
+ $('saveBtn').textContent = 'Saved ✓';
+ $('previewBtn').click();
+ } else {
+ setStatus('That saved deck is no longer in this browser.', 'error');
+ }
+ }