Add public feed, admin area, self-registration, visibility & hashtags
Some checks failed
Deploy to NAS / deploy (push) Failing after 26s

- Public feed (/) with infinite scroll via Intersection Observer
- Self-registration (/register)
- Admin area (/admin/entries, /admin/users) with user management
- journal_entries: visibility (public/private) + hashtags fields
- users: is_admin flag
- DB schema updated (recreate DB to apply)
- CI: run go test via docker run (golang:1.25-alpine) — fixes 'go not found'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-04-07 20:53:31 +02:00
parent 034d16e059
commit 86627f94b1
20 changed files with 783 additions and 92 deletions

View File

@@ -0,0 +1,33 @@
{{define "admin_title"}}Einträge verwalten — Admin{{end}}
{{define "admin_content"}}
<h1>Einträge</h1>
<p><a href="/days">→ Neuen Eintrag anlegen (Tagesansicht)</a></p>
{{if .Entries}}
<figure>
<table>
<thead><tr><th>Datum</th><th>Zeit</th><th>Titel</th><th>Sichtbarkeit</th><th>Hashtags</th></tr></thead>
<tbody>
{{range .Entries}}
<tr>
<td><a href="/days/{{.EntryDate}}">{{.EntryDate}}</a></td>
<td>{{.EntryTime}}</td>
<td>{{if .Title}}{{.Title}}{{else}}<small></small>{{end}}</td>
<td>
{{if eq .Visibility "public"}}
<span class="badge-public">öffentlich</span>
{{else}}
<small>privat</small>
{{end}}
</td>
<td><small>{{join .Hashtags ", "}}</small></td>
</tr>
{{end}}
</tbody>
</table>
</figure>
{{else}}
<p><small>// Noch keine Einträge</small></p>
{{end}}
{{end}}

View File

@@ -0,0 +1,25 @@
{{define "admin_base"}}<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "admin_title" .}}Admin{{end}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.slate.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container">
<nav>
<strong>Admin</strong>
<span>
<a href="/admin/entries">Einträge</a> ·
<a href="/admin/users">Benutzer</a> ·
<a href="/days">← App</a>
</span>
</nav>
{{block "admin_content" .}}{{end}}
</main>
{{block "admin_scripts" .}}{{end}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,53 @@
{{define "admin_title"}}Benutzer verwalten — Admin{{end}}
{{define "admin_content"}}
<h1>Benutzer</h1>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/admin/users" style="display:flex;gap:1rem;align-items:flex-end;flex-wrap:wrap">
<div>
<label>Benutzername</label>
<input type="text" name="username" required autocomplete="off">
</div>
<div>
<label>Passwort</label>
<input type="password" name="password" required autocomplete="new-password">
</div>
<button type="submit">Anlegen</button>
</form>
<figure>
<table>
<thead><tr><th>Benutzername</th><th>Admin</th><th>Erstellt</th><th></th></tr></thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.Username}}</td>
<td>{{if .IsAdmin}}✓{{end}}</td>
<td><small>{{.CreatedAt.Format "2006-01-02"}}</small></td>
<td>
{{if ne .UserID $.User.UserID}}
<button class="btn-delete" data-url="/admin/users/{{.UserID}}" data-name="{{.Username}}">Löschen</button>
{{else}}
<small>(du)</small>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</figure>
{{end}}
{{define "admin_scripts"}}
<script>
document.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Benutzer "' + btn.dataset.name + '" löschen?')) return;
fetch(btn.dataset.url, {method: 'DELETE'})
.then(function() { window.location.reload(); });
});
});
</script>
{{end}}

View File

@@ -13,6 +13,15 @@
<label>Uhrzeit</label>
<input type="time" name="time" required id="entry-time">
</div>
<div class="form-col">
<label>Sichtbarkeit</label>
<select name="visibility">
<option value="private">Privat</option>
<option value="public">Öffentlich</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-col">
<label>GPS-Koordinaten <small>(optional)</small></label>
<div class="gps-row">
@@ -22,6 +31,10 @@
</div>
<small id="gps-status"></small>
</div>
<div class="form-col">
<label>Hashtags <small>(kommagetrennt, optional)</small></label>
<input type="text" name="hashtags" placeholder="reise, essen, natur">
</div>
</div>
<label>Überschrift</label>
<input type="text" name="title" placeholder="Titel des Eintrags">
@@ -38,10 +51,12 @@
<div class="entry-card">
<div class="entry-meta">
<strong>{{.EntryTime}}</strong>
{{if eq .Visibility "public"}}<span class="badge-public">öffentlich</span>{{end}}
{{if .Lat}}<small> · &#9675; {{printf "%.5f" (deref .Lat)}}, {{printf "%.5f" (deref .Lon)}}</small>{{end}}
</div>
{{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}}
{{if .Description}}<div class="entry-desc">{{.Description}}</div>{{end}}
{{if .Hashtags}}<div class="hashtags">{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}</div>{{end}}
{{if .Images}}
<div class="entry-images">
{{range .Images}}
@@ -56,27 +71,8 @@
<p><small>// Noch keine Einträge</small></p>
{{end}}
<h2>Trackpunkte <small>({{len .Points}})</small></h2>
<figure>
<table>
<thead><tr><th>Zeit</th><th>Lat</th><th>Lon</th><th>Quelle</th><th>Notiz</th></tr></thead>
<tbody>
{{range .Points}}
<tr>
<td>{{.Timestamp.Format "15:04:05"}}</td>
<td>{{printf "%.5f" .Lat}}</td>
<td>{{printf "%.5f" .Lon}}</td>
<td class="source-{{.Source}}">{{.Source}}</td>
<td><small>{{.Note}}</small></td>
</tr>
{{else}}
<tr><td colspan="5"><small>// Keine Punkte</small></td></tr>
{{end}}
</tbody>
</table>
</figure>
<h2>Aufenthalte <small>({{len .Stops}})</small></h2>
{{if .Stops}}
<figure>
<table>
<thead><tr><th>Von</th><th>Bis</th><th>Dauer</th><th>Ort</th></tr></thead>
@@ -88,12 +84,34 @@
<td><small>{{divInt .DurationS 60}} min</small></td>
<td>{{if .PlaceLabel}}{{.PlaceLabel}}{{else}}<small></small>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="4"><small>// Keine Aufenthalte</small></td></tr>
{{end}}
</tbody>
</table>
</figure>
{{else}}
<p><small>// Keine Aufenthalte</small></p>
{{end}}
<details>
<summary><small>Trackpunkte ({{len .Points}})</small></summary>
<figure>
<table>
<thead><tr><th>Zeit</th><th>Lat</th><th>Lon</th><th>Quelle</th></tr></thead>
<tbody>
{{range .Points}}
<tr>
<td>{{.Timestamp.Format "15:04:05"}}</td>
<td>{{printf "%.5f" .Lat}}</td>
<td>{{printf "%.5f" .Lon}}</td>
<td class="source-{{.Source}}">{{.Source}}</td>
</tr>
{{else}}
<tr><td colspan="4"><small>// Keine Punkte</small></td></tr>
{{end}}
</tbody>
</table>
</figure>
</details>
</main>
{{end}}

View File

@@ -4,7 +4,10 @@
<main class="container">
<div class="page-header">
<h1>REISEJOURNAL</h1>
<a href="/logout">[ Ausloggen ]</a>
<span>
{{if .IsAdmin}}<a href="/admin">[ Admin ]</a> · {{end}}
<a href="/logout">[ Ausloggen ]</a>
</span>
</div>
<form method="get" action="/days/redirect">
<fieldset role="group">

View File

@@ -0,0 +1,72 @@
{{define "title"}}Journal — Öffentliche Einträge{{end}}
{{define "content"}}
<main class="container">
<nav>
<strong>Journal</strong>
<a href="/login">Anmelden</a>
</nav>
<div id="feed">
{{template "feed_items" .}}
</div>
</main>
{{end}}
{{define "feed_items"}}
{{range .Entries}}
<article class="entry-card">
<header>
<small>{{.EntryDate}} · {{.EntryTime}}</small>
{{if .Title}}<strong> · {{.Title}}</strong>{{end}}
</header>
{{if .Description}}<p>{{.Description}}</p>{{end}}
{{if .Images}}
<div class="entry-images">
{{range .Images}}
<a href="/uploads/{{.Filename}}" target="_blank">
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
</a>
{{end}}
</div>
{{end}}
{{if .Hashtags}}
<footer class="hashtags">
{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}
</footer>
{{end}}
</article>
{{else}}
<p><small>// Noch keine öffentlichen Einträge</small></p>
{{end}}
{{if .HasMore}}
<div id="sentinel" data-offset="{{.Offset}}"></div>
{{end}}
{{end}}
{{define "scripts"}}
<script>
(function() {
const sentinel = document.getElementById('sentinel');
if (!sentinel) return;
const obs = new IntersectionObserver(function(entries) {
if (!entries[0].isIntersecting) return;
obs.disconnect();
const offset = sentinel.dataset.offset;
fetch('/feed?offset=' + offset)
.then(r => r.text())
.then(html => {
sentinel.remove();
const div = document.createElement('div');
div.innerHTML = html;
document.getElementById('feed').append(...div.childNodes);
const next = document.getElementById('sentinel');
if (next) obs.observe(next);
});
});
obs.observe(sentinel);
})();
</script>
{{end}}
{{template "base" .}}

View File

@@ -0,0 +1,20 @@
{{define "title"}}Registrieren — Journal{{end}}
{{define "content"}}
<main class="container" style="max-width:400px;margin-top:4rem">
<h1>Konto erstellen</h1>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/register">
<label>Benutzername</label>
<input type="text" name="username" value="{{.Username}}" required autofocus autocomplete="username">
<label>Passwort</label>
<input type="password" name="password" required autocomplete="new-password">
<label>Passwort bestätigen</label>
<input type="password" name="confirm" required autocomplete="new-password">
<button type="submit">Registrieren</button>
</form>
<p><small>Bereits registriert? <a href="/login">Anmelden</a></small></p>
</main>
{{end}}
{{template "base" .}}