Nyt modul erindringer.py: emner (projekter) med baggrund og startspørgsmål, personer (indloggede + gæstelinks), Claude stiller opfølgende spørgsmål, ElevenLabs læser op (cachet) og transskriberer, ffmpeg laver mp3, admin med klip, favoritter og zip-eksport. Forsidekort og admin-modul. Røgtest i tests/. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
607 lines
26 KiB
HTML
607 lines
26 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Erindringer{% endblock %}
|
|
{% block body %}
|
|
<style>
|
|
/* Ingen vandret scroll på iPhone: alt holder sig inden for kortet, og felter
|
|
har mindst 16 px skrift, ellers zoomer iOS ind. */
|
|
html, body { overflow-x: hidden; }
|
|
.er-side { max-width: 100%; overflow-wrap: anywhere; }
|
|
.er-side audio, .er-side textarea, .er-side .wt-card { max-width: 100%; box-sizing: border-box; }
|
|
.er-side audio { display: block; width: 100%; min-width: 0; }
|
|
.er-side textarea { font-size: 16px !important; min-width: 0; min-height: 7rem; line-height: 1.5; }
|
|
.er-side .wt-btn { white-space: normal; }
|
|
|
|
/* Store, rolige knapper til ældre fingre */
|
|
.er-stor { font-size: 1.1rem; padding: .85rem 1.4rem; min-height: 3.2rem; }
|
|
.er-knapper { display: flex; flex-wrap: wrap; gap: .7rem; justify-content: center; margin-top: 1rem; }
|
|
.er-kort { margin: 1rem 0; padding: 1.1rem 1.2rem; }
|
|
.er-emne { display: flex; flex-direction: column; gap: .5rem; }
|
|
.er-emne h3 { margin: 0; font-size: 1.25rem; }
|
|
.er-emne .wt-btn { align-self: flex-start; }
|
|
|
|
/* Spørgsmålet: stor skrift, god linjeafstand */
|
|
.er-spoergsmaal { font-size: 1.3rem; line-height: 1.5; margin: .3rem 0 .8rem; font-weight: 600; }
|
|
|
|
/* Den runde optage-knap */
|
|
.er-midt { display: flex; flex-direction: column; align-items: center; gap: .8rem; margin: 1.4rem 0; text-align: center; }
|
|
.er-optag {
|
|
width: 7.5rem; height: 7.5rem; min-width: 88px; min-height: 88px; border-radius: 50%;
|
|
border: 3px solid var(--color-accent);
|
|
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
|
|
color: inherit; font: inherit; font-size: 1.2rem; font-weight: 700; cursor: pointer;
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .15rem;
|
|
transition: transform .15s, background .2s;
|
|
}
|
|
.er-optag:hover { transform: scale(1.04); }
|
|
.er-optag:disabled { opacity: .5; cursor: default; transform: none; }
|
|
.er-optag.optager {
|
|
background: var(--color-bad); border-color: var(--color-bad); color: #fff;
|
|
animation: er-puls 1.3s ease-in-out infinite;
|
|
}
|
|
.er-optag .er-taeller { font-size: .95rem; font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
@keyframes er-puls {
|
|
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-bad) 55%, transparent); }
|
|
50% { box-shadow: 0 0 0 18px transparent; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) { .er-optag.optager { animation: none; } }
|
|
|
|
.er-status { min-height: 1.5rem; font-size: 1.05rem; color: var(--color-muted); }
|
|
.er-status.travl { color: var(--color-accent); font-weight: 600; }
|
|
.er-link { background: none; border: 0; padding: .4rem; color: var(--color-accent); text-decoration: underline;
|
|
cursor: pointer; font: inherit; font-size: 1rem; }
|
|
|
|
/* Svar-lister */
|
|
.er-liste { list-style: none; padding: 0; margin: .6rem 0 0; }
|
|
.er-liste li { border-top: 1px solid var(--color-border); padding: .9rem 0; }
|
|
.er-liste li:first-child { border-top: 0; }
|
|
.er-liste .er-q { font-weight: 700; margin: 0 0 .3rem; font-size: 1.05rem; }
|
|
.er-liste .er-meta { color: var(--color-muted); font-size: .9rem; margin: 0 0 .3rem; }
|
|
.er-tekst { margin: 0; white-space: pre-line; line-height: 1.55; font-size: 1.05rem; }
|
|
.er-ret { margin-top: .5rem; }
|
|
.er-ret .wt-btn { font-size: .95rem; }
|
|
.er-dit-svar { background: color-mix(in srgb, var(--color-good) 8%, transparent); border-color: color-mix(in srgb, var(--color-good) 40%, transparent); }
|
|
</style>
|
|
|
|
<div class="er-side" id="er-side"
|
|
data-api="{{ api }}" data-rod="{{ rod }}" data-noegle="{{ noegle }}" data-valgt="{{ valgt }}">
|
|
|
|
<h1>Erindringer</h1>
|
|
<p class="wt-lead">Hej {{ navn }}. Fortæl, så gemmer vi det. Du kan altid holde en pause og komme tilbage.</p>
|
|
|
|
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
|
<div class="wt-flash" id="er-flash" hidden role="status" aria-live="polite"></div>
|
|
|
|
<!-- Trin 2: vælg emne -->
|
|
<section id="er-emner">
|
|
<h2>Vælg, hvad du vil fortælle om</h2>
|
|
{% for p in projekter %}
|
|
<div class="wt-card er-kort er-emne" data-slug="{{ p.slug }}" data-titel="{{ p.titel }}">
|
|
<h3>{{ p.titel }}</h3>
|
|
{% if p.beskrivelse %}<p style="margin:0">{{ p.beskrivelse }}</p>{% endif %}
|
|
<p class="wt-muted" style="margin:0"><span class="er-antal">{{ p.antal }}</span> svar indtil nu</p>
|
|
<button type="button" class="wt-btn wt-btn--primary er-stor" data-vaelg="{{ p.slug }}"
|
|
aria-label="Fortæl om {{ p.titel }}">Fortæl</button>
|
|
</div>
|
|
{% else %}
|
|
<p class="wt-muted">Der er ingen emner endnu.</p>
|
|
{% endfor %}
|
|
</section>
|
|
|
|
<!-- Trin 3: samtalen -->
|
|
<section id="er-samtale" hidden>
|
|
<h2 id="er-samtale-titel">Samtale</h2>
|
|
|
|
<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>
|
|
<p class="er-spoergsmaal" id="er-spoergsmaal">Henter spørgsmålet…</p>
|
|
<button type="button" class="wt-btn er-stor" id="er-hoer" hidden aria-label="Hør spørgsmålet læst op">🔊 Hør spørgsmålet</button>
|
|
<audio id="er-lyd" preload="auto"></audio>
|
|
</div>
|
|
|
|
<div class="er-midt" id="er-optagelse">
|
|
<button type="button" class="er-optag" id="er-optag" aria-label="Optag dit svar" aria-pressed="false">
|
|
<span id="er-optag-tekst">Optag</span>
|
|
<span class="er-taeller" id="er-taeller" hidden>0:00</span>
|
|
</button>
|
|
<p class="er-status" id="er-status" aria-live="polite"></p>
|
|
<p class="wt-muted" style="margin:0">Tal bare, som du ville fortælle det til et barnebarn.</p>
|
|
|
|
<div id="er-optaget" hidden style="width:100%;max-width:32rem">
|
|
<audio id="er-afspil" controls></audio>
|
|
<div class="er-knapper">
|
|
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-send">Send svaret</button>
|
|
<button type="button" class="wt-btn er-stor" id="er-om">Tag det om</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="button" class="er-link" id="er-skriv-link">Skriv i stedet</button>
|
|
<div id="er-skriv" hidden style="width:100%;max-width:32rem;text-align:left">
|
|
<label for="er-tekstfelt" class="wt-muted">Skriv dit svar her</label>
|
|
<textarea id="er-tekstfelt" rows="5"></textarea>
|
|
<div class="er-knapper">
|
|
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-send-tekst">Send teksten</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="wt-card er-kort er-dit-svar" id="er-svar-kort" hidden>
|
|
<p class="wt-muted" style="margin:0 0 .3rem">Dit svar</p>
|
|
<p class="er-tekst" id="er-svar-tekst"></p>
|
|
<div class="er-ret" id="er-svar-ret"></div>
|
|
<div class="er-knapper">
|
|
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-naeste">Næste spørgsmål</button>
|
|
<button type="button" class="wt-btn er-stor" id="er-pause">Hold pause</button>
|
|
</div>
|
|
</div>
|
|
|
|
<h2>Dine svar i dag</h2>
|
|
<ul class="er-liste" id="er-dagens"></ul>
|
|
<p class="wt-muted" id="er-dagens-tom">Ingen endnu. Dit første svar kommer her.</p>
|
|
</section>
|
|
|
|
<!-- Trin 4: tidligere svar -->
|
|
<section id="er-tidligere">
|
|
<h2>Tidligere svar</h2>
|
|
{% if klip_seneste %}
|
|
<ul class="er-liste">
|
|
{% for k in klip_seneste[:20] %}
|
|
<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-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>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
<p class="wt-muted">Ingen svar endnu. Det første gemmes her, når du har fortalt noget.</p>
|
|
{% endif %}
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
if (window.__erindringer) return;
|
|
window.__erindringer = true;
|
|
|
|
var side = document.getElementById('er-side');
|
|
var API = side.dataset.api, ROD = side.dataset.rod, NOEGLE = side.dataset.noegle || '';
|
|
var MAKS_SEK = 600; // 10 minutter pr. svar
|
|
var MIMER = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
var ui = {
|
|
emner: el('er-emner'), samtale: el('er-samtale'), titel: el('er-samtale-titel'),
|
|
nr: el('er-nr'), spoergsmaal: el('er-spoergsmaal'), hoer: el('er-hoer'), lyd: el('er-lyd'),
|
|
optagelse: el('er-optagelse'), optag: el('er-optag'), optagTekst: el('er-optag-tekst'), taeller: el('er-taeller'),
|
|
status: el('er-status'), optaget: el('er-optaget'), afspil: el('er-afspil'),
|
|
send: el('er-send'), om: el('er-om'), skrivLink: el('er-skriv-link'), skriv: el('er-skriv'),
|
|
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')
|
|
};
|
|
|
|
// Tilstand for den igangværende samtale
|
|
var st = {
|
|
projekt: null, titel: '', samtale: null, spoergsmaal: '', lydUrl: null,
|
|
recorder: null, stream: null, chunks: [], blob: null, mime: '', ext: 'webm',
|
|
timer: null, sekunder: 0, startet: 0, sender: false, sendtIDag: 0, lydAfspillet: false
|
|
};
|
|
|
|
/* ---------- Hjælpere ---------- */
|
|
|
|
function visFlash(tekst) {
|
|
ui.flash.textContent = tekst;
|
|
ui.flash.hidden = !tekst;
|
|
}
|
|
function status(tekst, travl) {
|
|
ui.status.textContent = tekst || '';
|
|
ui.status.classList.toggle('travl', !!travl);
|
|
}
|
|
function mmss(s) {
|
|
s = Math.max(0, Math.round(s));
|
|
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
|
|
}
|
|
function datoTekst(iso) {
|
|
try {
|
|
var d = new Date(iso);
|
|
if (isNaN(d)) return iso.slice(0, 10);
|
|
return d.toLocaleDateString('da-DK', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
} catch (e) { return iso.slice(0, 10); }
|
|
}
|
|
function lagerNoegle() { return 'erindringer:samtale:' + st.projekt; }
|
|
function husk(id) { try { sessionStorage.setItem(lagerNoegle(), id); } catch (e) {} }
|
|
function glem() { try { sessionStorage.removeItem(lagerNoegle()); } catch (e) {} }
|
|
function hentHusket() { try { return sessionStorage.getItem(lagerNoegle()); } catch (e) { return null; } }
|
|
|
|
// Fælles fetch: JSON-body eller FormData, altid med nøgle og marker-header.
|
|
// Ingen timeout: transskription kan tage lang tid.
|
|
function kald(sti, data) {
|
|
var erForm = data instanceof FormData;
|
|
if (erForm) data.append('noegle', NOEGLE); else data.noegle = NOEGLE;
|
|
var opts = { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } };
|
|
if (erForm) { opts.body = data; }
|
|
else { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(data); }
|
|
return fetch(API + sti, opts).then(function (r) {
|
|
return r.text().then(function (t) {
|
|
var j = null;
|
|
try { j = t ? JSON.parse(t) : null; } catch (e) {}
|
|
if (!r.ok) throw new Error((j && j.fejl) || 'Noget gik galt (' + r.status + '). Prøv igen om lidt.');
|
|
return j || {};
|
|
});
|
|
}, function () {
|
|
throw new Error('Vi kunne ikke få forbindelse. Tjek, at du har net, og prøv igen.');
|
|
});
|
|
}
|
|
|
|
/* ---------- Ret teksten (bruges både i dag og i tidligere svar) ---------- */
|
|
|
|
// tekstEls: ét eller flere elementer, der viser samme tekst og skal følges ad
|
|
function retTekst(id, tekstEls, holder) {
|
|
if (holder.querySelector('textarea')) return;
|
|
if (!Array.isArray(tekstEls)) tekstEls = [tekstEls];
|
|
holder.innerHTML = '';
|
|
var ta = document.createElement('textarea');
|
|
ta.value = tekstEls[0].textContent;
|
|
ta.rows = 6;
|
|
var gem = document.createElement('button');
|
|
gem.type = 'button'; gem.className = 'wt-btn wt-btn--primary'; gem.textContent = 'Gem';
|
|
var fortryd = document.createElement('button');
|
|
fortryd.type = 'button'; fortryd.className = 'wt-btn'; fortryd.textContent = 'Fortryd';
|
|
var knapper = document.createElement('div');
|
|
knapper.className = 'er-knapper'; knapper.style.justifyContent = 'flex-start';
|
|
knapper.appendChild(gem); knapper.appendChild(fortryd);
|
|
holder.appendChild(ta); holder.appendChild(knapper);
|
|
ta.focus();
|
|
|
|
function tilbage() {
|
|
holder.innerHTML = '';
|
|
var b = document.createElement('button');
|
|
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten';
|
|
b.setAttribute('data-ret', id);
|
|
holder.appendChild(b);
|
|
}
|
|
fortryd.addEventListener('click', tilbage);
|
|
gem.addEventListener('click', function () {
|
|
var ny = ta.value.trim();
|
|
gem.disabled = true; fortryd.disabled = true; gem.textContent = 'Gemmer…';
|
|
kald('/ret', { id: id, tekst: ny }).then(function () {
|
|
tekstEls.forEach(function (t) { t.textContent = ny; });
|
|
tilbage();
|
|
visFlash('Teksten er rettet.');
|
|
}).catch(function (e) {
|
|
gem.disabled = false; fortryd.disabled = false; gem.textContent = 'Gem';
|
|
visFlash(e.message);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Alle "Ret teksten"-knapper, også dem der oprettes senere
|
|
document.addEventListener('click', function (e) {
|
|
var b = e.target.closest && e.target.closest('[data-ret]');
|
|
if (!b) return;
|
|
var li = b.closest('[data-id]');
|
|
var tekstEl = li ? li.querySelector('[data-tekst]') : null;
|
|
if (!li || !tekstEl) return;
|
|
var els = [tekstEl];
|
|
// Er det dagens seneste svar, skal "Dit svar"-kortet følge med
|
|
if (!ui.svarKort.hidden && ui.svarKort.dataset.id === b.dataset.ret) els.push(ui.svarTekst);
|
|
retTekst(b.dataset.ret, els, b.parentNode);
|
|
});
|
|
|
|
// Datoer i "Tidligere svar" på læsbart dansk
|
|
document.querySelectorAll('[data-tid]').forEach(function (s) { s.textContent = datoTekst(s.dataset.tid); });
|
|
|
|
/* ---------- Emnevalg ---------- */
|
|
|
|
function vaelg(slug) {
|
|
var kort = ui.emner.querySelector('[data-slug="' + slug + '"]');
|
|
if (!kort) return;
|
|
st.projekt = slug;
|
|
st.titel = kort.dataset.titel || slug;
|
|
st.samtale = hentHusket();
|
|
st.sendtIDag = 0;
|
|
ui.titel.textContent = st.titel;
|
|
ui.dagens.innerHTML = '';
|
|
ui.dagensTom.hidden = false;
|
|
visFlash('');
|
|
ui.emner.hidden = true;
|
|
ui.samtale.hidden = false;
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
hentSpoergsmaal(true);
|
|
}
|
|
|
|
ui.emner.addEventListener('click', function (e) {
|
|
var b = e.target.closest && e.target.closest('[data-vaelg]');
|
|
if (b) vaelg(b.dataset.vaelg);
|
|
});
|
|
|
|
function holdPause() {
|
|
stopStream();
|
|
stopTimer();
|
|
nulstilOptagelse();
|
|
ui.svarKort.hidden = true;
|
|
ui.samtale.hidden = true;
|
|
ui.emner.hidden = false;
|
|
// Opdatér tællingen på emnekortet
|
|
var kort = ui.emner.querySelector('[data-slug="' + st.projekt + '"] .er-antal');
|
|
if (kort) kort.textContent = (parseInt(kort.textContent, 10) || 0) + st.sendtIDag;
|
|
st.sendtIDag = 0;
|
|
visFlash('Tak for i dag. Dine svar er gemt.');
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
ui.pause.addEventListener('click', holdPause);
|
|
|
|
/* ---------- Spørgsmål ---------- */
|
|
|
|
function hentSpoergsmaal(foersteForsoeg) {
|
|
ui.spoergsmaal.textContent = 'Henter spørgsmålet…';
|
|
ui.nr.textContent = '';
|
|
ui.hoer.hidden = true;
|
|
ui.svarKort.hidden = true;
|
|
nulstilOptagelse();
|
|
ui.optagelse.hidden = false;
|
|
status('');
|
|
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null }).then(function (j) {
|
|
st.samtale = j.samtale;
|
|
husk(j.samtale);
|
|
st.spoergsmaal = j.spoergsmaal || '';
|
|
st.lydUrl = j.lyd_url || null;
|
|
ui.spoergsmaal.textContent = st.spoergsmaal;
|
|
ui.nr.textContent = j.nr ? String(j.nr) : '';
|
|
ui.hoer.hidden = !st.lydUrl;
|
|
if (st.lydUrl) {
|
|
ui.lyd.src = st.lydUrl;
|
|
// Afspil automatisk, hvis browseren tillader det; ellers kun på klik
|
|
var p = ui.lyd.play();
|
|
if (p && p.catch) p.catch(function () {});
|
|
}
|
|
}).catch(function (e) {
|
|
// Et gammelt samtale-id kan være udløbet: prøv én gang forfra
|
|
if (foersteForsoeg && st.samtale) { st.samtale = null; glem(); return hentSpoergsmaal(false); }
|
|
ui.spoergsmaal.textContent = 'Vi kunne ikke hente et spørgsmål.';
|
|
visFlash(e.message + ' Du kan holde pause og prøve igen senere.');
|
|
});
|
|
}
|
|
|
|
ui.hoer.addEventListener('click', function () {
|
|
try {
|
|
ui.lyd.currentTime = 0;
|
|
var p = ui.lyd.play();
|
|
if (p && p.catch) p.catch(function () { visFlash('Lyden kunne ikke afspilles. Du kan læse spørgsmålet ovenfor.'); });
|
|
} catch (e) {}
|
|
});
|
|
ui.naeste.addEventListener('click', function () { hentSpoergsmaal(false); });
|
|
|
|
/* ---------- Optagelse ---------- */
|
|
|
|
function vaelgMime() {
|
|
if (!window.MediaRecorder || !MediaRecorder.isTypeSupported) return '';
|
|
for (var i = 0; i < MIMER.length; i++) if (MediaRecorder.isTypeSupported(MIMER[i])) return MIMER[i];
|
|
return '';
|
|
}
|
|
function endelse(mime) {
|
|
if (mime.indexOf('mp4') > -1) return 'mp4';
|
|
if (mime.indexOf('ogg') > -1) return 'ogg';
|
|
return 'webm';
|
|
}
|
|
function stopStream() {
|
|
if (st.stream) { st.stream.getTracks().forEach(function (t) { t.stop(); }); st.stream = null; }
|
|
}
|
|
function stopTimer() {
|
|
if (st.timer) { clearInterval(st.timer); st.timer = null; }
|
|
}
|
|
function nulstilOptagelse() {
|
|
st.blob = null; st.chunks = []; st.sekunder = 0;
|
|
ui.optag.disabled = false;
|
|
ui.optag.classList.remove('optager');
|
|
ui.optag.setAttribute('aria-pressed', 'false');
|
|
ui.optagTekst.textContent = 'Optag';
|
|
ui.taeller.hidden = true; ui.taeller.textContent = '0:00';
|
|
ui.optag.hidden = false;
|
|
ui.optaget.hidden = true;
|
|
if (ui.afspil.src) { try { URL.revokeObjectURL(ui.afspil.src); } catch (e) {} ui.afspil.removeAttribute('src'); }
|
|
ui.skriv.hidden = true; ui.skrivLink.hidden = false;
|
|
ui.tekstfelt.value = '';
|
|
ui.send.disabled = false; ui.om.disabled = false; ui.sendTekst.disabled = false;
|
|
st.sender = false;
|
|
}
|
|
|
|
function startOptagelse() {
|
|
visFlash('');
|
|
var mime = vaelgMime();
|
|
if (!window.MediaRecorder || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
visFlash('Din browser kan ikke optage lyd. Brug "Skriv i stedet" herunder.');
|
|
visSkriv();
|
|
return;
|
|
}
|
|
ui.optag.disabled = true;
|
|
status('Gør mikrofonen klar…');
|
|
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
|
st.stream = stream;
|
|
st.chunks = [];
|
|
st.mime = mime;
|
|
st.ext = endelse(mime);
|
|
try {
|
|
st.recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
|
} catch (e) {
|
|
stopStream();
|
|
throw new Error('Optageren kunne ikke startes. Brug "Skriv i stedet", eller prøv en anden browser.');
|
|
}
|
|
if (!st.mime) st.mime = st.recorder.mimeType || '';
|
|
st.ext = endelse(st.mime);
|
|
st.recorder.addEventListener('dataavailable', function (e) { if (e.data && e.data.size) st.chunks.push(e.data); });
|
|
st.recorder.addEventListener('stop', optagelseFaerdig);
|
|
st.recorder.addEventListener('error', function () {
|
|
visFlash('Optagelsen blev afbrudt. Prøv igen, eller brug "Skriv i stedet".');
|
|
stopOptagelse();
|
|
});
|
|
st.recorder.start(1000);
|
|
st.startet = Date.now();
|
|
st.sekunder = 0;
|
|
ui.optag.disabled = false;
|
|
ui.optag.classList.add('optager');
|
|
ui.optag.setAttribute('aria-pressed', 'true');
|
|
ui.optag.setAttribute('aria-label', 'Stop optagelsen');
|
|
ui.optagTekst.textContent = 'Stop';
|
|
ui.taeller.hidden = false; ui.taeller.textContent = '0:00';
|
|
ui.skrivLink.hidden = true;
|
|
status('Vi optager. Tryk på knappen, når du er færdig.');
|
|
st.timer = setInterval(function () {
|
|
st.sekunder = Math.round((Date.now() - st.startet) / 1000);
|
|
ui.taeller.textContent = mmss(st.sekunder);
|
|
if (st.sekunder >= MAKS_SEK) {
|
|
stopOptagelse();
|
|
visFlash('Vi stoppede optagelsen efter 10 minutter. Du kan sende svaret nu og fortsætte på næste spørgsmål.');
|
|
}
|
|
}, 1000);
|
|
}).catch(function (e) {
|
|
stopStream();
|
|
ui.optag.disabled = false;
|
|
status('');
|
|
var navn = e && e.name;
|
|
if (navn === 'NotAllowedError' || navn === 'SecurityError' || navn === 'PermissionDeniedError') {
|
|
visFlash('Vi fik ikke lov til at bruge mikrofonen. Tillad mikrofonen i browseren, eller brug "Skriv i stedet".');
|
|
} else if (navn === 'NotFoundError' || navn === 'DevicesNotFoundError') {
|
|
visFlash('Vi kunne ikke finde en mikrofon. Brug "Skriv i stedet".');
|
|
} else {
|
|
visFlash((e && e.message) || 'Mikrofonen kunne ikke startes. Prøv igen, eller brug "Skriv i stedet".');
|
|
}
|
|
visSkriv();
|
|
});
|
|
}
|
|
|
|
function stopOptagelse() {
|
|
stopTimer();
|
|
ui.optag.disabled = true;
|
|
status('Gør optagelsen klar…');
|
|
try {
|
|
if (st.recorder && st.recorder.state !== 'inactive') st.recorder.stop();
|
|
else optagelseFaerdig();
|
|
} catch (e) { optagelseFaerdig(); }
|
|
}
|
|
|
|
function optagelseFaerdig() {
|
|
stopStream(); // ellers bliver den røde mikrofon-prik stående på iPhone
|
|
ui.optag.classList.remove('optager');
|
|
ui.optag.setAttribute('aria-pressed', 'false');
|
|
ui.optag.setAttribute('aria-label', 'Optag dit svar');
|
|
ui.optagTekst.textContent = 'Optag';
|
|
ui.taeller.hidden = true;
|
|
if (!st.chunks.length) {
|
|
nulstilOptagelse();
|
|
status('');
|
|
visFlash('Der kom ingen lyd med. Prøv igen, og tal tæt på telefonen.');
|
|
return;
|
|
}
|
|
st.blob = new Blob(st.chunks, { type: st.mime || 'application/octet-stream' });
|
|
ui.afspil.src = URL.createObjectURL(st.blob);
|
|
ui.optag.hidden = true;
|
|
ui.optaget.hidden = false;
|
|
status('Hør dit svar igennem, og send det, når du er tilfreds.');
|
|
ui.send.focus();
|
|
}
|
|
|
|
ui.optag.addEventListener('click', function () {
|
|
if (st.recorder && st.recorder.state === 'recording') stopOptagelse(); else startOptagelse();
|
|
});
|
|
ui.om.addEventListener('click', function () {
|
|
nulstilOptagelse();
|
|
status('');
|
|
visFlash('');
|
|
});
|
|
|
|
/* ---------- Skriv i stedet ---------- */
|
|
|
|
function visSkriv() {
|
|
ui.skriv.hidden = false;
|
|
ui.skrivLink.hidden = true;
|
|
ui.tekstfelt.focus();
|
|
}
|
|
ui.skrivLink.addEventListener('click', visSkriv);
|
|
|
|
/* ---------- Send svar ---------- */
|
|
|
|
function sendSvar(form) {
|
|
if (st.sender) return;
|
|
st.sender = true;
|
|
ui.send.disabled = true; ui.om.disabled = true; ui.sendTekst.disabled = true; ui.optag.disabled = true;
|
|
status('Gemmer og skriver af… Det kan tage op til et halvt minut.', true);
|
|
visFlash('');
|
|
form.append('projekt', st.projekt);
|
|
form.append('samtale', st.samtale || '');
|
|
form.append('spoergsmaal', st.spoergsmaal);
|
|
kald('/svar', form).then(function (j) {
|
|
st.sendtIDag++;
|
|
visSvar(j);
|
|
}).catch(function (e) {
|
|
// Behold det optagede, så det kan sendes igen
|
|
st.sender = false;
|
|
ui.send.disabled = false; ui.om.disabled = false; ui.sendTekst.disabled = false; ui.optag.disabled = false;
|
|
status('Svaret blev ikke sendt. Det er stadig her, så prøv igen.');
|
|
visFlash(e.message);
|
|
});
|
|
}
|
|
|
|
ui.send.addEventListener('click', function () {
|
|
if (!st.blob) { visFlash('Der er ikke noget optaget endnu. Tryk på Optag først.'); return; }
|
|
var form = new FormData();
|
|
form.append('lyd', st.blob, 'svar.' + st.ext);
|
|
sendSvar(form);
|
|
});
|
|
ui.sendTekst.addEventListener('click', function () {
|
|
var t = ui.tekstfelt.value.trim();
|
|
if (!t) { visFlash('Skriv lidt i feltet først.'); ui.tekstfelt.focus(); return; }
|
|
var form = new FormData();
|
|
form.append('tekst', t);
|
|
sendSvar(form);
|
|
});
|
|
|
|
function visSvar(j) {
|
|
var tekst = j.tekst || '';
|
|
ui.optagelse.hidden = true;
|
|
status('');
|
|
ui.svarTekst.textContent = tekst;
|
|
ui.svarKort.dataset.id = j.id;
|
|
ui.svarKort.hidden = false;
|
|
var dagensTekst = tilfoejDagens(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); });
|
|
ui.svarRet.appendChild(ret);
|
|
ui.naeste.focus();
|
|
nulstilOptagelse();
|
|
}
|
|
|
|
// Sætter svaret øverst i "Dine svar i dag" og returnerer tekst-elementet
|
|
function tilfoejDagens(j, tekst) {
|
|
var li = document.createElement('li');
|
|
li.setAttribute('data-id', j.id);
|
|
var q = document.createElement('p'); q.className = 'er-q'; q.textContent = st.spoergsmaal;
|
|
var t = document.createElement('p'); t.className = 'er-tekst'; t.setAttribute('data-tekst', ''); t.textContent = tekst;
|
|
li.appendChild(q); li.appendChild(t);
|
|
if (j.varighed) {
|
|
var m = document.createElement('p'); m.className = 'er-meta'; m.textContent = 'Varighed ' + mmss(j.varighed);
|
|
li.appendChild(m);
|
|
}
|
|
var ret = document.createElement('div'); ret.className = 'er-ret';
|
|
var b = document.createElement('button');
|
|
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten'; b.setAttribute('data-ret', j.id);
|
|
ret.appendChild(b); li.appendChild(ret);
|
|
ui.dagens.insertBefore(li, ui.dagens.firstChild);
|
|
ui.dagensTom.hidden = true;
|
|
return t;
|
|
}
|
|
|
|
// Slip mikrofonen, hvis siden forlades midt i en optagelse
|
|
window.addEventListener('pagehide', function () { stopTimer(); stopStream(); });
|
|
|
|
// Åbn et projekt med det samme, hvis serveren bad om det
|
|
if (side.dataset.valgt) vaelg(side.dataset.valgt);
|
|
})();
|
|
</script>
|
|
{% endblock %}
|