Add public feed, admin area, self-registration, visibility & hashtags
Some checks failed
Deploy to NAS / deploy (push) Failing after 26s
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:
33
backend/internal/api/templates/admin/entries.html
Normal file
33
backend/internal/api/templates/admin/entries.html
Normal 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}}
|
||||
25
backend/internal/api/templates/admin/layout.html
Normal file
25
backend/internal/api/templates/admin/layout.html
Normal 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}}
|
||||
53
backend/internal/api/templates/admin/users.html
Normal file
53
backend/internal/api/templates/admin/users.html
Normal 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}}
|
||||
@@ -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> · ○ {{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}}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
72
backend/internal/api/templates/public.html
Normal file
72
backend/internal/api/templates/public.html
Normal 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" .}}
|
||||
20
backend/internal/api/templates/register.html
Normal file
20
backend/internal/api/templates/register.html
Normal 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" .}}
|
||||
Reference in New Issue
Block a user