ui: echte Mobile-First-Variante — Recording-Hero, Job-Cards, Admin-Klappe

Mobile-Layout ab <700px komplett umgebaut, nicht mehr nur Desktop-Verkleinerung:

- Header: nur Logo + User + Profil-Mini-Dropdown + Logout
- Upload-Card: großer roter Aufnahme-Button als Hero-Element
  - Profil und Titel darunter, kompakt
  - Datei-Upload in <details>-Klappe (auf Desktop offen, Mobile zu)
- Jobs: Card-Liste statt Tabelle (Tabelle nur Desktop, Cards nur Mobile)
- Admin: H2 als Toggle, Body kollabiert (auf Desktop immer offen)
- Inputs auf Mobile mit font-size:16px → kein iOS-Zoom-on-Focus
- Recording-Pane: Timer 64px, Buttons full-width
- safe-area-inset für iPhone-Notch + Bottom-Bar

Ohne JS-Mode-Switch — alles via CSS Media Query +
.desktop-only / .mobile-only Helfer-Klassen. JS rendert beide
Job-Repräsentationen, CSS schaltet die richtige sichtbar.
This commit is contained in:
root 2026-05-14 04:00:33 +00:00
parent e75bc05421
commit 24ac3ac058
3 changed files with 260 additions and 71 deletions

View File

@ -37,6 +37,10 @@ const defaultProfileName = document.getElementById("default-profile-name");
const headerProfileWrap = document.getElementById("header-profile-wrap"); const headerProfileWrap = document.getElementById("header-profile-wrap");
const headerProfile = document.getElementById("header-profile"); const headerProfile = document.getElementById("header-profile");
const jobsCards = document.getElementById("jobs-cards");
const adminToggle = document.getElementById("admin-toggle");
const adminCollapsible = document.getElementById("admin-card");
const recordToggle = document.getElementById("record-toggle"); const recordToggle = document.getElementById("record-toggle");
const recordToggleLabel = document.getElementById("record-toggle-label"); const recordToggleLabel = document.getElementById("record-toggle-label");
const recordPane = document.getElementById("record-pane"); const recordPane = document.getElementById("record-pane");
@ -302,6 +306,43 @@ function row(job) {
return tr; return tr;
} }
function card(job) {
const div = document.createElement("div");
div.className = "jc";
const title = job.title || job.original_name;
const showProfileTag = availableProfiles.length > 1 || (job.profile && job.profile !== "meeting");
const profileTag = showProfileTag
? `<span class="tag-profile">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>`
: "";
const ownerLine = currentUser?.is_admin && job.owner_username
? `<div class="jc-sub">von ${escapeHtml(job.owner_username)}</div>` : "";
const dlButtons = DOWNLOADS.map(({ kind, label, title: tip }) => {
const dis = !job.has[kind];
const href = dis ? "" : `href="/api/jobs/${job.id}/download/${kind}"`;
return `<a class="btn-dl ${dis ? "disabled" : ""}" ${href} title="${escapeHtml(tip)}">${label}</a>`;
}).join("");
div.innerHTML = `
<div class="jc-head">
<div>
<div class="jc-title">${escapeHtml(title)} ${profileTag}</div>
<div class="jc-sub">${escapeHtml(job.original_name)} · <span class="jc-id">#${job.id}</span></div>
${ownerLine}
</div>
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
</div>
<div class="jc-status-row">
<div class="jc-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div></div>
<small class="muted">${job.progress}%</small>
</div>
${job.error ? `<div class="jc-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
<div class="jc-sub">aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}</div>
<div class="jc-dl">${dlButtons}</div>
`;
return div;
}
async function loadJobs() { async function loadJobs() {
if (!currentUser) return; if (!currentUser) return;
try { try {
@ -309,11 +350,16 @@ async function loadJobs() {
if (r.status === 401) { showAuth("login"); return; } if (r.status === 401) { showAuth("login"); return; }
const jobs = await r.json(); const jobs = await r.json();
jobsBody.innerHTML = ""; jobsBody.innerHTML = "";
jobsCards.innerHTML = "";
if (!jobs.length) { if (!jobs.length) {
jobsBody.innerHTML = '<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>'; jobsBody.innerHTML = '<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>';
jobsCards.innerHTML = '<div class="muted">Noch keine Jobs.</div>';
return; return;
} }
jobs.forEach((j) => jobsBody.appendChild(row(j))); jobs.forEach((j) => {
jobsBody.appendChild(row(j));
jobsCards.appendChild(card(j));
});
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
@ -613,6 +659,13 @@ recPauseBtn.addEventListener("click", pauseRecording);
recStopBtn.addEventListener("click", stopRecording); recStopBtn.addEventListener("click", stopRecording);
recordDiscardBtn.addEventListener("click", discardRecording); recordDiscardBtn.addEventListener("click", discardRecording);
// Admin-Toggle (klappbar auf Mobile, immer offen auf Desktop)
adminToggle.addEventListener("click", () => {
if (window.matchMedia("(max-width: 700px)").matches) {
adminCollapsible.classList.toggle("open");
}
});
// ─── Init ────────────────────────────────────────────────────────────────── // ─── Init ──────────────────────────────────────────────────────────────────
bootstrap(); bootstrap();
checkMac(); checkMac();

View File

@ -19,17 +19,17 @@
<div class="header-row"> <div class="header-row">
<div class="header-left"> <div class="header-left">
<h1>Voice-Agent</h1> <h1>Voice-Agent</h1>
<p class="sub">Sitzungsaufnahmen automatisch transkribieren und protokollieren</p> <p class="sub desktop-only">Sitzungsaufnahmen automatisch transkribieren und protokollieren</p>
</div> </div>
<div class="header-right"> <div class="header-right">
<div id="mac-status" class="badge">Mac-Backend: prüfe …</div> <div id="mac-status" class="badge desktop-only">Mac-Backend: prüfe …</div>
<div id="user-bar" class="user-bar hidden"> <div id="user-bar" class="user-bar hidden">
<span class="muted">Angemeldet als</span> <span class="muted desktop-only">Angemeldet als</span>
<strong id="user-name"></strong> <strong id="user-name"></strong>
<span id="user-admin-badge" class="badge admin-badge hidden">Admin</span> <span id="user-admin-badge" class="badge admin-badge hidden">Admin</span>
<span id="header-profile-wrap" class="header-profile-wrap hidden"> <span id="header-profile-wrap" class="header-profile-wrap hidden">
<span class="muted">·</span> <span class="muted desktop-only">·</span>
<span class="muted">Profil:</span> <span class="muted desktop-only">Profil:</span>
<select id="header-profile" class="header-profile" title="Standard-Profil — wird sofort gespeichert"></select> <select id="header-profile" class="header-profile" title="Standard-Profil — wird sofort gespeichert"></select>
</span> </span>
<button type="button" id="logout-btn" class="btn-link">Abmelden</button> <button type="button" id="logout-btn" class="btn-link">Abmelden</button>
@ -59,30 +59,16 @@
</div> </div>
<main id="app" class="hidden"> <main id="app" class="hidden">
<section class="card"> <section class="card upload-card">
<h2>Neue Aufnahme hochladen</h2> <h2 class="desktop-only">Neue Aufnahme hochladen</h2>
<form id="upload-form"> <form id="upload-form">
<label> <!-- HERO: Aufnahme-Button (auf Mobile groß und zentriert) -->
Titel (optional)
<input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung" />
</label>
<label id="profile-label" class="hidden">
Verarbeitungs-Profil
<select id="profile-select"></select>
<small class="muted">Standard: <span id="default-profile-name"></span> (in Einstellungen änderbar)</small>
</label>
<div class="audio-source">
<label class="audio-file-label">
<span>Audiodatei wählen <small class="muted">(MP3, WAV, M4A, MP4, OGG, FLAC, WebM)</small></span>
<input type="file" name="audio" id="audio" accept="audio/*,.mp3,.wav,.m4a,.mp4,.ogg,.flac,.webm" />
</label>
<div class="audio-or muted">— oder —</div>
<button type="button" id="record-toggle" class="btn-record"> <button type="button" id="record-toggle" class="btn-record">
<span class="rec-dot"></span> <span class="rec-dot"></span>
<span id="record-toggle-label">Aufnahme starten</span> <span id="record-toggle-label">Aufnahme starten</span>
</button> </button>
</div>
<!-- Aufnahme-Bühne -->
<div id="record-pane" class="record-pane hidden"> <div id="record-pane" class="record-pane hidden">
<div class="record-timer" id="record-timer">00:00</div> <div class="record-timer" id="record-timer">00:00</div>
<div class="record-status" id="record-status">Aufnahme läuft</div> <div class="record-status" id="record-status">Aufnahme läuft</div>
@ -92,12 +78,35 @@
</div> </div>
</div> </div>
<!-- Aufnahme bereit -->
<div id="record-result" class="record-result hidden"> <div id="record-result" class="record-result hidden">
<span class="muted">Aufnahme bereit:</span> <span class="muted">Aufnahme bereit:</span>
<span id="record-filename"></span> <span id="record-filename"></span>
<button type="button" id="record-discard" class="btn-link btn-danger">Verwerfen</button> <button type="button" id="record-discard" class="btn-link btn-danger">Verwerfen</button>
</div> </div>
<!-- Profil + Titel -->
<div class="upload-meta">
<label id="profile-label" class="hidden">
Verarbeitungs-Profil
<select id="profile-select"></select>
<small class="muted desktop-only">Standard: <span id="default-profile-name"></span></small>
</label>
<label>
Titel <span class="muted">(optional)</span>
<input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung" />
</label>
</div>
<!-- Datei-Upload als Klappe (auf Mobile collapsed, auf Desktop offen) -->
<details class="audio-file-details" id="audio-file-details">
<summary>Lieber eine vorhandene Audio-Datei hochladen?</summary>
<label class="audio-file-label">
<span class="muted">MP3, WAV, M4A, MP4, OGG, FLAC, WebM</span>
<input type="file" name="audio" id="audio" accept="audio/*,.mp3,.wav,.m4a,.mp4,.ogg,.flac,.webm" />
</label>
</details>
<button type="submit" id="submit-btn">Hochladen &amp; verarbeiten</button> <button type="submit" id="submit-btn">Hochladen &amp; verarbeiten</button>
</form> </form>
<div id="upload-progress" class="hidden"> <div id="upload-progress" class="hidden">
@ -107,9 +116,10 @@
<p id="upload-msg" class="msg"></p> <p id="upload-msg" class="msg"></p>
</section> </section>
<section class="card"> <section class="card jobs-card">
<h2>Jobs</h2> <h2>Jobs</h2>
<table id="jobs-table"> <!-- Desktop: Tabelle -->
<table id="jobs-table" class="desktop-only">
<thead> <thead>
<tr> <tr>
<th class="col-id">#</th> <th class="col-id">#</th>
@ -124,11 +134,16 @@
<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr> <tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>
</tbody> </tbody>
</table> </table>
<!-- Mobile: Card-Liste -->
<div id="jobs-cards" class="jobs-cards mobile-only">
<div class="muted">Noch keine Jobs.</div>
</div>
</section> </section>
<!-- Nur sichtbar für Admins --> <!-- Nur sichtbar für Admins -->
<section id="admin-card" class="card hidden"> <section id="admin-card" class="card hidden admin-collapsible">
<h2>Benutzer verwalten</h2> <h2 class="admin-toggle" id="admin-toggle">Benutzer verwalten <span class="admin-toggle-icon"></span></h2>
<div class="admin-body">
<form id="user-form" class="user-form"> <form id="user-form" class="user-form">
<label> <label>
Benutzername Benutzername
@ -157,6 +172,7 @@
</thead> </thead>
<tbody id="users-body"></tbody> <tbody id="users-body"></tbody>
</table> </table>
</div>
</section> </section>
</main> </main>

View File

@ -281,24 +281,6 @@ select {
} }
.header-profile:disabled { opacity: .5; } .header-profile:disabled { opacity: .5; }
/* ── Audio-Quelle: Datei-Upload vs. Aufnahme ───────────────────── */
.audio-source {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
}
.audio-file-label {
display: grid;
gap: 6px;
}
.audio-or {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .5px;
text-align: center;
}
/* Aufnahme-Button (groß, rot, touch-tauglich) */ /* Aufnahme-Button (groß, rot, touch-tauglich) */
.btn-record { .btn-record {
display: inline-flex; display: inline-flex;
@ -378,36 +360,174 @@ select {
font-size: 13px; font-size: 13px;
} }
/* ── Mobile-Optimierung ────────────────────────────────────────── */ /* ── Sichtbarkeit-Helfer ───────────────────────────────────────── */
.mobile-only { display: none; }
/* Upload-Form Layout (Desktop) */
.upload-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.audio-file-details summary {
cursor: pointer;
padding: 8px 4px;
color: var(--muted);
font-size: 13px;
user-select: none;
}
.audio-file-details summary:hover { color: var(--text); }
.audio-file-details[open] summary { color: var(--text); margin-bottom: 8px; }
.audio-file-label {
display: grid;
gap: 6px;
}
/* Admin-Toggle (auf Desktop deaktiviert) */
.admin-toggle { cursor: default; }
.admin-toggle-icon { display: none; }
/* ── Mobile-Optimierung (echte Mobile-First-UI) ────────────────── */
@media (max-width: 700px) { @media (max-width: 700px) {
header { padding: 16px 12px 12px; } .desktop-only { display: none !important; }
header h1 { font-size: 22px; } .mobile-only { display: block; }
.sub { font-size: 13px; }
main { padding: 0 10px; gap: 14px; }
.card { padding: 14px 16px; border-radius: 10px; }
.card h2 { font-size: 16px; }
.header-row { flex-direction: column; align-items: stretch; gap: 10px; } body { font-size: 15px; }
.header-right { align-items: stretch; }
.user-bar { flex-wrap: wrap; justify-content: flex-start; gap: 6px; font-size: 12px; }
.header-profile { max-width: 140px; }
.audio-source { grid-template-columns: 1fr; gap: 8px; } /* HEADER: nur Logo + User */
.audio-or { padding: 4px 0; } header {
.btn-record { padding: 18px; font-size: 17px; } padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 18px; margin: 0; }
.header-row {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.header-left, .header-right { align-items: center; }
.header-right { flex-direction: row; gap: 8px; flex: 1; justify-content: flex-end; }
.user-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
font-size: 13px;
flex-wrap: nowrap;
}
.user-bar strong { font-size: 14px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; }
.header-profile { max-width: 130px; padding: 4px 6px; font-size: 12px; }
.btn-link { font-size: 12px; padding: 4px 6px; }
/* Tabelle horizontal scrollbar statt zerquetscht */ main { padding: 14px 12px 90px; gap: 14px; }
.card section, .card { overflow-x: auto; } .card { padding: 16px; border-radius: 12px; }
table { min-width: 720px; font-size: 13px; } .card h2 { font-size: 16px; margin-bottom: 10px; }
button[type="submit"] { padding: 14px 20px; font-size: 15px; min-height: 48px; }
.record-timer { font-size: 56px; } /* UPLOAD-CARD: Recording-Hero-Style */
.btn-record-ctrl { padding: 16px 24px; font-size: 17px; } .upload-card form { display: flex; flex-direction: column; gap: 14px; }
.btn-record {
/* HERO: groß und auffällig */
padding: 26px;
font-size: 19px;
min-height: 90px;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(231,76,60,.15);
}
.btn-record .rec-dot { width: 16px; height: 16px; animation: pulse 1.5s ease-in-out infinite; }
.upload-meta { grid-template-columns: 1fr; gap: 10px; }
.upload-meta select,
.upload-meta input[type=text] {
padding: 14px 12px;
font-size: 16px; /* iOS: ≥16px verhindert Zoom-on-Focus */
min-height: 48px;
}
.audio-file-details summary { padding: 12px 4px; font-size: 14px; }
button[type="submit"] {
padding: 16px;
font-size: 16px;
min-height: 52px;
border-radius: 10px;
width: 100%;
}
/* Aufnahme-Bühne: noch größer auf Mobile */
.record-pane { padding: 24px 16px; }
.record-timer { font-size: 64px; line-height: 1; }
.record-controls { width: 100%; gap: 12px; }
.btn-record-ctrl {
flex: 1;
padding: 18px;
font-size: 17px;
min-height: 56px;
min-width: 0;
}
/* JOBS-CARDS statt Tabelle */
.jobs-cards { display: flex; flex-direction: column; gap: 10px; }
.jc {
background: #0f1219;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.jc-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 6px;
}
.jc-id { color: var(--muted); font-size: 12px; }
.jc-title { font-weight: 600; font-size: 15px; line-height: 1.3; word-break: break-word; }
.jc-sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
.jc-status-row {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0 6px;
}
.jc-progress { flex: 1; }
.jc-error {
color: var(--err);
font-size: 12px;
margin: 4px 0;
word-break: break-word;
}
.jc-dl {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 8px;
}
.jc-dl .btn-dl { padding: 8px; font-size: 12px; min-height: 36px; }
/* Admin-Bereich: collapsible */
.admin-collapsible .admin-toggle {
cursor: pointer;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
margin: 0;
}
.admin-toggle-icon { display: inline; transition: transform .2s; }
.admin-collapsible:not(.open) .admin-body { display: none; }
.admin-collapsible.open .admin-toggle-icon { transform: rotate(180deg); }
.user-form { grid-template-columns: 1fr; }
.user-form input { font-size: 16px; }
} }
/* iOS PWA: oberer Sicherheitsbereich (Notch) respektieren */ /* iOS PWA: oberer Sicherheitsbereich (Notch) respektieren */
@supports (padding: max(0px)) { @supports (padding: max(0px)) {
header { padding-top: max(16px, env(safe-area-inset-top)); } header { padding-top: max(10px, env(safe-area-inset-top)); }
main { padding-bottom: max(20px, env(safe-area-inset-bottom)); }
} }
/* Profile-Tag in der Job-Zeile */ /* Profile-Tag in der Job-Zeile */