feat: PWA + Mikrofon-Aufnahme + HTTPS via selbst-signiertem Cert
Frontend-Aufnahme (MediaRecorder): - Aufnahme-Button im Upload-Form, große Touch-Targets - Start / Pause / Fortsetzen / Stop mit Live-Timer - Format-Auto-Detect (Opus/WebM, Safari nimmt M4A) - Wake Lock damit iOS nicht ins Sleep geht - Aufnahme-Datei landet als File-Objekt im Upload-Flow - Beim Submit: aufgenommene Datei bevorzugt vor File-Input PWA: - manifest.webmanifest, icon.svg + PNG-Icons (192/512) - apple-mobile-web-app-capable + theme-color - "Zum Home-Bildschirm" hinzufügen verwandelt Seite in App Mobile-Layout: - Single-Column ab 700px, größere Buttons - Header stapelt sich vertikal - Tabelle scrollt horizontal statt zu zerquetschen - Safe-Area-Insets für iPhone-Notch HTTPS (Pflicht für Mikrofon-Zugriff): - install.sh erzeugt selbst-signiertes Cert (4096 Bit, 825 Tage, mit SAN aus Hostname + LAN-IP) - Nginx: HTTP → HTTPS Redirect, Cert unter /ssl/voice-agent.crt ungesichert abrufbar (für bequemen iOS-Profil-Import) Backend: - ALLOWED_EXTS um 'webm' erweitert
This commit is contained in:
parent
05cf2ac349
commit
11ab8b57bf
@ -6,7 +6,8 @@
|
||||
"Write(*)",
|
||||
"Edit(*)",
|
||||
"Glob(*)",
|
||||
"Grep(*)"
|
||||
"Grep(*)",
|
||||
"WebFetch(domain:192.168.85.11)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,7 +61,32 @@ systemctl daemon-reload
|
||||
systemctl enable voice-agent
|
||||
systemctl restart voice-agent
|
||||
|
||||
echo "[7/7] Nginx-Reverse-Proxy"
|
||||
echo "[7/8] Selbst-signiertes TLS-Zertifikat (für Mikrofon-Zugriff via HTTPS)"
|
||||
SSL_DIR="/etc/ssl/voice-agent"
|
||||
SSL_CRT="$SSL_DIR/voice-agent.crt"
|
||||
SSL_KEY="$SSL_DIR/voice-agent.key"
|
||||
mkdir -p "$SSL_DIR"
|
||||
|
||||
if [ ! -f "$SSL_CRT" ] || [ ! -f "$SSL_KEY" ]; then
|
||||
apt-get install -y --no-install-recommends openssl >/dev/null
|
||||
HOST_NAME="$(hostname --fqdn 2>/dev/null || hostname)"
|
||||
HOST_SHORT="$(hostname)"
|
||||
HOST_IP="$(hostname -I | awk '{print $1}')"
|
||||
SAN="DNS:${HOST_NAME},DNS:${HOST_SHORT},DNS:voice-agent.local,IP:${HOST_IP}"
|
||||
echo " Erzeuge Cert für: $SAN"
|
||||
openssl req -x509 -nodes -newkey rsa:4096 -days 825 \
|
||||
-keyout "$SSL_KEY" -out "$SSL_CRT" \
|
||||
-subj "/CN=${HOST_NAME}" -addext "subjectAltName=${SAN}" \
|
||||
-addext "extendedKeyUsage=serverAuth" \
|
||||
-addext "basicConstraints=CA:FALSE" 2>/dev/null
|
||||
chmod 0644 "$SSL_CRT"
|
||||
chmod 0600 "$SSL_KEY"
|
||||
echo " Cert erzeugt: $SSL_CRT"
|
||||
else
|
||||
echo " Cert bereits vorhanden, behalte: $SSL_CRT"
|
||||
fi
|
||||
|
||||
echo "[8/8] Nginx-Reverse-Proxy (HTTPS + HTTP-Redirect)"
|
||||
cp "$APP_DIR/deploy/nginx/voice-agent.conf" /etc/nginx/sites-available/voice-agent
|
||||
ln -sf /etc/nginx/sites-available/voice-agent /etc/nginx/sites-enabled/voice-agent
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
@ -78,10 +103,21 @@ chmod 0755 /usr/local/bin/voice-agent-admin
|
||||
|
||||
echo
|
||||
echo "Fertig. Health-Check:"
|
||||
echo " curl -s http://localhost/health"
|
||||
echo " curl -sk https://localhost/health"
|
||||
echo
|
||||
echo "Beim ersten Aufruf im Browser: Initialer Admin wird angelegt — bestehende Jobs"
|
||||
echo "werden diesem Account automatisch zugeordnet."
|
||||
echo "Aufruf im Browser: https://<LXC-IP>/ (HTTP wird auf HTTPS umgeleitet)"
|
||||
echo "Beim ersten Aufruf wird der initiale Admin angelegt."
|
||||
echo
|
||||
echo "── Mikrofon-Aufnahme von Handys ──────────────────────────────────────"
|
||||
echo "Damit iOS/Android das Mikrofon freigibt, MUSS die Seite über HTTPS laufen."
|
||||
echo "Das selbst-signierte Zertifikat einmalig auf jedem Gerät installieren:"
|
||||
echo
|
||||
echo " iPhone: Safari öffnen, http://<LXC-IP>/ssl/voice-agent.crt aufrufen"
|
||||
echo " → Profil herunterladen → Einstellungen → Profil installieren"
|
||||
echo " → Allgemein → Info → Zertifikatsvertrauen → 'voice-agent' aktivieren"
|
||||
echo
|
||||
echo " Android: Browser öffnen, http://<LXC-IP>/ssl/voice-agent.crt aufrufen"
|
||||
echo " → Cert installieren als 'VPN/Apps' (Variiert je nach Hersteller)"
|
||||
echo
|
||||
echo "Falls noch nicht geschehen: MAC_API_URL in $APP_DIR/lxc-frontend/.env setzen"
|
||||
echo "und Service neu starten: sudo systemctl restart voice-agent"
|
||||
|
||||
@ -1,11 +1,39 @@
|
||||
# HTTP — leitet auf HTTPS um, außer für /ssl/voice-agent.crt (Cert-Download)
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
# Upload-Limit muss zu MAX_UPLOAD_MB in .env passen
|
||||
client_max_body_size 600M;
|
||||
proxy_read_timeout 1800;
|
||||
proxy_send_timeout 1800;
|
||||
|
||||
# Lange Verarbeitung — Timeouts hoch
|
||||
# Cert für Handy-Import bequem ohne HTTPS abholbar
|
||||
location /ssl/voice-agent.crt {
|
||||
alias /etc/ssl/voice-agent/voice-agent.crt;
|
||||
default_type application/x-x509-ca-cert;
|
||||
add_header Content-Disposition 'attachment; filename="voice-agent.crt"';
|
||||
}
|
||||
|
||||
# Alles andere → HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS mit selbst-signiertem Cert
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/ssl/voice-agent/voice-agent.crt;
|
||||
ssl_certificate_key /etc/ssl/voice-agent/voice-agent.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
client_max_body_size 600M;
|
||||
proxy_read_timeout 1800;
|
||||
proxy_send_timeout 1800;
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ MAC_API_TIMEOUT=1800
|
||||
|
||||
# Limits
|
||||
MAX_UPLOAD_MB=500
|
||||
ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac
|
||||
ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac,webm
|
||||
|
||||
# Session-Cookie-Signierung — wird vom install.sh automatisch generiert.
|
||||
# NIE committen. Bei Wechsel werden alle Sessions invalidiert.
|
||||
|
||||
@ -17,7 +17,7 @@ class Settings(BaseSettings):
|
||||
mac_api_timeout: int = 1800
|
||||
|
||||
max_upload_mb: int = 500
|
||||
allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac"
|
||||
allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac,webm"
|
||||
|
||||
# Wird beim Deploy automatisch generiert (siehe install.sh).
|
||||
session_secret: str = "CHANGE-ME-SET-IN-ENV"
|
||||
|
||||
@ -37,6 +37,17 @@ const defaultProfileName = document.getElementById("default-profile-name");
|
||||
const headerProfileWrap = document.getElementById("header-profile-wrap");
|
||||
const headerProfile = document.getElementById("header-profile");
|
||||
|
||||
const recordToggle = document.getElementById("record-toggle");
|
||||
const recordToggleLabel = document.getElementById("record-toggle-label");
|
||||
const recordPane = document.getElementById("record-pane");
|
||||
const recordTimer = document.getElementById("record-timer");
|
||||
const recordStatus = document.getElementById("record-status");
|
||||
const recPauseBtn = document.getElementById("rec-pause");
|
||||
const recStopBtn = document.getElementById("rec-stop");
|
||||
const recordResult = document.getElementById("record-result");
|
||||
const recordFilename = document.getElementById("record-filename");
|
||||
const recordDiscardBtn = document.getElementById("record-discard");
|
||||
|
||||
let currentUser = null; // { id, username, is_admin, default_profile }
|
||||
let mode = "login"; // "login" | "setup"
|
||||
let availableProfiles = []; // [{ name, display_name, language }, ...]
|
||||
@ -310,9 +321,15 @@ async function loadJobs() {
|
||||
|
||||
form.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
if (!audioInput.files.length) return;
|
||||
// Quelle: Aufnahme bevorzugt, sonst File-Input
|
||||
const file = recordedFile || (audioInput.files.length ? audioInput.files[0] : null);
|
||||
if (!file) {
|
||||
uploadMsg.textContent = "Bitte Datei wählen oder Aufnahme erstellen.";
|
||||
uploadMsg.className = "msg err";
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append("audio", audioInput.files[0]);
|
||||
fd.append("audio", file, file.name);
|
||||
fd.append("title", titleInput.value);
|
||||
// Profile mitgeben — leer = Server nimmt user.default_profile
|
||||
fd.append("profile", profileSelect.value || "");
|
||||
@ -341,6 +358,7 @@ form.addEventListener("submit", (ev) => {
|
||||
uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`;
|
||||
uploadMsg.className = "msg ok";
|
||||
form.reset();
|
||||
discardRecording();
|
||||
loadJobs();
|
||||
} else {
|
||||
let err = "Upload fehlgeschlagen";
|
||||
@ -434,6 +452,167 @@ usersBody.addEventListener("click", async (ev) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Audio-Aufnahme (MediaRecorder) ────────────────────────────────────────
|
||||
let mediaRecorder = null;
|
||||
let recordedChunks = [];
|
||||
let recordedFile = null; // File-Objekt nach Stop
|
||||
let recStream = null; // MediaStream zum sauberen Schließen
|
||||
let recStartedAt = 0;
|
||||
let recPausedAt = 0; // wenn paused: Zeitpunkt des Pausierens
|
||||
let recElapsedBeforePause = 0; // aufaddierte Aufnahme-Sekunden vor letztem Resume
|
||||
let timerInterval = null;
|
||||
let wakeLock = null;
|
||||
|
||||
function pickMimeType() {
|
||||
const candidates = [
|
||||
"audio/webm;codecs=opus",
|
||||
"audio/webm",
|
||||
"audio/mp4", // Safari iOS
|
||||
"audio/mpeg",
|
||||
"", // Browser-Default
|
||||
];
|
||||
for (const m of candidates) {
|
||||
if (m === "" || (window.MediaRecorder && MediaRecorder.isTypeSupported(m))) return m;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extFromMime(mime) {
|
||||
if (!mime) return "webm";
|
||||
if (mime.includes("webm")) return "webm";
|
||||
if (mime.includes("mp4")) return "m4a";
|
||||
if (mime.includes("mpeg")) return "mp3";
|
||||
if (mime.includes("ogg")) return "ogg";
|
||||
return "webm";
|
||||
}
|
||||
|
||||
function fmtTimer(s) {
|
||||
const mm = String(Math.floor(s / 60)).padStart(2, "0");
|
||||
const ss = String(Math.floor(s % 60)).padStart(2, "0");
|
||||
return `${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function tickTimer() {
|
||||
if (!mediaRecorder) return;
|
||||
let elapsed = recElapsedBeforePause;
|
||||
if (mediaRecorder.state === "recording") elapsed += (Date.now() - recStartedAt) / 1000;
|
||||
recordTimer.textContent = fmtTimer(elapsed);
|
||||
}
|
||||
|
||||
async function acquireWakeLock() {
|
||||
try {
|
||||
if ("wakeLock" in navigator) {
|
||||
wakeLock = await navigator.wakeLock.request("screen");
|
||||
}
|
||||
} catch { /* nicht kritisch */ }
|
||||
}
|
||||
async function releaseWakeLock() {
|
||||
try { if (wakeLock) await wakeLock.release(); } catch {}
|
||||
wakeLock = null;
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
alert("Dein Browser unterstützt kein Mikrofon — oder die Seite läuft nicht über HTTPS.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
recStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
});
|
||||
} catch (e) {
|
||||
alert("Mikrofon-Zugriff verweigert oder fehlgeschlagen: " + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const mime = pickMimeType();
|
||||
recordedChunks = [];
|
||||
try {
|
||||
mediaRecorder = mime ? new MediaRecorder(recStream, { mimeType: mime }) : new MediaRecorder(recStream);
|
||||
} catch (e) {
|
||||
alert("MediaRecorder konnte nicht starten: " + e.message);
|
||||
cleanupStream();
|
||||
return;
|
||||
}
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); };
|
||||
mediaRecorder.onstop = () => {
|
||||
const type = mediaRecorder.mimeType || mime || "audio/webm";
|
||||
const ext = extFromMime(type);
|
||||
const blob = new Blob(recordedChunks, { type });
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
recordedFile = new File([blob], `aufnahme-${ts}.${ext}`, { type });
|
||||
onRecordingFinalized();
|
||||
};
|
||||
|
||||
mediaRecorder.start(1000); // alle 1s ein dataavailable-Event
|
||||
recStartedAt = Date.now();
|
||||
recPausedAt = 0;
|
||||
recElapsedBeforePause = 0;
|
||||
recordTimer.textContent = "00:00";
|
||||
recordStatus.textContent = "Aufnahme läuft";
|
||||
recPauseBtn.textContent = "Pause";
|
||||
recordPane.classList.remove("hidden");
|
||||
recordResult.classList.add("hidden");
|
||||
recordToggle.classList.add("hidden");
|
||||
audioInput.value = ""; // konkurrierende Datei-Auswahl löschen
|
||||
timerInterval = setInterval(tickTimer, 250);
|
||||
acquireWakeLock();
|
||||
}
|
||||
|
||||
function pauseRecording() {
|
||||
if (!mediaRecorder) return;
|
||||
if (mediaRecorder.state === "recording") {
|
||||
mediaRecorder.pause();
|
||||
recElapsedBeforePause += (Date.now() - recStartedAt) / 1000;
|
||||
recPausedAt = Date.now();
|
||||
recordStatus.textContent = "Pausiert";
|
||||
recPauseBtn.textContent = "Fortsetzen";
|
||||
} else if (mediaRecorder.state === "paused") {
|
||||
mediaRecorder.resume();
|
||||
recStartedAt = Date.now();
|
||||
recordStatus.textContent = "Aufnahme läuft";
|
||||
recPauseBtn.textContent = "Pause";
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (!mediaRecorder) return;
|
||||
if (mediaRecorder.state !== "inactive") mediaRecorder.stop();
|
||||
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
||||
cleanupStream();
|
||||
releaseWakeLock();
|
||||
}
|
||||
|
||||
function cleanupStream() {
|
||||
if (recStream) {
|
||||
recStream.getTracks().forEach((t) => t.stop());
|
||||
recStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onRecordingFinalized() {
|
||||
recordPane.classList.add("hidden");
|
||||
recordResult.classList.remove("hidden");
|
||||
recordToggle.classList.remove("hidden");
|
||||
recordToggleLabel.textContent = "Neue Aufnahme starten";
|
||||
const sizeKB = Math.round(recordedFile.size / 1024);
|
||||
recordFilename.textContent = `${recordedFile.name} (${sizeKB} KB)`;
|
||||
}
|
||||
|
||||
function discardRecording() {
|
||||
recordedFile = null;
|
||||
recordedChunks = [];
|
||||
recordResult.classList.add("hidden");
|
||||
recordToggleLabel.textContent = "Aufnahme starten";
|
||||
recordFilename.textContent = "";
|
||||
}
|
||||
|
||||
recordToggle.addEventListener("click", startRecording);
|
||||
recPauseBtn.addEventListener("click", pauseRecording);
|
||||
recStopBtn.addEventListener("click", stopRecording);
|
||||
recordDiscardBtn.addEventListener("click", discardRecording);
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||
bootstrap();
|
||||
checkMac();
|
||||
|
||||
BIN
lxc-frontend/app/static/icon-192.png
Normal file
BIN
lxc-frontend/app/static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
lxc-frontend/app/static/icon-512.png
Normal file
BIN
lxc-frontend/app/static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
14
lxc-frontend/app/static/icon.svg
Normal file
14
lxc-frontend/app/static/icon.svg
Normal file
@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#0f1115"/>
|
||||
<g transform="translate(256 256)">
|
||||
<!-- Mikrofon -->
|
||||
<rect x="-56" y="-160" width="112" height="200" rx="56" fill="#4f8cff"/>
|
||||
<!-- Bügel -->
|
||||
<path d="M -120 0 a 120 120 0 0 0 240 0" stroke="#4f8cff" stroke-width="20" fill="none" stroke-linecap="round"/>
|
||||
<!-- Standfuß -->
|
||||
<line x1="0" y1="120" x2="0" y2="170" stroke="#4f8cff" stroke-width="20" stroke-linecap="round"/>
|
||||
<line x1="-50" y1="170" x2="50" y2="170" stroke="#4f8cff" stroke-width="20" stroke-linecap="round"/>
|
||||
<!-- Aufnahme-Punkt -->
|
||||
<circle cx="100" cy="-100" r="30" fill="#e74c3c"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 721 B |
@ -2,9 +2,17 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f1115" />
|
||||
<title>Voice-Agent — Transkription</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="manifest" href="/static/manifest.webmanifest" />
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/static/icon-192.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Voice-Agent" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@ -63,10 +71,33 @@
|
||||
<select id="profile-select"></select>
|
||||
<small class="muted">Standard: <span id="default-profile-name">—</span> (in Einstellungen änderbar)</small>
|
||||
</label>
|
||||
<label>
|
||||
Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC)
|
||||
<input type="file" name="audio" id="audio" accept=".mp3,.wav,.m4a,.mp4,.ogg,.flac" required />
|
||||
<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">
|
||||
<span class="rec-dot"></span>
|
||||
<span id="record-toggle-label">Aufnahme starten</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="record-pane" class="record-pane hidden">
|
||||
<div class="record-timer" id="record-timer">00:00</div>
|
||||
<div class="record-status" id="record-status">Aufnahme läuft</div>
|
||||
<div class="record-controls">
|
||||
<button type="button" id="rec-pause" class="btn-record-ctrl">Pause</button>
|
||||
<button type="button" id="rec-stop" class="btn-record-ctrl danger">Stoppen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="record-result" class="record-result hidden">
|
||||
<span class="muted">Aufnahme bereit:</span>
|
||||
<span id="record-filename"></span>
|
||||
<button type="button" id="record-discard" class="btn-link btn-danger">Verwerfen</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submit-btn">Hochladen & verarbeiten</button>
|
||||
</form>
|
||||
<div id="upload-progress" class="hidden">
|
||||
|
||||
17
lxc-frontend/app/static/manifest.webmanifest
Normal file
17
lxc-frontend/app/static/manifest.webmanifest
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Voice-Agent",
|
||||
"short_name": "Voice-Agent",
|
||||
"description": "Sitzungsaufnahmen lokal transkribieren und protokollieren",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0f1115",
|
||||
"theme_color": "#0f1115",
|
||||
"lang": "de",
|
||||
"icons": [
|
||||
{ "src": "/static/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" },
|
||||
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
@ -281,6 +281,135 @@ select {
|
||||
}
|
||||
.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) */
|
||||
.btn-record {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
background: rgba(231,76,60,.12);
|
||||
color: var(--err);
|
||||
border: 1px solid rgba(231,76,60,.5);
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
min-height: 48px;
|
||||
}
|
||||
.btn-record:hover { background: rgba(231,76,60,.22); }
|
||||
.rec-dot {
|
||||
width: 12px; height: 12px;
|
||||
background: var(--err);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Aufnahme-Bühne */
|
||||
.record-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(231,76,60,.4);
|
||||
background: rgba(231,76,60,.06);
|
||||
border-radius: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.record-timer {
|
||||
font-size: 44px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--err);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.record-status { color: var(--muted); font-size: 13px; }
|
||||
.record-status::before {
|
||||
content: "● ";
|
||||
color: var(--err);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 50% { opacity: .25; } }
|
||||
.record-controls { display: flex; gap: 10px; margin-top: 6px; }
|
||||
.btn-record-ctrl {
|
||||
padding: 12px 22px;
|
||||
border-radius: 8px;
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
min-width: 100px;
|
||||
}
|
||||
.btn-record-ctrl.danger {
|
||||
background: var(--err);
|
||||
color: white;
|
||||
border-color: var(--err);
|
||||
}
|
||||
|
||||
/* Aufnahme-Ergebnis */
|
||||
.record-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(46,204,113,.08);
|
||||
border: 1px solid rgba(46,204,113,.4);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Mobile-Optimierung ────────────────────────────────────────── */
|
||||
@media (max-width: 700px) {
|
||||
header { padding: 16px 12px 12px; }
|
||||
header h1 { font-size: 22px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.audio-or { padding: 4px 0; }
|
||||
.btn-record { padding: 18px; font-size: 17px; }
|
||||
|
||||
/* Tabelle horizontal scrollbar statt zerquetscht */
|
||||
.card section, .card { overflow-x: auto; }
|
||||
table { min-width: 720px; font-size: 13px; }
|
||||
button[type="submit"] { padding: 14px 20px; font-size: 15px; min-height: 48px; }
|
||||
|
||||
.record-timer { font-size: 56px; }
|
||||
.btn-record-ctrl { padding: 16px 24px; font-size: 17px; }
|
||||
}
|
||||
|
||||
/* iOS PWA: oberer Sicherheitsbereich (Notch) respektieren */
|
||||
@supports (padding: max(0px)) {
|
||||
header { padding-top: max(16px, env(safe-area-inset-top)); }
|
||||
}
|
||||
|
||||
/* Profile-Tag in der Job-Zeile */
|
||||
.tag-profile {
|
||||
display: inline-block;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user