diff --git a/docker-compose.yml b/docker-compose.yml
index a450ffa..7fdb4d6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,7 +5,13 @@ services:
image: tecnativa/docker-socket-proxy:0.3.0
container_name: tts-voice-creator-docker-proxy
restart: unless-stopped
- read_only: true
+ # NOT read_only: the entrypoint sed's its own haproxy.cfg.template into
+ # haproxy.cfg at startup, in the same directory the template ships in --
+ # read_only crash-looped it ("Read-only file system"), and a tmpfs mount
+ # over that directory only trades one failure for another by hiding the
+ # template it needs to read (confirmed live: "No such file or
+ # directory"). The real hardening here is cap_drop: ALL,
+ # no-new-privileges, and the Docker socket itself being mounted :ro.
cap_drop:
- ALL
security_opt:
diff --git a/static/css/audiobook.css b/static/css/audiobook.css
index 8f21fb3..e3a1a7b 100644
--- a/static/css/audiobook.css
+++ b/static/css/audiobook.css
@@ -653,15 +653,28 @@
background: rgba(37,99,235,.06); border: 1px solid var(--accent); border-radius: 4px;
padding: 4px 6px; outline: none;
}
+/* .ab-cv-spk-row wraps the speaker name together with its confidence badge
+ and check button (see _abRowFromSegment) so the badge sits directly beside
+ the name instead of dropping to its own line below it — before this, both
+ .ab-cv-spk and .ab-cv-txt were `display: block`, which forced a line break
+ between the name and the badge that followed it in the DOM regardless of
+ how close together the markup already put them. */
+.ab-cv-spk-row {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 2px;
+}
.ab-cv-spk {
- display: block;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
- margin-bottom: 2px;
+}
+.ab-cv-row:not(.is-narr):not(.is-processing) .ab-cv-spk-row {
+ padding-left: 120px;
}
.ab-cv-row:not(.is-narr):not(.is-processing) .ab-cv-spk {
- padding-left: 120px;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
@@ -685,9 +698,7 @@
/*max-width: 500px;*/
}
.ab-cv-row.is-narr .ab-cv-spk {
- display: block;
font-weight: 600;
- padding-left: 0;
font-size: 11px;
}
.ab-cv-row.is-processing {
diff --git a/static/js/audiobook-cast.js b/static/js/audiobook-cast.js
index 574af32..ce2df6d 100644
--- a/static/js/audiobook-cast.js
+++ b/static/js/audiobook-cast.js
@@ -1867,8 +1867,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
- ${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}
- ${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)}
+ ${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)}${_abMarkQuotes(highlightText(_abDisplayText(s)))}`;
if (typeof s.conf === 'number') row.dataset.conf = String(s.conf);
diff --git a/static/js/library.js b/static/js/library.js
index 2edc2e7..6d2d34d 100644
--- a/static/js/library.js
+++ b/static/js/library.js
@@ -66,6 +66,9 @@ async function libraryRenderBooks() {
list.innerHTML = _libSkeleton(4);
let all = [];
try { const r = await fetch(LIB_READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
+ // Magazines share this store (kind: "magazine") but live in their own
+ // library at Studio -> Magazines -- see the matching fix in reader.js.
+ all = all.filter(d => d.kind !== 'magazine');
if (!all.length) {
list.innerHTML = '
No books yet.
'
+ '
Open Read Aloud, import a PDF or text, and save it to your library.
';
diff --git a/static/js/magazine.js b/static/js/magazine.js
index f54d087..0f73fdb 100644
--- a/static/js/magazine.js
+++ b/static/js/magazine.js
@@ -193,18 +193,32 @@ async function magCreate() {
// ── Ingestion ────────────────────────────────────────────────────────────────
+// Remembers which magazine was open so a full page reload or the browser's
+// own Back button (which, unlike in-app navigation, tears down all JS state)
+// lands back on it instead of the empty library — confirmed live as the
+// cause of a user re-uploading the same PDF, creating a duplicate under a
+// completely different kind ("book", via Studio/Reader's own import) because
+// the magazine they had been working on appeared to have vanished.
+const MAG_LAST_DOC_KEY = 'ttsvc_mag_last_doc';
+
async function magOpen(docId) {
try {
state.doc = await api('/api/reader/docs/' + docId);
state.doc.id = docId;
await magRefreshArticles();
magShow('contents');
+ try { localStorage.setItem(MAG_LAST_DOC_KEY, docId); } catch (_) {}
if ((state.doc.ingest || {}).status === 'running') pollIngest();
} catch (e) {
say('Could not open magazine: ' + e.message, 'err');
+ try { localStorage.removeItem(MAG_LAST_DOC_KEY); } catch (_) {}
}
}
+function magForgetLastDoc() {
+ try { localStorage.removeItem(MAG_LAST_DOC_KEY); } catch (_) {}
+}
+
async function magIngest() {
if (!state.doc) return;
try {
@@ -219,9 +233,18 @@ async function magIngest() {
}
}
+// "Analysing page 28 of 204" -> a real percentage, not just spinner text.
+function magParsePageProgress(detail) {
+ const m = /page (\d+) of (\d+)/i.exec(detail || '');
+ if (!m) return null;
+ const page = parseInt(m[1], 10), total = parseInt(m[2], 10);
+ if (!total) return null;
+ return { page, total, pct: Math.min(100, Math.round((page / total) * 100)) };
+}
+
async function pollIngest() {
const bar = $('mag-ingest-status');
- if (bar) bar.hidden = false;
+ if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; }
for (;;) {
let data;
try {
@@ -230,11 +253,27 @@ async function pollIngest() {
const ing = data.ingest || {};
const detail = (data.job || {}).detail || '';
if (bar) {
- bar.innerHTML = ing.status === 'running'
- ? ' ' + esc(detail || 'Analysing…')
- : ing.status === 'failed'
- ? ' Ingestion failed: ' + esc(ing.error || '')
- : ' ' + (ing.articles || 0) + ' articles detected';
+ if (ing.status === 'running') {
+ bar.className = 'mag-status is-running';
+ const p = magParsePageProgress(detail);
+ bar.innerHTML =
+ '
';
+ }
}
if (ing.status !== 'running') {
if (ing.status === 'done') await magRefreshArticles();
@@ -969,11 +1008,27 @@ function syncDrivingPlayButton() {
// ── Boot ─────────────────────────────────────────────────────────────────────
-function magInit() {
+async function magInit() {
if (!$('mag-library-list')) return; // section not mounted
magLoadLibrary();
magLoadVoices();
setupMagDropzone();
+ let lastId = null;
+ try { lastId = localStorage.getItem(MAG_LAST_DOC_KEY); } catch (_) {}
+ if (lastId) {
+ // Confirmed live: without this, a full reload or the browser's own Back
+ // button (as opposed to in-app navigation) always dropped back to the
+ // empty library, making an in-progress magazine look like it had been
+ // lost. If it genuinely no longer exists (deleted elsewhere), fall back
+ // to the library rather than getting stuck on a permanent error toast.
+ try {
+ await api('/api/reader/docs/' + lastId);
+ await magOpen(lastId);
+ return;
+ } catch (_) {
+ magForgetLastDoc();
+ }
+ }
magShow('library');
}
diff --git a/static/js/reader.js b/static/js/reader.js
index 6273946..d8ed0c7 100644
--- a/static/js/reader.js
+++ b/static/js/reader.js
@@ -1717,6 +1717,12 @@ async function readerRenderLibrary() {
if (!card || !list) return;
let all = [];
try { const r = await fetch(READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
+ // Magazines share this store (kind: "magazine") but are a different
+ // feature with their own library at Studio -> Magazines -- listing them
+ // here too let one masquerade as a plain book, which is how a user's
+ // in-progress magazine ended up re-uploaded as a duplicate (confirmed
+ // live: it never actually vanished, it was just misfiled here).
+ all = all.filter(d => d.kind !== 'magazine');
if (!all.length) { list.innerHTML = '