feat(erindringer): kladde-upload ved stop, afskrift og næste spørgsmål i baggrunden, dagbog pr. dag, afspil spørgsmål + svar; tabeller ruller selv på mobil
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 22s

Optagelsen sendes op som kladde, når man stopper, så STT kører mens man lytter;
'Send svaret' bekræfter. Næste spørgsmål (Claude + TTS) forberedes i baggrunden
efter hvert svar og hentes på 0 s. Dagbogens samtale er datoen (vælg dag).
'Spørgsmål og svar' afspiller oplæsningen og svaret efter hinanden; /erindringer/alle
viser alt dag for dag. Baggrundsjobs holdes med stærke referencer og logger fejl.
portal.css: én standardregel, så tabeller på smalle skærme ruller i sig selv og
lange ord brydes, i stedet for at siden får vandret scroll.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
root
2026-10-06 22:34:36 +02:00
parent 3dee428a37
commit 49a33b25a6
6 changed files with 347 additions and 29 deletions
+123 -11
View File
@@ -90,6 +90,7 @@
<!-- Trin 3: samtalen -->
<section id="er-samtale" hidden>
<h2 id="er-samtale-titel">Samtale</h2>
<p id="er-dag" hidden class="wt-muted" style="margin:0 0 .8rem">Dagbog for <input type="date" id="er-dato" max="{{ idag }}" value="{{ idag }}" style="font-size:16px;padding:.2rem .4rem"> <span class="wt-muted">(én dag ad gangen; vælg en anden dag for at skrive bagud)</span></p>
<div class="wt-card er-kort" id="er-spoergsmaal-kort">
<p class="wt-muted" style="margin:0 0 .2rem">Spørgsmål <span id="er-nr"></span></p>
@@ -143,21 +144,32 @@
<p class="wt-muted" id="er-dagens-tom">Ingen endnu. Dit første svar kommer her.</p>
</section>
<!-- Trin 4: tidligere svar -->
<!-- Trin 4: tidligere svar, dag for dag; "Afspil begge" spiller spørgsmålet og derefter svaret -->
<section id="er-tidligere">
<h2>Tidligere svar</h2>
<h2>{% if alle %}Alle svar{% else %}Tidligere svar{% endif %}</h2>
{% if klip_seneste %}
<ul class="er-liste">
{% for k in klip_seneste[:20] %}
{% for k in klip_seneste %}
{% if loop.first or k.dag != loop.previtem.dag %}
<li class="er-dagsskel"><h3 style="margin:1.2rem 0 .2rem;font-size:1.05rem"><span data-tid="{{ k.tid }}">{{ k.dag }}</span></h3></li>
{% endif %}
<li data-id="{{ k.id }}">
<p class="er-meta"><span data-tid="{{ k.tid }}">{{ k.tid[:10] }}</span> · {{ k.projekt_titel or k.projekt }}{% if k.varighed %} · {{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}</p>
<p class="er-meta">{{ k.projekt_titel or k.projekt }}{% if k.varighed %} · {{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}</p>
<p class="er-q">{{ k.spoergsmaal }}</p>
<p class="er-tekst" data-tekst>{{ k.tekst }}</p>
<audio controls preload="none" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.5rem"></audio>
<div class="er-ret"><button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button></div>
<div class="er-knapper" style="justify-content:flex-start;margin-top:.4rem">
{% if k.spoergsmaal_lyd %}<button type="button" class="wt-btn" data-begge="{{ k.id }}" aria-label="Afspil spørgsmål og svar">▶ Spørgsmål og svar</button>{% endif %}
<button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button>
</div>
{% if k.spoergsmaal_lyd %}<audio preload="none" data-sp="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}-sp.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}"></audio>{% endif %}
<audio controls preload="none" data-svar="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.4rem"></audio>
<div class="er-ret"></div>
</li>
{% endfor %}
</ul>
{% if not alle and antal_alle > klip_seneste|length %}
<p><a class="wt-btn" href="{{ rod }}/alle{% if noegle %}/{% endif %}">Se alle {{ antal_alle }} svar</a></p>
{% endif %}
{% else %}
<p class="wt-muted">Ingen svar endnu. Det første gemmes her, når du har fortalt noget.</p>
{% endif %}
@@ -184,12 +196,14 @@
tekstfelt: el('er-tekstfelt'), sendTekst: el('er-send-tekst'),
svarKort: el('er-svar-kort'), svarTekst: el('er-svar-tekst'), svarRet: el('er-svar-ret'),
naeste: el('er-naeste'), pause: el('er-pause'), dagens: el('er-dagens'), dagensTom: el('er-dagens-tom'),
flash: el('er-flash'), afrunding: el('er-afrunding'), spring: el('er-spring')
flash: el('er-flash'), afrunding: el('er-afrunding'), spring: el('er-spring'),
dag: el('er-dag'), dato: el('er-dato')
};
// Tilstand for den igangværende samtale
var st = {
projekt: null, titel: '', samtale: null, spoergsmaal: '', talt: '', lydSha: '', kilde: '', lydUrl: null,
kladdeId: null, kladdeKlar: false, dato: '', poll: null,
recorder: null, stream: null, chunks: [], blob: null, mime: '', ext: 'webm',
timer: null, sekunder: 0, startet: 0, sender: false, sendtIDag: 0, lydAfspillet: false
};
@@ -295,6 +309,22 @@
retTekst(b.dataset.ret, els, b.parentNode);
});
// "Spørgsmål og svar": spil spørgsmålets oplæsning, og derefter svaret
document.addEventListener('click', function (e) {
var b = e.target.closest && e.target.closest('[data-begge]');
if (!b) return;
var li = b.closest('[data-id]');
var sp = li && li.querySelector('audio[data-sp]');
var sv = li && li.querySelector('audio[data-svar]');
if (!sv) return;
document.querySelectorAll('audio').forEach(function (a) { if (a !== sp && a !== sv) { try { a.pause(); } catch (x) {} } });
var spilSvar = function () { try { sv.currentTime = 0; var p = sv.play(); if (p && p.catch) p.catch(function () {}); } catch (x) {} };
if (sp) {
sp.onended = spilSvar;
try { sp.currentTime = 0; var p = sp.play(); if (p && p.catch) p.catch(spilSvar); } catch (x) { spilSvar(); }
} else spilSvar();
});
// Datoer i "Tidligere svar" på læsbart dansk
document.querySelectorAll('[data-tid]').forEach(function (s) { s.textContent = datoTekst(s.dataset.tid); });
@@ -305,7 +335,10 @@
if (!kort) return;
st.projekt = slug;
st.titel = kort.dataset.titel || slug;
st.samtale = hentHusket();
// Dagbogen føres én dag ad gangen: serveren vælger samtalen ud fra datoen
st.dato = (slug === 'dagbog') ? ui.dato.value : '';
ui.dag.hidden = (slug !== 'dagbog');
st.samtale = (slug === 'dagbog') ? null : hentHusket();
st.sendtIDag = 0;
ui.titel.textContent = st.titel;
ui.dagens.innerHTML = '';
@@ -321,6 +354,13 @@
var b = e.target.closest && e.target.closest('[data-vaelg]');
if (b) vaelg(b.dataset.vaelg);
});
ui.dato.addEventListener('change', function () {
if (st.projekt !== 'dagbog' || st.sender) return;
st.dato = ui.dato.value;
st.samtale = null;
ui.dagens.innerHTML = ''; ui.dagensTom.hidden = false;
hentSpoergsmaal(false);
});
function holdPause() {
stopStream();
@@ -348,7 +388,7 @@
nulstilOptagelse();
ui.optagelse.hidden = false;
status('');
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null, sprunget: hentSprunget() }).then(function (j) {
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null, sprunget: hentSprunget(), dato: st.dato || '' }).then(function (j) {
st.samtale = j.samtale;
husk(j.samtale);
st.spoergsmaal = j.spoergsmaal || '';
@@ -522,12 +562,52 @@
ui.optaget.hidden = false;
status('Hør dit svar igennem, og send det, når du er tilfreds.');
ui.send.focus();
sendKladde();
}
// Optagelsen sendes op med det samme som kladde, så afskriften er i gang, mens man lytter.
// "Send svaret" bekræfter bare; "Tag det om" sletter kladden igen.
function sendKladde() {
st.kladdeId = null; st.kladdeKlar = false;
var form = new FormData();
form.append('lyd', st.blob, 'svar.' + st.ext);
form.append('kladde', '1');
form.append('projekt', st.projekt);
form.append('samtale', st.samtale || '');
form.append('spoergsmaal', st.spoergsmaal);
form.append('spoergsmaal_talt', st.talt || '');
form.append('lyd_sha', st.lydSha || '');
form.append('kilde', st.kilde || '');
var blob = st.blob;
kald('/svar', form).then(function (j) {
if (st.blob !== blob) { kald('/slet', { id: j.id }).catch(function () {}); return; } // taget om imens
st.kladdeId = j.id;
st.kladdeKlar = true;
if (j.samtale) { st.samtale = j.samtale; if (st.projekt !== 'dagbog') husk(j.samtale); }
if (st.sender) bekraeft(); // brugeren trykkede "Send" før kladden var oppe
}).catch(function () {
st.kladdeKlar = false; // falder tilbage til almindelig sending ved "Send"
});
}
function bekraeft() {
kald('/bekraeft', { id: st.kladdeId }).then(function (j) {
st.sendtIDag++;
visSvar(j);
}).catch(function (e) {
st.sender = false;
ui.send.disabled = false; ui.om.disabled = false; ui.optag.disabled = false;
status('Svaret blev ikke sendt. Det er stadig her, så prøv igen.');
visFlash(e.message);
});
}
ui.optag.addEventListener('click', function () {
if (st.recorder && st.recorder.state === 'recording') stopOptagelse(); else startOptagelse();
});
ui.om.addEventListener('click', function () {
if (st.kladdeId) { kald('/slet', { id: st.kladdeId }).catch(function () {}); }
st.kladdeId = null; st.kladdeKlar = false;
nulstilOptagelse();
status('');
visFlash('');
@@ -570,6 +650,16 @@
ui.send.addEventListener('click', function () {
if (!st.blob) { visFlash('Der er ikke noget optaget endnu. Tryk på Optag først.'); return; }
if (st.sender) return;
if (st.kladdeId || st.kladdeKlar !== false) {
// Kladden er oppe (eller på vej): bekræft, i stedet for at sende lyden igen
st.sender = true;
ui.send.disabled = true; ui.om.disabled = true; ui.optag.disabled = true;
status('Gemmer…', true);
visFlash('');
if (st.kladdeId) bekraeft();
return;
}
var form = new FormData();
form.append('lyd', st.blob, 'svar.' + st.ext);
sendSvar(form);
@@ -583,20 +673,42 @@
});
function visSvar(j) {
var tekst = j.tekst || '';
var venter = (j.status === 'transskriberes');
var tekst = j.tekst || (venter ? 'Teksten er på vej. Du kan gå videre til næste spørgsmål imens.' : '');
ui.optagelse.hidden = true;
status('');
ui.svarTekst.textContent = tekst;
ui.svarTekst.classList.toggle('wt-muted', venter);
ui.svarKort.dataset.id = j.id;
ui.svarKort.hidden = false;
var dagensTekst = tilfoejDagens(j, tekst);
var dagensTekst = tilfoejDagens(j, j.tekst || '');
ui.svarRet.innerHTML = '';
var ret = document.createElement('button');
ret.type = 'button'; ret.className = 'wt-btn'; ret.textContent = 'Ret teksten';
ret.addEventListener('click', function () { retTekst(j.id, [ui.svarTekst, dagensTekst], ui.svarRet); });
ret.hidden = venter;
ui.svarRet.appendChild(ret);
ui.naeste.focus();
st.kladdeId = null; st.kladdeKlar = false;
nulstilOptagelse();
if (venter) ventPaaTekst(j.id, [ui.svarTekst, dagensTekst], ret);
}
// Afskriften kommer i baggrunden: spørg hvert par sekunder, og sæt teksten ind, når den er der
function ventPaaTekst(id, els, retKnap) {
if (st.poll) clearInterval(st.poll);
var n = 0;
st.poll = setInterval(function () {
n++;
kald('/status', { id: id }).then(function (j) {
if (j.status !== 'transskriberes' || n > 60) {
clearInterval(st.poll); st.poll = null;
var t = j.tekst || (j.status === 'fejl' ? 'Afskriften lykkedes ikke; lyden er gemt, og du kan skrive teksten selv.' : '');
els.forEach(function (e) { if (e) { e.textContent = t; e.classList.remove('wt-muted'); } });
if (retKnap) retKnap.hidden = false;
}
}).catch(function () {});
}, 2000);
}
// Sætter svaret øverst i "Dine svar i dag" og returnerer tekst-elementet