feat(settings): eigene Unterseite für User-Einstellungen + Passwort-Wechsel
Header: Profil- und Modell-Dropdowns rausgenommen, dafür ein
"⚙ Einstellungen"-Link. Klick öffnet eine eigene Settings-View
(toggled die main-View aus) mit drei Sektionen:
- Standard-Profil (sofort speichernd)
- Standard-LLM-Modell (sofort speichernd, leer = Worker-Default)
- Passwort ändern (aktuelles + neues + Bestätigung)
Neuer Endpoint POST /api/auth/me/password — verlangt zwingend das
aktuelle Passwort (verhindert Übernahme via offene Browser-Session).
Admin-Reset über /api/admin/users/<id>/password bleibt unberührt.
Layout: Settings-View ist eine Card mit max-width 640px, jede
Sektion durch Trennlinie abgegrenzt. Inline-Status-Meldungen
("Standard-Profil ist jetzt: …") direkt unter den Controls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f03d46afb5
commit
7679efa7e4
@ -71,6 +71,33 @@ def update_me(payload: UpdateMeIn, user: User = Depends(current_user), session:
|
|||||||
return _user_dict(user)
|
return _user_dict(user)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordIn(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1, max_length=200)
|
||||||
|
new_password: str = Field(min_length=8, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/password")
|
||||||
|
def change_password(
|
||||||
|
payload: ChangePasswordIn,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""User ändert eigenes Passwort.
|
||||||
|
|
||||||
|
Aktuelles Passwort muss korrekt sein — verhindert Übernahme, wenn jemand
|
||||||
|
eine offene Browser-Session findet. Admin-Reset läuft weiter über
|
||||||
|
/api/admin/users/<id>/password (ohne current_password).
|
||||||
|
"""
|
||||||
|
if not verify_password(payload.current_password, user.password_hash):
|
||||||
|
log.warning("Password-Change abgelehnt für user=%s (aktuelles Passwort falsch)", user.username)
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Aktuelles Passwort falsch")
|
||||||
|
user.password_hash = hash_password(payload.new_password)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
log.info("Password geändert für user=%s (id=%d)", user.username, user.id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/needs-setup")
|
@router.get("/needs-setup")
|
||||||
def needs_setup(session: Session = Depends(get_session)):
|
def needs_setup(session: Session = Depends(get_session)):
|
||||||
"""Vor Login prüfen ob noch kein User existiert (erstes Deployment)."""
|
"""Vor Login prüfen ob noch kein User existiert (erstes Deployment)."""
|
||||||
|
|||||||
@ -33,10 +33,24 @@ const usersBody = document.getElementById("users-body");
|
|||||||
|
|
||||||
const profileLabel = document.getElementById("profile-label");
|
const profileLabel = document.getElementById("profile-label");
|
||||||
const profileSelect = document.getElementById("profile-select");
|
const profileSelect = document.getElementById("profile-select");
|
||||||
const headerProfileWrap = document.getElementById("header-profile-wrap");
|
|
||||||
const headerProfile = document.getElementById("header-profile");
|
// Settings-Subpage
|
||||||
const headerModelWrap = document.getElementById("header-model-wrap");
|
const settingsBtn = document.getElementById("settings-btn");
|
||||||
const headerModel = document.getElementById("header-model");
|
const settingsView = document.getElementById("settings-view");
|
||||||
|
const settingsBackBtn = document.getElementById("settings-back");
|
||||||
|
const settingsUsername = document.getElementById("settings-username");
|
||||||
|
const settingsProfile = document.getElementById("settings-profile");
|
||||||
|
const settingsProfileMsg = document.getElementById("settings-profile-msg");
|
||||||
|
const settingsModelSection = document.getElementById("settings-model-section");
|
||||||
|
const settingsModel = document.getElementById("settings-model");
|
||||||
|
const settingsModelMsg = document.getElementById("settings-model-msg");
|
||||||
|
const settingsPwForm = document.getElementById("settings-pw-form");
|
||||||
|
const pwCurrent = document.getElementById("pw-current");
|
||||||
|
const pwNew = document.getElementById("pw-new");
|
||||||
|
const pwConfirm = document.getElementById("pw-confirm");
|
||||||
|
const pwSaveBtn = document.getElementById("pw-save");
|
||||||
|
const settingsPwMsg = document.getElementById("settings-pw-msg");
|
||||||
|
const mainView = document.getElementById("main-view");
|
||||||
|
|
||||||
const jobsCards = document.getElementById("jobs-cards");
|
const jobsCards = document.getElementById("jobs-cards");
|
||||||
const adminToggle = document.getElementById("admin-toggle");
|
const adminToggle = document.getElementById("admin-toggle");
|
||||||
@ -188,6 +202,9 @@ function enterApp() {
|
|||||||
authOverlay.classList.add("hidden");
|
authOverlay.classList.add("hidden");
|
||||||
appMain.classList.remove("hidden");
|
appMain.classList.remove("hidden");
|
||||||
userBar.classList.remove("hidden");
|
userBar.classList.remove("hidden");
|
||||||
|
// Falls vorher in Settings → wieder Main zeigen
|
||||||
|
settingsView.classList.add("hidden");
|
||||||
|
mainView.classList.remove("hidden");
|
||||||
userName.textContent = currentUser.username;
|
userName.textContent = currentUser.username;
|
||||||
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
|
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
|
||||||
adminCard.classList.toggle("hidden", !currentUser.is_admin);
|
adminCard.classList.toggle("hidden", !currentUser.is_admin);
|
||||||
@ -215,12 +232,12 @@ function profileLabelOf(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyProfileUI() {
|
function applyProfileUI() {
|
||||||
// Upload-Dropdown und Header-Selector nur sichtbar wenn ≥2 Profile.
|
// Upload-Dropdown nur sichtbar wenn ≥2 Profile (sonst sinnlos).
|
||||||
const showDropdown = availableProfiles.length > 1;
|
const showDropdown = availableProfiles.length > 1;
|
||||||
profileLabel.classList.toggle("hidden", !showDropdown);
|
profileLabel.classList.toggle("hidden", !showDropdown);
|
||||||
headerProfileWrap.classList.toggle("hidden", !showDropdown);
|
|
||||||
|
|
||||||
for (const sel of [profileSelect, headerProfile]) {
|
for (const sel of [profileSelect, settingsProfile]) {
|
||||||
|
if (!sel) continue;
|
||||||
sel.innerHTML = "";
|
sel.innerHTML = "";
|
||||||
availableProfiles.forEach((p) => {
|
availableProfiles.forEach((p) => {
|
||||||
const o = document.createElement("option");
|
const o = document.createElement("option");
|
||||||
@ -231,7 +248,7 @@ function applyProfileUI() {
|
|||||||
}
|
}
|
||||||
const def = currentUser.default_profile || "meeting";
|
const def = currentUser.default_profile || "meeting";
|
||||||
profileSelect.value = def;
|
profileSelect.value = def;
|
||||||
headerProfile.value = def;
|
settingsProfile.value = def;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModels() {
|
async function loadModels() {
|
||||||
@ -253,67 +270,126 @@ function modelLabelOf(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyModelUI() {
|
function applyModelUI() {
|
||||||
// Wenn der Mac keine Modelle meldet, Dropdown verstecken — wir wollen
|
// Wenn der Mac keine Modelle meldet, Section verstecken — sonst leerer Selector.
|
||||||
// keinen leeren Selector zeigen, der dem User vortäuscht, er hätte Wahl.
|
|
||||||
const showDropdown = availableModels.length > 0;
|
const showDropdown = availableModels.length > 0;
|
||||||
headerModelWrap.classList.toggle("hidden", !showDropdown);
|
settingsModelSection.classList.toggle("hidden", !showDropdown);
|
||||||
if (!showDropdown) return;
|
if (!showDropdown) return;
|
||||||
|
|
||||||
headerModel.innerHTML = "";
|
settingsModel.innerHTML = "";
|
||||||
// Erste Option: kein Override → Worker-Default
|
|
||||||
const opt0 = document.createElement("option");
|
const opt0 = document.createElement("option");
|
||||||
opt0.value = "";
|
opt0.value = "";
|
||||||
opt0.textContent = workerDefaultModel
|
opt0.textContent = workerDefaultModel
|
||||||
? `Worker-Default (${workerDefaultModel})`
|
? `Worker-Default (${workerDefaultModel})`
|
||||||
: "Worker-Default";
|
: "Worker-Default";
|
||||||
headerModel.appendChild(opt0);
|
settingsModel.appendChild(opt0);
|
||||||
|
|
||||||
availableModels.forEach((m) => {
|
availableModels.forEach((m) => {
|
||||||
const o = document.createElement("option");
|
const o = document.createElement("option");
|
||||||
o.value = m.name;
|
o.value = m.name;
|
||||||
const sizeGb = m.size ? ` — ${(m.size / 1024 / 1024 / 1024).toFixed(1)} GB` : "";
|
const sizeGb = m.size ? ` — ${(m.size / 1024 / 1024 / 1024).toFixed(1)} GB` : "";
|
||||||
o.textContent = `${m.name}${sizeGb}`;
|
o.textContent = `${m.name}${sizeGb}`;
|
||||||
headerModel.appendChild(o);
|
settingsModel.appendChild(o);
|
||||||
});
|
});
|
||||||
headerModel.value = currentUser.default_model || "";
|
settingsModel.value = currentUser.default_model || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
headerModel.addEventListener("change", async () => {
|
// ─── Settings-Subpage: Toggle + Save-Handler ────────────────────────────────
|
||||||
const newDefault = headerModel.value;
|
function showSettings() {
|
||||||
headerModel.disabled = true;
|
settingsUsername.textContent = currentUser.username;
|
||||||
|
applyProfileUI();
|
||||||
|
applyModelUI();
|
||||||
|
settingsProfileMsg.textContent = "";
|
||||||
|
settingsModelMsg.textContent = "";
|
||||||
|
settingsPwMsg.textContent = "";
|
||||||
|
settingsPwForm.reset();
|
||||||
|
mainView.classList.add("hidden");
|
||||||
|
settingsView.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSettings() {
|
||||||
|
settingsView.classList.add("hidden");
|
||||||
|
mainView.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashMsg(el, text, kind) {
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = `msg ${kind}`;
|
||||||
|
if (kind === "ok") setTimeout(() => { if (el.textContent === text) el.textContent = ""; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsBtn.addEventListener("click", (ev) => { ev.preventDefault(); showSettings(); });
|
||||||
|
settingsBackBtn.addEventListener("click", (ev) => { ev.preventDefault(); hideSettings(); });
|
||||||
|
|
||||||
|
// Default-Profile: sofort speichern bei change.
|
||||||
|
settingsProfile.addEventListener("change", async () => {
|
||||||
|
const v = settingsProfile.value;
|
||||||
|
settingsProfile.disabled = true;
|
||||||
try {
|
try {
|
||||||
const r = await api("/api/auth/me", {
|
const r = await api("/api/auth/me", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ default_model: newDefault }),
|
body: JSON.stringify({ default_profile: v }),
|
||||||
});
|
});
|
||||||
if (!r.ok) throw new Error("save failed");
|
if (!r.ok) throw new Error("save failed");
|
||||||
currentUser = await r.json();
|
currentUser = await r.json();
|
||||||
} catch (e) {
|
|
||||||
alert("Konnte Standard-Modell nicht speichern.");
|
|
||||||
headerModel.value = currentUser.default_model || "";
|
|
||||||
} finally {
|
|
||||||
headerModel.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sofort-Speichern, sobald der User im Header das Standard-Profil ändert.
|
|
||||||
headerProfile.addEventListener("change", async () => {
|
|
||||||
const newDefault = headerProfile.value;
|
|
||||||
headerProfile.disabled = true;
|
|
||||||
try {
|
|
||||||
const r = await api("/api/auth/me", {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({ default_profile: newDefault }),
|
|
||||||
});
|
|
||||||
if (!r.ok) throw new Error("save failed");
|
|
||||||
currentUser = await r.json();
|
|
||||||
// Upload-Dropdown synchron mitziehen
|
|
||||||
profileSelect.value = currentUser.default_profile;
|
profileSelect.value = currentUser.default_profile;
|
||||||
|
flashMsg(settingsProfileMsg, `Standard-Profil ist jetzt: ${profileLabelOf(currentUser.default_profile)}`, "ok");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Konnte Standard-Profil nicht speichern.");
|
flashMsg(settingsProfileMsg, "Konnte Standard-Profil nicht speichern.", "err");
|
||||||
headerProfile.value = currentUser.default_profile || "meeting";
|
settingsProfile.value = currentUser.default_profile || "meeting";
|
||||||
} finally {
|
} finally {
|
||||||
headerProfile.disabled = false;
|
settingsProfile.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default-Modell: sofort speichern bei change.
|
||||||
|
settingsModel.addEventListener("change", async () => {
|
||||||
|
const v = settingsModel.value;
|
||||||
|
settingsModel.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api("/api/auth/me", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ default_model: v }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error("save failed");
|
||||||
|
currentUser = await r.json();
|
||||||
|
flashMsg(settingsModelMsg,
|
||||||
|
`Standard-Modell ist jetzt: ${currentUser.default_model || "(Worker-Default)"}`, "ok");
|
||||||
|
} catch (e) {
|
||||||
|
flashMsg(settingsModelMsg, "Konnte Standard-Modell nicht speichern.", "err");
|
||||||
|
settingsModel.value = currentUser.default_model || "";
|
||||||
|
} finally {
|
||||||
|
settingsModel.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Passwort-Form: Bestätigung clientseitig prüfen, dann an /me/password.
|
||||||
|
settingsPwForm.addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (pwNew.value !== pwConfirm.value) {
|
||||||
|
flashMsg(settingsPwMsg, "Neue Passwörter stimmen nicht überein.", "err");
|
||||||
|
pwConfirm.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pwSaveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api("/api/auth/me/password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
current_password: pwCurrent.value,
|
||||||
|
new_password: pwNew.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) {
|
||||||
|
flashMsg(settingsPwMsg, d.detail || "Konnte Passwort nicht ändern.", "err");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flashMsg(settingsPwMsg, "Passwort geändert.", "ok");
|
||||||
|
settingsPwForm.reset();
|
||||||
|
} catch (e) {
|
||||||
|
flashMsg(settingsPwMsg, "Netzwerkfehler beim Speichern.", "err");
|
||||||
|
} finally {
|
||||||
|
pwSaveBtn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -27,16 +27,7 @@
|
|||||||
<span class="muted desktop-only">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">
|
<button type="button" id="settings-btn" class="btn-link" title="Eigene Einstellungen — Profil, Modell, Passwort">⚙ Einstellungen</button>
|
||||||
<span class="muted desktop-only">·</span>
|
|
||||||
<span class="muted desktop-only">Profil:</span>
|
|
||||||
<select id="header-profile" class="header-profile" title="Standard-Profil — wird sofort gespeichert"></select>
|
|
||||||
</span>
|
|
||||||
<span id="header-model-wrap" class="header-profile-wrap hidden">
|
|
||||||
<span class="muted desktop-only">·</span>
|
|
||||||
<span class="muted desktop-only">Modell:</span>
|
|
||||||
<select id="header-model" class="header-profile" title="Standard-LLM — wird sofort gespeichert. Leer = Worker-Default."></select>
|
|
||||||
</span>
|
|
||||||
<button type="button" id="logout-btn" class="btn-link">Abmelden</button>
|
<button type="button" id="logout-btn" class="btn-link">Abmelden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -64,6 +55,50 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main id="app" class="hidden">
|
<main id="app" class="hidden">
|
||||||
|
<!-- Settings-View — eigene "Unterseite" für Profil/Modell/Passwort -->
|
||||||
|
<section id="settings-view" class="card hidden settings-view">
|
||||||
|
<div class="settings-head">
|
||||||
|
<h2>Einstellungen für <span id="settings-username">…</span></h2>
|
||||||
|
<button type="button" id="settings-back" class="btn-link">← zurück</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Standard-Profil</h3>
|
||||||
|
<p class="muted">Wird bei jedem neuen Upload vorausgewählt. Pro Upload überschreibbar.</p>
|
||||||
|
<select id="settings-profile" class="settings-input"></select>
|
||||||
|
<p id="settings-profile-msg" class="msg"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section" id="settings-model-section">
|
||||||
|
<h3>Standard-LLM-Modell</h3>
|
||||||
|
<p class="muted">Leer = der Mac-Worker entscheidet (sein .env-Default). Wechsel braucht keinen Worker-Restart — Ollama lädt das gewählte Modell beim ersten Job nach.</p>
|
||||||
|
<select id="settings-model" class="settings-input"></select>
|
||||||
|
<p id="settings-model-msg" class="msg"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Passwort ändern</h3>
|
||||||
|
<form id="settings-pw-form" autocomplete="off">
|
||||||
|
<label>
|
||||||
|
Aktuelles Passwort
|
||||||
|
<input type="password" id="pw-current" autocomplete="current-password" required minlength="1" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Neues Passwort <span class="muted">(min. 8 Zeichen)</span>
|
||||||
|
<input type="password" id="pw-new" autocomplete="new-password" required minlength="8" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Neues Passwort wiederholen
|
||||||
|
<input type="password" id="pw-confirm" autocomplete="new-password" required minlength="8" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" id="pw-save">Passwort speichern</button>
|
||||||
|
</form>
|
||||||
|
<p id="settings-pw-msg" class="msg"></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Haupt-View (Upload + Jobs + Admin) -->
|
||||||
|
<div id="main-view">
|
||||||
<section class="card upload-card">
|
<section class="card upload-card">
|
||||||
<h2 class="desktop-only">Neue Aufnahme hochladen</h2>
|
<h2 class="desktop-only">Neue Aufnahme hochladen</h2>
|
||||||
<form id="upload-form">
|
<form id="upload-form">
|
||||||
@ -178,6 +213,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div><!-- /main-view -->
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
|
|||||||
@ -416,6 +416,55 @@ select {
|
|||||||
.admin-toggle { cursor: default; }
|
.admin-toggle { cursor: default; }
|
||||||
.admin-toggle-icon { display: none; }
|
.admin-toggle-icon { display: none; }
|
||||||
|
|
||||||
|
/* ── Settings-Subpage ─────────────────────────────────────────── */
|
||||||
|
.settings-view { max-width: 640px; }
|
||||||
|
.settings-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.settings-head h2 { margin: 0; }
|
||||||
|
.settings-section {
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-section:first-of-type { border-top: 0; }
|
||||||
|
.settings-section h3 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.settings-section p.muted { margin: 0 0 10px 0; font-size: 12.5px; }
|
||||||
|
.settings-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: rgba(0,0,0,.25);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
#settings-pw-form { display: flex; flex-direction: column; gap: 12px; max-width: 380px; }
|
||||||
|
#settings-pw-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
||||||
|
#settings-pw-form input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(0,0,0,.25);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
#settings-pw-form button {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
.settings-section .msg { margin-top: 8px; font-size: 12.5px; }
|
||||||
|
|
||||||
/* ── Mobile-Optimierung (echte Mobile-First-UI) ────────────────── */
|
/* ── Mobile-Optimierung (echte Mobile-First-UI) ────────────────── */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.desktop-only { display: none !important; }
|
.desktop-only { display: none !important; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user