init
release-tag / release-image (push) Has been cancelled

This commit is contained in:
2026-08-14 17:42:54 +02:00
parent 18a48d2285
commit 222b5d2413
22 changed files with 3013 additions and 1 deletions
+200
View File
@@ -0,0 +1,200 @@
:root {
color-scheme: dark;
--bg: #0e0f11;
--panel: #111316;
--card: #16181b;
--raised: #1c1f23;
--active: #2b3037;
--border: #282c31;
--divider: #202328;
--text: #eceef1;
--muted: #969ca4;
--dim: #6c727a;
--amber: #f5c065;
--amber-hover: #ffd98a;
--on-amber: #16191c;
--danger: #e85b61;
--shadow: 0 24px 70px rgba(0,0,0,.42);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); }
body { min-height: 100dvh; }
button, input, textarea, select { font: inherit; }
button { color: inherit; }
input, textarea, select {
width: 100%; border: 1px solid var(--border); background: var(--card); color: var(--text);
border-radius: 12px; padding: 11px 12px; outline: none; transition: border-color .15s, background .15s;
}
input:focus, textarea:focus, select:focus { border-color: var(--amber); background: #191c20; }
textarea { resize: vertical; min-height: 90px; }
label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; font-weight: 600; }
button { border: 0; cursor: pointer; }
[hidden] { display: none !important; }
.app-shell { height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.topbar { height: 68px; flex: 0 0 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--divider); }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 720; letter-spacing: -.025em; font-size: 18px; }
.brand-large { font-size: 22px; margin-bottom: 32px; }
.brand-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 4px rgba(245,192,101,.08); }
.topbar-actions { display: flex; align-items: center; gap: 9px; }
.metric-inline { display: grid; text-align: right; margin-right: 12px; }
.metric-inline strong { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 18px; }
.metric-inline span { font-size: 10px; color: var(--muted); }
.icon-btn, .avatar-btn { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: transparent; border: 1px solid transparent; }
.icon-btn:hover, .avatar-btn:hover { background: var(--raised); border-color: var(--border); }
.avatar-btn { background: var(--amber); color: var(--on-amber); font-weight: 800; border-radius: 50%; width: 34px; height: 34px; margin-left: 4px; }
.main-grid { min-height: 0; flex: 1; display: grid; grid-template-columns: minmax(320px, 400px) 1fr; }
.tracker-pane { min-height: 0; overflow-y: auto; background: var(--panel); padding: 26px; display: flex; flex-direction: column; gap: 18px; }
.tracker-card { padding: 22px; border: 1px solid var(--border); border-radius: 22px; background: var(--card); display: grid; gap: 16px; box-shadow: inset 0 1px rgba(255,255,255,.02); }
.tracker-date { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--muted); }
.status-pill { color: var(--amber); background: rgba(245,192,101,.08); border: 1px solid rgba(245,192,101,.25); border-radius: 999px; padding: 4px 8px; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.timer-display { padding: 7px 0 0; text-align: center; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(34px, 5vw, 48px); font-variant-numeric: tabular-nums; font-weight: 700; letter-spacing: -.05em; }
.timer-display.running { color: var(--amber); }
.btn { min-height: 40px; padding: 9px 14px; border-radius: 12px; font-weight: 700; border: 1px solid transparent; }
.btn.primary { background: var(--amber); color: var(--on-amber); }
.btn.primary:hover { background: var(--amber-hover); }
.btn.subtle { background: var(--raised); border-color: var(--border); color: var(--text); }
.btn.subtle:hover { background: var(--active); }
.btn.danger { background: rgba(232,91,97,.11); color: #ff9296; border-color: rgba(232,91,97,.28); }
.btn.wide { width: 100%; }
.timer-btn { min-height: 52px; font-size: 16px; }
.link-btn { background: transparent; color: var(--muted); padding: 3px; font-size: 13px; }
.link-btn:hover { color: var(--text); }
.centered { justify-self: center; }
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.metric-card { background: var(--card); border: 1px solid var(--border); border-radius: 17px; padding: 15px 16px; display: grid; gap: 3px; }
.metric-card span { font-size: 11px; color: var(--muted); }
.metric-card strong { color: var(--amber); font: 700 21px ui-monospace, SFMono-Regular, Menlo, monospace; }
.warning-box, .error-box { border-radius: 12px; padding: 11px 12px; font-size: 13px; }
.warning-box { background: rgba(245,192,101,.08); color: #f6cf8c; border: 1px solid rgba(245,192,101,.22); }
.error-box { background: rgba(232,91,97,.09); color: #ff9aa0; border: 1px solid rgba(232,91,97,.22); margin: 0; }
.sidebar-footer { margin-top: auto; padding-top: 10px; display: flex; justify-content: space-between; color: var(--dim); font-size: 11px; }
.mobile-pane-head { display: none; }
.history-pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-left: 1px solid var(--divider); }
.history-head { padding: 25px 28px 15px; display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; }
.history-head h1, .mobile-pane-head h1 { margin: 0; font-size: 31px; line-height: 1; letter-spacing: -.035em; }
.eyebrow { margin: 0 0 7px; color: var(--dim); letter-spacing: .13em; font-size: 10px; font-weight: 800; }
.history-actions { display: flex; gap: 8px; }
.filter-bar { padding: 0 28px 15px; display: grid; grid-template-columns: minmax(180px, 1fr) auto auto; gap: 10px; align-items: center; border-bottom: 1px solid var(--divider); }
.filter-bar input { min-width: 0; }
.period-tabs { display: flex; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 3px; }
.period-tabs button, .period-step button { background: transparent; color: var(--muted); padding: 7px 9px; border-radius: 8px; font-size: 12px; font-weight: 700; }
.period-tabs button.active { background: var(--active); color: var(--text); }
.period-step { display: flex; align-items: center; justify-content: center; gap: 2px; color: var(--muted); white-space: nowrap; }
.period-step span { min-width: 112px; text-align: center; font-size: 12px; }
.period-step button:hover { color: var(--text); background: var(--raised); }
.history-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 28px 20px; }
.entries { display: grid; }
.day-group { min-width: 0; }
.day-heading { position: sticky; top: 0; z-index: 2; padding: 15px 0 8px; display: flex; justify-content: space-between; align-items: baseline; background: linear-gradient(var(--bg) 80%, transparent); }
.day-heading strong { font-size: 13px; }
.day-heading span { color: var(--dim); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
.entry-row { width: 100%; display: grid; grid-template-columns: minmax(120px, .8fr) minmax(180px, 1.3fr) 120px 72px 28px; gap: 14px; align-items: center; padding: 12px 14px; margin-bottom: 6px; background: var(--card); border: 1px solid transparent; border-radius: 13px; text-align: left; }
.entry-row:hover { border-color: var(--border); background: var(--raised); }
.entry-client { min-width: 0; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.entry-activity { min-width: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.entry-time { color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
.entry-duration { text-align: right; color: var(--amber); font: 700 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
.entry-chevron { color: var(--dim); font-size: 19px; }
.history-footer { flex: 0 0 50px; border-top: 1px solid var(--divider); display: flex; align-items: center; justify-content: space-between; padding: 0 28px; color: var(--muted); font-size: 12px; }
.history-footer strong { color: var(--text); }
.history-footer span:last-child strong { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 15px; margin-left: 8px; }
.empty-state { min-height: 55vh; display: grid; place-items: center; align-content: center; text-align: center; color: var(--muted); }
.empty-state h2 { color: var(--text); margin: 10px 0 4px; font-size: 18px; }
.empty-state p { margin: 0; font-size: 13px; }
.empty-icon { font-size: 40px; color: var(--dim); }
.load-more { display: block; margin: 18px auto 0; }
.modal { width: min(620px, calc(100vw - 28px)); max-height: calc(100dvh - 28px); padding: 0; border: 1px solid var(--border); border-radius: 20px; background: var(--card); color: var(--text); box-shadow: var(--shadow); }
.modal::backdrop { background: rgba(0,0,0,.68); backdrop-filter: blur(5px); }
.modal-wide { width: min(860px, calc(100vw - 28px)); }
.modal-card { padding: 22px; display: grid; gap: 16px; max-height: calc(100dvh - 30px); overflow-y: auto; }
.modal-card.compact { max-width: 520px; }
.modal-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; }
.modal h2, .modal h3 { margin: 0; letter-spacing: -.025em; }
.modal h2 { font-size: 24px; }
.modal h3 { font-size: 15px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 5px; }
.modal-actions.split { justify-content: initial; }
.spacer { flex: 1; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.settings-grid section { display: grid; gap: 13px; align-content: start; }
.settings-grid h3 { padding-bottom: 4px; }
.check-row { display: flex; align-items: center; gap: 10px; color: var(--text); font-weight: 500; }
.check-row input { width: 17px; height: 17px; accent-color: var(--amber); }
.admin-section { border-top: 1px solid var(--divider); padding-top: 18px; display: grid; gap: 10px; }
.section-title { display: flex; justify-content: space-between; align-items: center; }
.user-list { display: grid; gap: 7px; }
.user-row { display: grid; grid-template-columns: minmax(140px,1fr) auto auto auto; gap: 9px; align-items: center; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
.user-row .user-meta { min-width: 0; display: grid; }
.user-row .user-meta strong, .user-row .user-meta span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.user-row .user-meta span { color: var(--muted); font-size: 11px; }
.role-badge { color: var(--muted); font-size: 11px; background: var(--raised); padding: 4px 7px; border-radius: 999px; }
.export-scope { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 11px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
.export-compact { align-self: end; min-height: 42px; padding-bottom: 9px; }
.export-preview { margin: -2px 0 0; padding: 10px 12px; border-radius: 11px; background: var(--bg); border: 1px solid var(--border); color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
.export-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.toast { position: fixed; z-index: 30; bottom: 24px; left: 50%; transform: translateX(-50%); background: #25292e; border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; box-shadow: var(--shadow); font-size: 13px; }
.mobile-only { display: none; }
.auth-body { display: grid; min-height: 100dvh; place-items: center; background: radial-gradient(circle at 50% 0, #1c1a16 0, var(--bg) 38%); padding: 20px; }
.auth-shell { width: min(440px, 100%); }
.auth-card { background: rgba(22,24,27,.96); border: 1px solid var(--border); border-radius: 24px; padding: 30px; box-shadow: var(--shadow); }
.auth-card h1 { margin: 0 0 7px; font-size: 30px; letter-spacing: -.035em; }
.auth-card .muted { margin: 0 0 22px; line-height: 1.5; }
.form-stack { display: grid; gap: 15px; }
.form-stack .btn { margin-top: 5px; }
.auth-card .error-box { margin-top: 15px; }
@media (max-width: 1050px) {
.filter-bar { grid-template-columns: 1fr auto; }
.period-step { grid-column: 1 / -1; justify-self: end; }
.entry-row { grid-template-columns: minmax(110px,.8fr) minmax(140px,1.2fr) 110px 68px 20px; gap: 9px; }
}
@media (max-width: 820px) {
.app-shell { overflow: hidden; }
.topbar { height: 58px; flex-basis: 58px; padding: 0 15px; }
.metric-inline, #export-open { display: none; }
.main-grid { display: block; overflow: hidden; }
.tracker-pane, .history-pane { height: calc(100dvh - 58px); border-left: 0; }
.tracker-pane { padding: 15px; }
.history-pane { display: none; }
body.mobile-history .tracker-pane { display: none; }
body.mobile-history .history-pane { display: flex; }
.mobile-pane-head { display: flex; align-items: center; justify-content: space-between; }
.mobile-only { display: inline-block; }
.history-head { padding: 16px 15px 12px; align-items: center; }
.history-title .eyebrow { display: none; }
.history-title h1 { font-size: 25px; margin-top: 4px; }
.history-actions #history-export { display: none; }
.filter-bar { padding: 0 15px 12px; display: flex; flex-wrap: wrap; }
.filter-bar > input { flex: 1 1 100%; }
.period-tabs { flex: 1 1 auto; overflow-x: auto; }
.period-step { flex: 1 1 100%; justify-content: space-between; }
.history-scroll { padding: 0 15px 16px; }
.entry-row { grid-template-columns: 1fr auto 18px; grid-template-areas: "client dur chevron" "activity time chevron"; gap: 4px 10px; padding: 12px; }
.entry-client { grid-area: client; }
.entry-activity { grid-area: activity; }
.entry-time { grid-area: time; }
.entry-duration { grid-area: dur; }
.entry-chevron { grid-area: chevron; align-self: center; }
.history-footer { padding: 0 15px; }
.settings-grid { grid-template-columns: 1fr; gap: 20px; }
.user-row { grid-template-columns: 1fr auto; }
.user-row .role-badge { justify-self: end; }
.two-col, .export-scope, .export-buttons { grid-template-columns: 1fr; }
.modal { width: calc(100vw - 12px); max-height: calc(100dvh - 12px); border-radius: 17px; }
.modal-card { max-height: calc(100dvh - 14px); padding: 18px; }
}
@media (max-width: 430px) {
.tracker-card { padding: 17px; }
.metric-grid { grid-template-columns: 1fr 1fr; }
.history-actions .btn { padding-inline: 10px; }
.auth-card { padding: 23px; }
}
+299
View File
@@ -0,0 +1,299 @@
const state = {
me: null,
csrf: '',
settings: null,
running: null,
entries: [],
clients: [],
totalCount: 0,
totalDuration: 0,
filter: { q: '', period: 'all', anchor: new Date(), offset: 0, limit: 120 },
timerTick: null,
};
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
async function api(url, options = {}) {
const headers = { ...(options.headers || {}) };
if (options.body !== undefined && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json';
if (state.csrf && options.method && !['GET','HEAD'].includes(options.method.toUpperCase())) headers['X-CSRF-Token'] = state.csrf;
const res = await fetch(url, { ...options, headers });
if (res.status === 401) { location.assign('/login'); throw new Error('Nicht angemeldet.'); }
const ct = res.headers.get('content-type') || '';
const body = res.status === 204 ? null : (ct.includes('application/json') ? await res.json().catch(() => ({})) : await res.text());
if (!res.ok) throw new Error(body?.error?.message || `HTTP ${res.status}`);
return body;
}
function toast(msg) {
const el = $('#toast'); el.textContent = msg; el.hidden = false;
clearTimeout(toast._t); toast._t = setTimeout(() => { el.hidden = true; }, 2600);
}
function showError(sel, err) { const el = $(sel); el.textContent = err?.message || String(err); el.hidden = false; }
function hideError(sel) { $(sel).hidden = true; }
function pad(n) { return String(n).padStart(2, '0'); }
function duration(ms, withSeconds = false) {
ms = Math.max(0, ms || 0);
const sec = Math.floor(ms / 1000), h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
return withSeconds ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${h}:${pad(m)}`;
}
function rounded(ms) {
const mins = Math.max(1, Number(state.settings?.rounding_minutes || 1));
if (mins <= 1) return Math.max(0, ms);
const step = mins * 60000;
return state.settings?.round_up ? Math.ceil(ms / step) * step : Math.round(ms / step) * step;
}
function clock(ms) {
const d = new Date(ms);
return new Intl.DateTimeFormat(state.settings?.language === 'en' ? 'en' : 'de-DE', { hour: '2-digit', minute: '2-digit', hour12: state.settings?.time_format === '12' }).format(d);
}
function localDateTimeValue(ms) {
const d = new Date(ms); const off = d.getTimezoneOffset();
return new Date(d.getTime() - off * 60000).toISOString().slice(0,16);
}
function msFromLocalValue(v) { return v ? new Date(v).getTime() : null; }
function dayKey(ms) { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
function dayTitle(ms) {
const d = new Date(ms), now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const target = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
if (target === today) return 'Heute';
const yd = new Date(now.getFullYear(), now.getMonth(), now.getDate()); yd.setDate(yd.getDate()-1);
if (target === yd.getTime()) return 'Gestern';
return new Intl.DateTimeFormat('de-DE', { weekday:'long', day:'2-digit', month:'long', year:'numeric' }).format(d);
}
function initials(s) { return (s || '?').trim().split(/\s+/).slice(0,2).map(x=>x[0]?.toUpperCase()||'').join('') || '?'; }
function escapeText(s) { const x=document.createElement('span'); x.textContent=s??''; return x.innerHTML; }
function rangeForFilter() {
const { period, anchor } = state.filter;
if (period === 'all') return { from:0, to:0, label:'Alle Zeiten' };
let from, to;
const d = new Date(anchor); d.setHours(0,0,0,0);
if (period === 'day') { from = d; to = new Date(d); to.setDate(to.getDate()+1); }
if (period === 'week') { const wd = (d.getDay()+6)%7; d.setDate(d.getDate()-wd); from = new Date(d); to = new Date(d); to.setDate(to.getDate()+7); }
if (period === 'month') { from = new Date(d.getFullYear(),d.getMonth(),1); to = new Date(d.getFullYear(),d.getMonth()+1,1); }
if (period === 'year') { from = new Date(d.getFullYear(),0,1); to = new Date(d.getFullYear()+1,0,1); }
let label='';
if (period === 'day') label = new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}).format(from);
if (period === 'week') label = `${new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit'}).format(from)} – ${new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}).format(new Date(to.getTime()-1))}`;
if (period === 'month') label = new Intl.DateTimeFormat('de-DE',{month:'long',year:'numeric'}).format(from);
if (period === 'year') label = String(from.getFullYear());
return { from: from.getTime(), to: to.getTime(), label };
}
function stepPeriod(delta) {
const d = new Date(state.filter.anchor), p = state.filter.period;
if (p === 'day') d.setDate(d.getDate()+delta);
if (p === 'week') d.setDate(d.getDate()+7*delta);
if (p === 'month') d.setMonth(d.getMonth()+delta);
if (p === 'year') d.setFullYear(d.getFullYear()+delta);
state.filter.anchor = d; state.filter.offset = 0; refreshEntries();
}
function queryString(includePaging = true) {
const r = rangeForFilter(); const p = new URLSearchParams();
if (state.filter.q) p.set('q', state.filter.q);
if (r.from) p.set('from', r.from); if (r.to) p.set('to', r.to);
if (includePaging) { p.set('limit', state.filter.limit); p.set('offset', state.filter.offset); }
return p.toString();
}
async function init() {
try {
const me = await api('/api/me'); state.me = me.user; state.csrf = me.csrf_token;
const [settings, running, clients] = await Promise.all([api('/api/settings'), api('/api/running'), api('/api/clients')]);
state.settings = settings; state.running = running.entry; state.clients = clients.clients || [];
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if ((!state.settings.timezone || state.settings.timezone === 'UTC') && tz && tz !== 'UTC') {
state.settings.timezone = tz;
try { state.settings = await api('/api/settings', { method:'PUT', body:JSON.stringify(state.settings) }); } catch (_) {}
}
renderIdentity(); renderClients(); renderRunning(); fillSettings();
await Promise.all([refreshEntries(), refreshTotals()]);
startTicker();
} catch (e) { toast(e.message); }
}
function renderIdentity() {
$('#user-label').textContent = state.me.display_name || state.me.username;
$('#avatar-text').textContent = initials(state.me.display_name || state.me.username);
}
function renderClients() { $('#client-list').innerHTML = state.clients.map(x => `<option value="${escapeText(x)}"></option>`).join(''); }
function renderRunning() {
const e = state.running;
$('#running-hint').hidden = !e; $('#edit-running').hidden = !e;
$('#timer-button').textContent = e ? '■ Stop' : '▶ Start';
$('#timer-display').classList.toggle('running', !!e);
$('#client-input').disabled = !!e;
if (e) { $('#client-input').value=e.client||''; $('#activity-input').value=e.activity||''; }
updateTicker();
}
function startTicker() { clearInterval(state.timerTick); state.timerTick = setInterval(updateTicker, 1000); updateTicker(); }
function updateTicker() {
const e=state.running, elapsed=e ? Date.now()-e.start_ms : 0;
$('#timer-display').textContent=duration(elapsed,true);
const warn=!!(e && state.settings?.long_run_reminder && elapsed>8*3600000); $('#long-running-warning').hidden=!warn;
}
async function timerToggle() {
try {
if (!state.running) {
const x = await api('/api/entries/start', { method:'POST', body:JSON.stringify({ client:$('#client-input').value, activity:$('#activity-input').value, start_ms:Date.now() }) });
state.running=x; renderRunning(); toast('Timer gestartet.');
} else {
await saveRunningActivity();
await api(`/api/entries/${state.running.id}/stop`, { method:'POST', body:JSON.stringify({ end_ms:Date.now() }) });
state.running=null; $('#client-input').disabled=false; $('#client-input').value=''; $('#activity-input').value=''; renderRunning(); toast('Timer gestoppt.');
await Promise.all([refreshEntries(true),refreshTotals(),refreshClients()]);
}
} catch(e) { toast(e.message); }
}
async function saveRunningActivity() {
if (!state.running) return;
const activity=$('#activity-input').value;
if (activity===state.running.activity) return;
const x=await api(`/api/entries/${state.running.id}`,{method:'PUT',body:JSON.stringify({client:state.running.client,activity,start_ms:state.running.start_ms,end_ms:null})});
state.running=x;
}
async function refreshClients(){const x=await api('/api/clients');state.clients=x.clients||[];renderClients();}
async function refreshTotals() {
const now=new Date(); const today=new Date(now.getFullYear(),now.getMonth(),now.getDate());
const week=new Date(today); week.setDate(week.getDate()-((week.getDay()+6)%7));
try {
const [t,w]=await Promise.all([
api(`/api/entries?from=${today.getTime()}&to=${new Date(today.getFullYear(),today.getMonth(),today.getDate()+1).getTime()}&limit=1&offset=0`),
api(`/api/entries?from=${week.getTime()}&limit=1&offset=0`)
]);
$('#today-total').textContent=duration(t.total_duration_ms);
$('#week-total').textContent=duration(w.total_duration_ms);
$('#header-week').textContent=duration(w.total_duration_ms);
$('#week-badge').hidden=!state.settings.show_week_total;
} catch(e){ console.warn(e); }
}
async function refreshEntries(reset=true) {
if(reset){state.filter.offset=0;state.entries=[];}
const range=rangeForFilter(); $('#period-label').textContent=range.label;
try {
const p=await api('/api/entries?'+queryString(true));
state.totalCount=p.total_count;state.totalDuration=p.total_duration_ms;
state.entries=reset?p.entries:[...state.entries,...p.entries];
renderEntries();
} catch(e){toast(e.message);}
}
function renderEntries() {
$('#result-count').textContent=state.totalCount; $('#result-total').textContent=duration(state.totalDuration);
$('#empty-state').hidden=state.totalCount!==0;
$('#load-more').hidden=state.entries.length>=state.totalCount;
const groups=[]; let current=null;
for(const e of state.entries){const k=dayKey(e.start_ms);if(!current||current.key!==k){current={key:k,start:e.start_ms,items:[]};groups.push(current)}current.items.push(e)}
$('#entries').innerHTML=groups.map(g=>{
const dayTotal=g.items.reduce((n,e)=>n+rounded((e.end_ms||e.start_ms)-e.start_ms),0);
return `<section class="day-group"><div class="day-heading"><strong>${escapeText(dayTitle(g.start))}</strong><span>${duration(dayTotal)}</span></div>${g.items.map(entryHTML).join('')}</section>`;
}).join('');
$$('.entry-row').forEach(el=>el.addEventListener('click',()=>openEntry(el.dataset.id)));
$$('.day-heading').forEach(el=>el.style.position=state.settings.sticky_days?'sticky':'static');
}
function entryHTML(e){return `<button class="entry-row" data-id="${e.id}"><span class="entry-client">${escapeText(e.client||'Kein Kunde')}</span><span class="entry-activity">${escapeText(e.activity||'—')}</span><span class="entry-time">${clock(e.start_ms)}–${clock(e.end_ms)}</span><span class="entry-duration">${duration(rounded(e.end_ms-e.start_ms))}</span><span class="entry-chevron">›</span></button>`}
function openEntry(id=null, running=false) {
hideError('#entry-error');
let e = null;
if (running) e=state.running; else if(id) e=state.entries.find(x=>x.id===id);
$('#entry-id').value=e?.id||''; $('#edit-client').value=e?.client||''; $('#edit-activity').value=e?.activity||'';
const now=Date.now(); $('#edit-start').value=localDateTimeValue(e?.start_ms||now); $('#edit-end').value=e?.end_ms?localDateTimeValue(e.end_ms):(running?'':localDateTimeValue(now+3600000));
$('#entry-dialog-title').textContent=e?'Eintrag bearbeiten':'Zeit nachtragen'; $('#delete-entry').hidden=!e;
updateEditDuration(); $('#entry-dialog').showModal();
}
function updateEditDuration(){const s=msFromLocalValue($('#edit-start').value),e=msFromLocalValue($('#edit-end').value);$('#edit-duration').textContent=s&&e&&e>=s?duration(rounded(e-s)):'–'}
async function saveEntry(ev){ev.preventDefault();hideError('#entry-error');const id=$('#entry-id').value;const start=msFromLocalValue($('#edit-start').value),end=msFromLocalValue($('#edit-end').value);if(!start||!end||end<start){showError('#entry-error',new Error('Bitte einen gültigen Start- und Endzeitpunkt wählen.'));return}const body={client:$('#edit-client').value,activity:$('#edit-activity').value,start_ms:start,end_ms:end};try{if(id)await api(`/api/entries/${id}`,{method:'PUT',body:JSON.stringify(body)});else await api('/api/entries',{method:'POST',body:JSON.stringify(body)});$('#entry-dialog').close();if(state.running?.id===id){state.running=null;renderRunning()}await Promise.all([refreshEntries(true),refreshTotals(),refreshClients()]);toast('Eintrag gespeichert.')}catch(e){showError('#entry-error',e)}}
async function deleteEntry(){const id=$('#entry-id').value;if(!id||!confirm('Diesen Eintrag wirklich löschen?'))return;try{await api(`/api/entries/${id}`,{method:'DELETE'});if(state.running?.id===id){state.running=null;renderRunning()}$('#entry-dialog').close();await Promise.all([refreshEntries(true),refreshTotals()]);toast('Eintrag gelöscht.')}catch(e){showError('#entry-error',e)}}
function fillSettings(){const s=state.settings;$('#setting-time-format').value=s.time_format;$('#setting-rounding').value=s.rounding_minutes;$('#setting-round-up').checked=s.round_up;$('#setting-week').checked=s.show_week_total;$('#setting-sticky').checked=s.sticky_days;$('#setting-reminder').checked=s.long_run_reminder;$('#setting-export-name').value=s.export_name||'';$('#setting-timezone').value=s.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||'UTC';$('#setting-export-date').checked=s.export_date;$('#admin-section').hidden=state.me.role!=='admin'}
async function openSettings(){fillSettings();hideError('#settings-error');$('#settings-dialog').showModal();if(state.me.role==='admin')await loadUsers()}
async function saveSettings(ev){ev.preventDefault();hideError('#settings-error');const s={language:state.settings.language||'de',time_format:$('#setting-time-format').value,rounding_minutes:Number($('#setting-rounding').value),round_up:$('#setting-round-up').checked,show_week_total:$('#setting-week').checked,sticky_days:$('#setting-sticky').checked,long_run_reminder:$('#setting-reminder').checked,export_name:$('#setting-export-name').value,timezone:$('#setting-timezone').value,export_date:$('#setting-export-date').checked};try{state.settings=await api('/api/settings',{method:'PUT',body:JSON.stringify(s)});$('#settings-dialog').close();renderEntries();await refreshTotals();toast('Einstellungen gespeichert.')}catch(e){showError('#settings-error',e)}}
async function changeOwnPassword(ev){
ev.preventDefault(); hideError('#account-password-error');
try {
await api('/api/account/password',{method:'POST',body:JSON.stringify({current_password:$('#current-password').value,new_password:$('#own-new-password').value})});
location.assign('/login');
} catch(e){ showError('#account-password-error',e); }
}
async function loadUsers(){try{const x=await api('/api/admin/users');$('#user-list').innerHTML=x.users.map(u=>`<div class="user-row" data-user-id="${u.id}"><div class="user-meta"><strong>${escapeText(u.display_name||u.username)}</strong><span>${escapeText(u.username)}</span></div><span class="role-badge">${u.role==='admin'?'Admin':'Benutzer'}</span><button type="button" class="btn subtle user-password" ${u.active?'':'disabled'}>Passwort</button><button type="button" class="btn ${u.active?'danger':'subtle'} user-toggle" ${u.id===state.me.id?'disabled':''}>${u.active?'Deaktivieren':'Aktivieren'}</button></div>`).join('');$$('.user-password').forEach(b=>b.addEventListener('click',()=>openPassword(b.closest('.user-row').dataset.userId)));$$('.user-toggle').forEach(b=>b.addEventListener('click',()=>toggleUser(b.closest('.user-row').dataset.userId,b.textContent==='Aktivieren')))}catch(e){showError('#settings-error',e)}}
function openPassword(id){$('#password-user-id').value=id;$('#reset-password').value='';hideError('#password-error');$('#password-dialog').showModal()}
async function toggleUser(id,active){try{await api(`/api/admin/users/${id}`,{method:'PATCH',body:JSON.stringify({active})});await loadUsers();toast(active?'Benutzer aktiviert.':'Benutzer deaktiviert.')}catch(e){showError('#settings-error',e)}}
async function createUser(ev){ev.preventDefault();hideError('#user-error');const body={username:$('#new-username').value,displayName:$('#new-display-name').value,password:$('#new-password').value,role:$('#new-role').value};try{await api('/api/admin/users',{method:'POST',body:JSON.stringify(body)});$('#user-dialog').close();ev.currentTarget.reset();await loadUsers();toast('Benutzer angelegt.')}catch(e){showError('#user-error',e)}}
async function resetPassword(ev){ev.preventDefault();hideError('#password-error');try{await api(`/api/admin/users/${$('#password-user-id').value}/password`,{method:'POST',body:JSON.stringify({password:$('#reset-password').value})});$('#password-dialog').close();toast('Passwort zurückgesetzt.')}catch(e){showError('#password-error',e)}}
function dateInputValue(d) { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
function localDayStartMS(value) {
if (!value) return 0;
const [y,m,d]=value.split('-').map(Number);
return new Date(y,m-1,d,0,0,0,0).getTime();
}
function exportParams(includePresentation=true) {
const p=new URLSearchParams();
const scope=$('input[name="export-scope"]:checked')?.value||'view';
if(scope==='view') {
const r=rangeForFilter();
if(state.filter.q) p.set('q',state.filter.q);
if(r.from) p.set('from',r.from);
if(r.to) p.set('to',r.to);
} else {
const from=localDayStartMS($('#export-from').value);
const toStart=localDayStartMS($('#export-to').value);
if(from) p.set('from',from);
if(toStart) { const end=new Date(toStart); end.setDate(end.getDate()+1); p.set('to',end.getTime()); }
}
if(includePresentation) {
if($('#export-sort').value==='asc') p.set('sort','asc');
if($('#export-compact').checked) p.set('compact','1');
}
return p;
}
let exportPreviewSeq=0;
async function updateExportPreview() {
const seq=++exportPreviewSeq;
const scope=$('input[name="export-scope"]:checked')?.value||'view';
$('#export-custom').hidden=scope!=='custom';
if(scope==='view') { $('#export-preview').textContent=`${state.totalCount} Einträge · ${duration(state.totalDuration)}`; return; }
const from=localDayStartMS($('#export-from').value), to=localDayStartMS($('#export-to').value);
if(!from||!to||to<from) { $('#export-preview').textContent='Bitte einen gültigen Zeitraum wählen.'; return; }
try {
const p=exportParams(false); p.set('limit','1'); p.set('offset','0');
const result=await api('/api/entries?'+p.toString());
if(seq===exportPreviewSeq) $('#export-preview').textContent=`${result.total_count} Einträge · ${duration(result.total_duration_ms)}`;
} catch(e) { if(seq===exportPreviewSeq) $('#export-preview').textContent=e.message; }
}
function openExport(){
const now=new Date(), first=new Date(now.getFullYear(),now.getMonth(),1);
if(!$('#export-from').value) $('#export-from').value=dateInputValue(first);
if(!$('#export-to').value) $('#export-to').value=dateInputValue(now);
updateExportPreview(); $('#export-dialog').showModal();
}
function downloadExport(type){
const p=exportParams(true), a=document.createElement('a');
a.href=`/api/export.${type}${p.toString()?'?'+p.toString():''}`; a.click();
}
function wireEvents(){
$('#timer-button').addEventListener('click',timerToggle); $('#activity-input').addEventListener('blur',()=>saveRunningActivity().catch(e=>toast(e.message)));
$('#edit-running').addEventListener('click',()=>openEntry(null,true)); $('#add-entry').addEventListener('click',()=>openEntry());
$('#entry-form').addEventListener('submit',saveEntry); $('#delete-entry').addEventListener('click',deleteEntry); $('#edit-start').addEventListener('input',updateEditDuration); $('#edit-end').addEventListener('input',updateEditDuration);
$('#settings-open').addEventListener('click',openSettings); $('#settings-form').addEventListener('submit',saveSettings); $('#change-own-password').addEventListener('click',()=>{hideError('#account-password-error');$('#account-password-form').reset();$('#account-password-dialog').showModal()}); $('#account-password-form').addEventListener('submit',changeOwnPassword); $('#add-user').addEventListener('click',()=>{hideError('#user-error');$('#user-dialog').showModal()}); $('#user-form').addEventListener('submit',createUser); $('#password-form').addEventListener('submit',resetPassword);
$('#export-open').addEventListener('click',openExport); $('#history-export').addEventListener('click',openExport); $('#export-pdf').addEventListener('click',()=>downloadExport('pdf')); $('#export-csv').addEventListener('click',()=>downloadExport('csv')); $$('input[name="export-scope"]').forEach(x=>x.addEventListener('change',updateExportPreview)); $('#export-from').addEventListener('change',updateExportPreview); $('#export-to').addEventListener('change',updateExportPreview);
$('#account-menu').addEventListener('click',async()=>{if(!confirm('Abmelden?'))return;try{await api('/api/logout',{method:'POST',body:'{}'});}finally{location.assign('/login')}});
let searchTimer; $('#search-input').addEventListener('input',e=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{state.filter.q=e.target.value.trim();state.filter.offset=0;refreshEntries(true)},220)});
$$('.period-tabs button').forEach(b=>b.addEventListener('click',()=>{$$('.period-tabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.filter.period=b.dataset.period;state.filter.anchor=new Date();state.filter.offset=0;refreshEntries(true)}));
$('#period-prev').addEventListener('click',()=>stepPeriod(-1)); $('#period-next').addEventListener('click',()=>stepPeriod(1));
$('#load-more').addEventListener('click',()=>{state.filter.offset=state.entries.length;refreshEntries(false)});
$$('[data-close]').forEach(b=>b.addEventListener('click',()=>document.getElementById(b.dataset.close).close()));
$$('dialog').forEach(d=>d.addEventListener('click',e=>{const r=d.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)d.close()}));
$('#mobile-history').addEventListener('click',()=>document.body.classList.add('mobile-history')); $('#mobile-track').addEventListener('click',()=>document.body.classList.remove('mobile-history'));
}
wireEvents(); init();
+174
View File
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0f11">
<title>Pocketwatch</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand"><span class="brand-dot"></span><span>pocketwatch</span></div>
<div class="topbar-actions">
<div id="week-badge" class="metric-inline"><strong id="header-week">0:00</strong><span>Diese Woche</span></div>
<button class="icon-btn" id="export-open" title="Exportieren" aria-label="Exportieren">⇩</button>
<button class="icon-btn" id="settings-open" title="Einstellungen" aria-label="Einstellungen">⚙</button>
<button class="avatar-btn" id="account-menu" title="Abmelden"><span id="avatar-text">U</span></button>
</div>
</header>
<main class="main-grid">
<aside class="tracker-pane">
<div class="mobile-pane-head"><h1>Erfassen</h1><button class="link-btn" id="mobile-history">Verlauf →</button></div>
<section class="tracker-card">
<div class="tracker-date"><span id="today-label">Heute</span><span id="running-hint" class="status-pill" hidden>läuft</span></div>
<label class="field-label">Kunde
<input id="client-input" list="client-list" maxlength="200" placeholder="Kunde / Projekt">
<datalist id="client-list"></datalist>
</label>
<label class="field-label">Tätigkeit
<textarea id="activity-input" rows="4" maxlength="4000" placeholder="Woran arbeitest du?"></textarea>
</label>
<div class="timer-display" id="timer-display">00:00:00</div>
<button id="timer-button" class="btn primary timer-btn">▶ Start</button>
<button id="edit-running" class="link-btn centered" hidden>Laufenden Eintrag bearbeiten</button>
</section>
<div class="metric-grid">
<article class="metric-card"><span>Heute</span><strong id="today-total">0:00</strong></article>
<article class="metric-card"><span>Diese Woche</span><strong id="week-total">0:00</strong></article>
</div>
<div id="long-running-warning" class="warning-box" hidden>Der Timer läuft seit mehr als 8 Stunden. Vielleicht vergessen zu stoppen?</div>
<footer class="sidebar-footer"><span id="user-label"></span><span>SQLite · Go</span></footer>
</aside>
<section class="history-pane" id="history-pane">
<div class="history-head">
<div class="history-title"><button class="link-btn mobile-only" id="mobile-track">← Erfassen</button><p class="eyebrow">ZEITEN</p><h1>Verlauf</h1></div>
<div class="history-actions">
<button class="btn subtle" id="add-entry">+ Nachtragen</button>
<button class="btn subtle" id="history-export">Export</button>
</div>
</div>
<div class="filter-bar">
<input id="search-input" type="search" placeholder="Kunde oder Tätigkeit suchen…" aria-label="Verlauf durchsuchen">
<div class="period-tabs" role="group" aria-label="Zeitraum">
<button data-period="day">Tag</button><button data-period="week">Woche</button><button data-period="month">Monat</button><button data-period="year">Jahr</button><button data-period="all" class="active">Alle</button>
</div>
<div class="period-step"><button id="period-prev" aria-label="Vorheriger Zeitraum">‹</button><span id="period-label">Alle Zeiten</span><button id="period-next" aria-label="Nächster Zeitraum">›</button></div>
</div>
<div class="history-scroll">
<div id="empty-state" class="empty-state" hidden><div class="empty-icon">◷</div><h2>Noch keine Einträge</h2><p>Starte links einen Timer oder trage eine Zeit nach.</p></div>
<div id="entries" class="entries"></div>
<button id="load-more" class="btn subtle load-more" hidden>Weitere laden</button>
</div>
<footer class="history-footer"><span><strong id="result-count">0</strong> Einträge</span><span>Gesamt <strong id="result-total">0:00</strong></span></footer>
</section>
</main>
</div>
<dialog id="entry-dialog" class="modal">
<form id="entry-form" method="dialog" class="modal-card">
<div class="modal-head"><div><p class="eyebrow">ZEITEINTRAG</p><h2 id="entry-dialog-title">Eintrag bearbeiten</h2></div><button type="button" class="icon-btn" data-close="entry-dialog">×</button></div>
<input type="hidden" id="entry-id">
<label>Kunde<input id="edit-client" maxlength="200" list="client-list"></label>
<label>Tätigkeit<textarea id="edit-activity" rows="4" maxlength="4000"></textarea></label>
<div class="two-col"><label>Start<input id="edit-start" type="datetime-local" required></label><label>Ende<input id="edit-end" type="datetime-local"></label></div>
<p class="muted small">Dauer: <strong id="edit-duration">–</strong></p>
<p id="entry-error" class="error-box" hidden></p>
<div class="modal-actions split"><button type="button" id="delete-entry" class="btn danger" hidden>Löschen</button><div class="spacer"></div><button type="button" class="btn subtle" data-close="entry-dialog">Abbrechen</button><button type="submit" class="btn primary">Speichern</button></div>
</form>
</dialog>
<dialog id="settings-dialog" class="modal modal-wide">
<form id="settings-form" method="dialog" class="modal-card">
<div class="modal-head"><div><p class="eyebrow">POCKETWATCH</p><h2>Einstellungen</h2></div><button type="button" class="icon-btn" data-close="settings-dialog">×</button></div>
<div class="settings-grid">
<section>
<h3>Anzeige & Auswertung</h3>
<label>Zeitformat<select id="setting-time-format"><option value="24">24 Stunden</option><option value="12">12 Stunden</option></select></label>
<label>Rundung in Minuten<input id="setting-rounding" type="number" min="1" max="60"></label>
<label class="check-row"><input id="setting-round-up" type="checkbox"><span>Immer aufrunden</span></label>
<label class="check-row"><input id="setting-week" type="checkbox"><span>Wochensumme anzeigen</span></label>
<label class="check-row"><input id="setting-sticky" type="checkbox"><span>Tagesüberschriften fixieren</span></label>
<label class="check-row"><input id="setting-reminder" type="checkbox"><span>Warnung bei langem Timer</span></label>
</section>
<section>
<h3>Export</h3>
<label>Name im PDF<input id="setting-export-name" maxlength="120"></label>
<label>Zeitzone für Exporte<input id="setting-timezone" maxlength="80" placeholder="Europe/Berlin"></label>
<label class="check-row"><input id="setting-export-date" type="checkbox"><span>Exportdatum im PDF</span></label>
<p class="muted small">Tipp: Die Zeitzone wird beim ersten Öffnen automatisch aus dem Browser übernommen.</p>
<button type="button" class="btn subtle" id="change-own-password">Eigenes Passwort ändern</button>
</section>
</div>
<section id="admin-section" class="admin-section" hidden>
<div class="section-title"><div><p class="eyebrow">ADMIN</p><h3>Benutzer</h3></div><button type="button" id="add-user" class="btn subtle">+ Benutzer</button></div>
<div id="user-list" class="user-list"></div>
</section>
<p id="settings-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="settings-dialog">Abbrechen</button><button type="submit" class="btn primary">Speichern</button></div>
</form>
</dialog>
<dialog id="user-dialog" class="modal">
<form id="user-form" method="dialog" class="modal-card compact">
<div class="modal-head"><h2>Benutzer anlegen</h2><button type="button" class="icon-btn" data-close="user-dialog">×</button></div>
<label>Benutzername<input id="new-username" minlength="3" maxlength="64" required></label>
<label>Anzeigename<input id="new-display-name" maxlength="120"></label>
<label>Passwort<input id="new-password" type="password" minlength="10" required></label>
<label>Rolle<select id="new-role"><option value="user">Benutzer</option><option value="admin">Administrator</option></select></label>
<p id="user-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="user-dialog">Abbrechen</button><button type="submit" class="btn primary">Anlegen</button></div>
</form>
</dialog>
<dialog id="account-password-dialog" class="modal">
<form id="account-password-form" method="dialog" class="modal-card compact">
<div class="modal-head"><h2>Eigenes Passwort ändern</h2><button type="button" class="icon-btn" data-close="account-password-dialog">×</button></div>
<label>Aktuelles Passwort<input id="current-password" type="password" autocomplete="current-password" required></label>
<label>Neues Passwort<input id="own-new-password" type="password" minlength="10" autocomplete="new-password" required></label>
<p id="account-password-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="account-password-dialog">Abbrechen</button><button type="submit" class="btn primary">Ändern</button></div>
</form>
</dialog>
<dialog id="password-dialog" class="modal">
<form id="password-form" method="dialog" class="modal-card compact">
<div class="modal-head"><h2>Passwort zurücksetzen</h2><button type="button" class="icon-btn" data-close="password-dialog">×</button></div>
<input type="hidden" id="password-user-id">
<label>Neues Passwort<input id="reset-password" type="password" minlength="10" required></label>
<p id="password-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="password-dialog">Abbrechen</button><button type="submit" class="btn primary">Zurücksetzen</button></div>
</form>
</dialog>
<dialog id="export-dialog" class="modal">
<div class="modal-card compact">
<div class="modal-head"><div><p class="eyebrow">EXPORT</p><h2>Zeiten exportieren</h2></div><button type="button" class="icon-btn" data-close="export-dialog">×</button></div>
<p class="muted">Rundung und Zeitzone werden aus deinen Einstellungen übernommen.</p>
<div class="export-scope" role="radiogroup" aria-label="Exportumfang">
<label class="check-row"><input type="radio" name="export-scope" value="view" checked><span>Aktuelle Ansicht</span></label>
<label class="check-row"><input type="radio" name="export-scope" value="custom"><span>Eigener Zeitraum</span></label>
</div>
<div id="export-custom" class="two-col" hidden>
<label>Von<input id="export-from" type="date"></label>
<label>Bis<input id="export-to" type="date"></label>
</div>
<div class="two-col">
<label>Sortierung<select id="export-sort"><option value="desc">Neueste zuerst</option><option value="asc">Älteste zuerst</option></select></label>
<label class="check-row export-compact"><input id="export-compact" type="checkbox"><span>Nur Zeiten (ohne Tätigkeit)</span></label>
</div>
<p id="export-preview" class="export-preview">0 Einträge · 0:00</p>
<div class="export-buttons"><button id="export-pdf" class="btn primary">PDF herunterladen</button><button id="export-csv" class="btn subtle">CSV herunterladen</button></div>
</div>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script src="/static/app.js" defer></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0f11">
<title>Pocketwatch</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="auth-body">
<main class="auth-shell">
<section class="auth-card" aria-labelledby="auth-title">
<div class="brand brand-large"><span class="brand-dot"></span><span>pocketwatch</span></div>
<div id="login-panel">
<p class="eyebrow">SELF-HOSTED TIME TRACKING</p>
<h1 id="auth-title">Anmelden</h1>
<p class="muted">Deine Zeiten bleiben auf deinem Server.</p>
<form id="login-form" class="form-stack">
<label>Benutzername<input name="username" autocomplete="username" required autofocus></label>
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
<button class="btn primary wide" type="submit">Anmelden</button>
</form>
</div>
<div id="setup-panel" hidden>
<p class="eyebrow">ERSTEINRICHTUNG</p>
<h1>Admin anlegen</h1>
<p class="muted">Der erste Benutzer wird Administrator. Weitere Konten legst du später in den Einstellungen an.</p>
<form id="setup-form" class="form-stack">
<label>Benutzername<input name="username" minlength="3" maxlength="64" autocomplete="username" required autofocus></label>
<label>Anzeigename<input name="displayName" maxlength="120" autocomplete="name"></label>
<label>Passwort<input name="password" type="password" minlength="10" autocomplete="new-password" required></label>
<button class="btn primary wide" type="submit">Instanz einrichten</button>
</form>
</div>
<p id="auth-error" class="error-box" role="alert" hidden></p>
</section>
</main>
<script src="/static/login.js" defer></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
const errorBox = document.querySelector('#auth-error');
const loginPanel = document.querySelector('#login-panel');
const setupPanel = document.querySelector('#setup-panel');
function showError(message) {
errorBox.textContent = message;
errorBox.hidden = !message;
}
async function request(url, options = {}) {
const res = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } });
const body = res.status === 204 ? null : await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body?.error?.message || `HTTP ${res.status}`);
return body;
}
(async () => {
try {
const x = await request('/api/bootstrap');
loginPanel.hidden = x.needs_setup;
setupPanel.hidden = !x.needs_setup;
} catch (e) { showError(e.message); }
})();
document.querySelector('#login-form').addEventListener('submit', async (ev) => {
ev.preventDefault(); showError('');
const fd = new FormData(ev.currentTarget);
try {
await request('/api/login', { method: 'POST', body: JSON.stringify({ username: fd.get('username'), password: fd.get('password') }) });
location.assign('/');
} catch (e) { showError(e.message); }
});
document.querySelector('#setup-form').addEventListener('submit', async (ev) => {
ev.preventDefault(); showError('');
const fd = new FormData(ev.currentTarget);
try {
await request('/api/setup', { method: 'POST', body: JSON.stringify({ username: fd.get('username'), displayName: fd.get('displayName'), password: fd.get('password') }) });
location.assign('/');
} catch (e) { showError(e.message); }
});