9cc4dc6384
Introduces a toggle in the web UI that allows administrators to switch between a standard view and an "admin view". This admin view exposes additional administrative elements that are hidden in the standard view. The toggle state is persisted in local storage, allowing the user's preference to be remembered across sessions. Non-administrator users will not see the toggle. feat: Add admin view toggle to web UI Introduce a client-side toggle for an "admin view" in the web UI. This allows administrators to selectively hide or show elements intended only for administrative purposes. The toggle state is persisted in local storage for a persistent user experience. The implementation involves: - Adding a checkbox element to the header of the case pages and my cases list. - Using CSS to conditionally hide elements with the `admin-only` class when the admin view is off. - Implementing JavaScript to manage the toggle's state, update local storage, and apply the `admin-view-off` class to the `<html>` element. - Updating relevant templates (`case_page.html`, `case_recordings.html`, `my_cases.html`) to include the toggle and the `admin-only` class where appropriate. - Adding unit tests to verify the visibility of the toggle for admins and non-admins.
157 lines
8.0 KiB
HTML
157 lines
8.0 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Doctate — Meine Fälle</title>
|
|
<style>
|
|
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
|
header { display: flex; justify-content: space-between; align-items: center; }
|
|
header form { margin: 0; }
|
|
section { margin: 1.5em 0; }
|
|
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
|
|
.empty { color: #888; font-style: italic; }
|
|
|
|
.case-list { list-style: none; padding: 0; margin: 0; }
|
|
.case-row { display: grid; grid-template-columns: auto 1fr auto; gap: 0.6em 1em; align-items: center; padding: 0.7em 1em; border: 1px solid #ccc; border-radius: 4px; margin: 0.5em 0; }
|
|
.case-row.done { border-left: 4px solid #7ed321; }
|
|
.case-row.open { border-left: 4px solid #4a90e2; }
|
|
.case-row .check { align-self: start; padding-top: 0.25em; }
|
|
.case-row .body { min-width: 0; }
|
|
.case-row .body a { color: inherit; text-decoration: none; }
|
|
.case-row .body a:hover .line1 { text-decoration: underline; }
|
|
.case-row .body h3 { margin: 0 0 0.2em 0; font-size: 1em; font-weight: 600; }
|
|
.case-row .body small { color: #666; font-family: monospace; font-size: 0.85em; }
|
|
.case-row .oneliner { color: #333; margin: 0.2em 0 0; }
|
|
.case-row .line1 { font-size: 1em; margin: 0; }
|
|
.case-row .line1 .time { font-family: monospace; font-weight: bold; color: #444; }
|
|
.case-row .line2 { margin: 0.2em 0 0; color: #555; font-size: 0.95em; display: flex; align-items: center; gap: 0.5em; }
|
|
.case-row .uuid { color: #999; font-family: monospace; font-size: 0.7em; margin-top: 0.2em; }
|
|
.status-badge { display: inline-block; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; color: white; }
|
|
.status-badge.open { background: #e24a4a; }
|
|
.status-badge.done { background: #7ed321; }
|
|
.case-row .actions { display: flex; gap: 0.4em; align-items: center; }
|
|
.case-row .actions button { font-size: 0.85em; padding: 0.35em 0.8em; }
|
|
.case-row .actions .delete-btn { padding: 0.3em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; }
|
|
.case-row .actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
|
|
|
|
.label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; vertical-align: middle; }
|
|
.label.analyzing { background: #eee; color: #555; }
|
|
.label.done-doc { background: #7ed321; color: white; }
|
|
|
|
.bulk-bar { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; display: flex; gap: 0.6em; flex-wrap: wrap; align-items: center; }
|
|
.bulk-bar button { font-size: 0.9em; padding: 0.5em 1em; }
|
|
.bulk-bar .undo { margin-left: auto; }
|
|
.bulk-bar .undo button { background: #fff3cd; border: 1px solid #d39e00; color: #5a4500; }
|
|
|
|
.header-right { display: flex; align-items: center; gap: 0.8em; }
|
|
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
|
|
html.admin-view-off .admin-only { display: none !important; }
|
|
</style>
|
|
<script>
|
|
try {
|
|
if (localStorage.getItem('admin-view') === 'off') {
|
|
document.documentElement.classList.add('admin-view-off');
|
|
}
|
|
} catch (_) {}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>{{ slug }} — Meine Fälle</h1>
|
|
<div class="header-right">
|
|
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
|
|
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
|
</div>
|
|
</header>
|
|
|
|
{% if undo_count > 0 %}
|
|
<form class="bulk-bar" method="post" action="/web/cases/undo-delete" style="background:#fff8e1;">
|
|
<span><strong>{{ undo_count }}</strong> zuletzt gelöschte(r) Fall(/Fälle).</span>
|
|
<button type="submit">Löschung rückgängig</button>
|
|
</form>
|
|
{% endif %}
|
|
|
|
{% if total == 0 %}
|
|
<section><p class="empty">Keine Fälle.</p></section>
|
|
{% else %}
|
|
<form method="post" action="/web/cases/bulk">
|
|
{% for g in groups %}
|
|
<section>
|
|
<h2 class="date-group" data-iso="{{ g.label }}">{{ g.label }} ({{ g.cases.len() }})</h2>
|
|
<ul class="case-list">
|
|
{% for case in g.cases %}
|
|
<li class="case-row {% if case.has_document %}done{% else %}open{% endif %}">
|
|
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div>
|
|
<div class="body">
|
|
<a href="/web/cases/{{ case.case_id }}">
|
|
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span>{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %} — <span class="empty">kein Oneliner</span>{% endmatch %}</div>
|
|
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
|
|
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
|
</a>
|
|
</div>
|
|
<div class="actions">
|
|
<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
|
|
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only">Reset</button>{% endif %}
|
|
<button type="submit" formaction="/web/cases/{{ case.case_id }}/delete" class="delete-btn" title="Fall entfernen" aria-label="Fall entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg></button>
|
|
</div>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</section>
|
|
{% endfor %}
|
|
<div class="bulk-bar">
|
|
<span>Auswahl:</span>
|
|
<button type="button" onclick="const b=document.querySelectorAll('input[name=case_id]');const all=Array.from(b).every(c=>c.checked);b.forEach(c=>c.checked=!all);this.textContent=all?'Alles auswählen':'Keine auswählen'">Alles auswählen</button>
|
|
<button type="submit" name="action" value="analyze">Analysieren</button>
|
|
{% if is_admin %}<button type="submit" name="action" value="reset" class="admin-only">Reset</button>{% endif %}
|
|
<button type="submit" name="action" value="delete">Entfernen</button>
|
|
</div>
|
|
</form>
|
|
{% endif %}
|
|
<script>
|
|
// Render all UTC timestamps in the browser's local timezone, and relabel
|
|
// date-group headings to Heute/Gestern based on the browser's current
|
|
// UTC date. Server-side renders only UTC so timezone logic belongs here.
|
|
(function () {
|
|
document.querySelectorAll('time[datetime]').forEach(function (el) {
|
|
var d = new Date(el.dateTime);
|
|
if (!isNaN(d.getTime())) {
|
|
el.textContent = d.toLocaleTimeString(undefined, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
var todayISO = new Date().toISOString().slice(0, 10);
|
|
var yesterday = new Date();
|
|
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
|
var yesterdayISO = yesterday.toISOString().slice(0, 10);
|
|
|
|
document.querySelectorAll('h2.date-group').forEach(function (h) {
|
|
var iso = h.dataset.iso;
|
|
if (!iso) return;
|
|
var suffix = h.textContent.replace(iso, '').trim();
|
|
var label;
|
|
if (iso === todayISO) label = 'Heute';
|
|
else if (iso === yesterdayISO) label = 'Gestern';
|
|
else label = iso;
|
|
h.textContent = label + ' ' + suffix;
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
const cb = document.getElementById('admin-view-toggle');
|
|
if (!cb) return;
|
|
cb.checked = !document.documentElement.classList.contains('admin-view-off');
|
|
cb.addEventListener('change', () => {
|
|
const on = cb.checked;
|
|
try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
|
|
document.documentElement.classList.toggle('admin-view-off', !on);
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|