feat: implement Phase 1 (auth) and Phase 2 (faves CRUD) foundation
Go backend with server-rendered HTML/HTMX frontend, SQLite database, and filesystem image storage. Self-hostable single-binary architecture. Phase 1 — Authentication & project foundation: - Argon2id password hashing with timing-attack prevention - Session management with cookie-based auth and periodic cleanup - Login, signup (open/requests/closed modes), logout, forced password reset - CSRF double-submit cookie pattern with HTMX auto-inclusion - Proxy-aware real IP extraction (WireGuard/Tailscale support) - Configurable base path for subdomain and subpath deployment - Rate limiting on auth endpoints with background cleanup - Security headers (CSP, X-Frame-Options, Referrer-Policy) - Structured logging with slog, graceful shutdown - Pico CSS + HTMX vendored and embedded via go:embed Phase 2 — Faves CRUD with tags and images: - Full CRUD for favorites with ownership checks - Image upload with EXIF stripping, resize to 1920px, UUID filenames - Tag system with HTMX autocomplete (prefix search, popularity-sorted) - Privacy controls (public/private per fave, user-configurable default) - Tag browsing, pagination, batch tag loading (avoids N+1) - OpenGraph meta tags on public fave detail pages Includes code quality pass: extracted shared helpers, fixed signup request persistence bug, plugged rate limiter memory leak, removed dead code, and logged previously-swallowed errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
fc1f7259c5
52 changed files with 5459 additions and 0 deletions
12
web/embed.go
Normal file
12
web/embed.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package web embeds static assets and templates into the binary.
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates
|
||||
var TemplatesFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var StaticFS embed.FS
|
||||
162
web/static/css/style.css
Normal file
162
web/static/css/style.css
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/* Favoritter — custom styles on top of Pico CSS */
|
||||
|
||||
/* Skip navigation link for accessibility */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--pico-primary-background);
|
||||
color: var(--pico-primary-inverse);
|
||||
z-index: 1000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Inline forms (e.g. logout button in nav) */
|
||||
.inline-form {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Flash messages */
|
||||
.flash {
|
||||
padding: 1rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.flash-success {
|
||||
background: color-mix(in srgb, var(--pico-ins-color) 15%, transparent);
|
||||
border: 1px solid var(--pico-ins-color);
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
background: color-mix(in srgb, var(--pico-del-color) 15%, transparent);
|
||||
border: 1px solid var(--pico-del-color);
|
||||
}
|
||||
|
||||
.flash-info {
|
||||
background: color-mix(in srgb, var(--pico-primary) 10%, transparent);
|
||||
border: 1px solid var(--pico-primary);
|
||||
}
|
||||
|
||||
/* Fave card grid */
|
||||
.fave-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fave-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fave-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0;
|
||||
}
|
||||
|
||||
.fave-card header {
|
||||
padding: 0.5rem 1rem 0;
|
||||
}
|
||||
|
||||
.fave-card footer {
|
||||
padding: 0 1rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Privacy badge */
|
||||
.badge-private {
|
||||
background: var(--pico-muted-border-color);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--pico-border-radius);
|
||||
font-size: 0.75rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Tag chips */
|
||||
.tag-chip {
|
||||
display: inline-block;
|
||||
background: var(--pico-primary-focus);
|
||||
color: var(--pico-primary);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
margin: 0.1rem;
|
||||
}
|
||||
|
||||
.tag-chip:hover {
|
||||
background: var(--pico-primary);
|
||||
color: var(--pico-primary-inverse);
|
||||
}
|
||||
|
||||
/* Tag autocomplete suggestions */
|
||||
.tag-suggestions {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tag-suggestions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tag-suggestion {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-suggestion:hover,
|
||||
.tag-suggestion:focus {
|
||||
background: var(--pico-primary-focus);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Fave detail actions */
|
||||
.fave-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.fave-actions a,
|
||||
.fave-actions button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Current image preview in edit form */
|
||||
.current-image {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.current-image img {
|
||||
border-radius: var(--pico-border-radius);
|
||||
}
|
||||
|
||||
/* Respect reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
71
web/static/js/app.js
Normal file
71
web/static/js/app.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Favoritter — minimal JavaScript for HTMX configuration and form helpers.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Auto-include the CSRF token in all HTMX requests.
|
||||
document.body.addEventListener("htmx:configRequest", function (event) {
|
||||
var csrfCookie = getCookie("csrf_token");
|
||||
if (csrfCookie) {
|
||||
event.detail.headers["X-CSRF-Token"] = csrfCookie;
|
||||
}
|
||||
|
||||
// For the tag search input, send the current value of the last
|
||||
// comma-separated segment as the 'q' parameter.
|
||||
var elt = event.detail.elt;
|
||||
if (elt && elt.id === "tags") {
|
||||
var val = elt.value;
|
||||
var parts = val.split(",");
|
||||
var lastPart = parts[parts.length - 1].trim();
|
||||
event.detail.parameters["q"] = lastPart;
|
||||
}
|
||||
});
|
||||
|
||||
// Focus management after HTMX content swaps for accessibility.
|
||||
document.body.addEventListener("htmx:afterSwap", function (event) {
|
||||
var target = event.detail.target;
|
||||
if (target) {
|
||||
var autoFocus = target.querySelector("[autofocus]");
|
||||
if (autoFocus) {
|
||||
autoFocus.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// After a successful HTMX DELETE, redirect if the element has a data-redirect attribute.
|
||||
document.body.addEventListener("htmx:afterRequest", function (event) {
|
||||
if (event.detail.successful && event.detail.verb === "delete") {
|
||||
var redirect = event.detail.elt.getAttribute("data-redirect");
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Tag autocomplete: add a selected tag to the tag input.
|
||||
window.addTag = function (element, tagName) {
|
||||
var input = document.getElementById("tags");
|
||||
if (!input) return;
|
||||
|
||||
var parts = input.value.split(",").map(function (s) { return s.trim(); });
|
||||
// Replace the last (incomplete) segment with the selected tag.
|
||||
parts[parts.length - 1] = tagName;
|
||||
// Add a trailing separator so the user can keep typing.
|
||||
input.value = parts.join(", ") + ", ";
|
||||
input.focus();
|
||||
|
||||
// Clear suggestions by removing all child elements.
|
||||
var suggestions = document.getElementById("tag-suggestions");
|
||||
if (suggestions) {
|
||||
while (suggestions.firstChild) {
|
||||
suggestions.removeChild(suggestions.firstChild);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function getCookie(name) {
|
||||
var match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"));
|
||||
return match ? match[2] : null;
|
||||
}
|
||||
})();
|
||||
1
web/static/vendor/htmx.min.js
vendored
Normal file
1
web/static/vendor/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
web/static/vendor/pico.min.css
vendored
Normal file
4
web/static/vendor/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
62
web/templates/layouts/base.html
Normal file
62
web/templates/layouts/base.html
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{{define "base.html"}}<!DOCTYPE html>
|
||||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title}} — {{end}}{{.SiteName}}</title>
|
||||
<link rel="stylesheet" href="{{basePath}}/static/vendor/pico.min.css">
|
||||
<link rel="stylesheet" href="{{basePath}}/static/css/style.css">
|
||||
{{block "head" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Hopp til hovedinnhold</a>
|
||||
|
||||
<header class="container">
|
||||
<nav aria-label="Hovednavigasjon">
|
||||
<ul>
|
||||
<li><a href="{{basePath}}/" class="site-title"><strong>{{.SiteName}}</strong></a></li>
|
||||
</ul>
|
||||
<ul>
|
||||
{{if .User}}
|
||||
<li><a href="{{basePath}}/faves">Mine favoritter</a></li>
|
||||
<li><a href="{{basePath}}/u/{{.User.Username}}">Profil</a></li>
|
||||
{{if .User.IsAdmin}}
|
||||
<li><a href="{{basePath}}/admin">Admin</a></li>
|
||||
{{end}}
|
||||
<li>
|
||||
<form method="POST" action="{{basePath}}/logout" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="outline secondary nav-button">Logg ut</button>
|
||||
</form>
|
||||
</li>
|
||||
{{else}}
|
||||
<li><a href="{{basePath}}/login">Logg inn</a></li>
|
||||
<li><a href="{{basePath}}/signup">Registrer</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="container">
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-{{.FlashType}}" role="alert">
|
||||
{{.Flash}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="container">
|
||||
<hr>
|
||||
<p>
|
||||
<small>
|
||||
Drevet av <a href="https://kode.naiv.no/olemd/favoritter" target="_blank" rel="noopener">Favoritter</a>
|
||||
— fri programvare under <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>
|
||||
</small>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script src="{{basePath}}/static/vendor/htmx.min.js"></script>
|
||||
<script src="{{basePath}}/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
73
web/templates/pages/fave_detail.html
Normal file
73
web/templates/pages/fave_detail.html
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{{define "head"}}
|
||||
{{with .Data}}{{with .Fave}}
|
||||
{{if eq .Privacy "public"}}
|
||||
<meta property="og:title" content="{{truncate 70 .Description}}">
|
||||
<meta property="og:description" content="En favoritt av {{.DisplayName}} på {{$.SiteName}}">
|
||||
<meta property="og:type" content="article">
|
||||
{{if $.ExternalURL}}
|
||||
<meta property="og:url" content="{{$.ExternalURL}}/faves/{{.ID}}">
|
||||
{{if .ImagePath}}
|
||||
<meta property="og:image" content="{{$.ExternalURL}}/uploads/{{.ImagePath}}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
{{else}}
|
||||
<meta name="twitter:card" content="summary">
|
||||
{{end}}
|
||||
{{end}}
|
||||
<meta property="og:site_name" content="{{$.SiteName}}">
|
||||
{{range .Tags}}
|
||||
<meta property="article:tag" content="{{.Name}}">
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<article>
|
||||
{{with .Fave}}
|
||||
{{if .ImagePath}}
|
||||
<img src="{{basePath}}/uploads/{{.ImagePath}}"
|
||||
alt="Bilde for: {{.Description}}">
|
||||
{{end}}
|
||||
|
||||
<header>
|
||||
<h1>{{.Description}}</h1>
|
||||
<p>
|
||||
Av <a href="{{basePath}}/u/{{.Username}}">{{.DisplayName}}</a>
|
||||
{{if eq .Privacy "private"}}
|
||||
— <small class="badge-private" aria-label="Privat">Privat</small>
|
||||
{{end}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{{if .URL}}
|
||||
<p><a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a></p>
|
||||
{{end}}
|
||||
|
||||
{{if .Tags}}
|
||||
<p>
|
||||
{{range .Tags}}
|
||||
<a href="{{basePath}}/tags/{{.Name}}" class="tag-chip">{{.Name}}</a>
|
||||
{{end}}
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<footer>
|
||||
<small>Lagt til {{.CreatedAt.Format "02.01.2006"}}</small>
|
||||
{{if $.IsOwner}}
|
||||
<nav class="fave-actions">
|
||||
<a href="{{basePath}}/faves/{{.ID}}/edit" role="button" class="outline">Rediger</a>
|
||||
<button
|
||||
hx-delete="{{basePath}}/faves/{{.ID}}"
|
||||
hx-confirm="Er du sikker på at du vil slette denne favoritten?"
|
||||
hx-headers='{"X-CSRF-Token": "{{$.CSRFToken}}"}'
|
||||
class="outline secondary"
|
||||
data-redirect="{{basePath}}/faves"
|
||||
>Slett</button>
|
||||
</nav>
|
||||
{{end}}
|
||||
</footer>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
{{end}}
|
||||
92
web/templates/pages/fave_form.html
Normal file
92
web/templates/pages/fave_form.html
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{{define "content"}}
|
||||
<article>
|
||||
{{with .Data}}
|
||||
{{if .IsNew}}
|
||||
<h1>Ny favoritt</h1>
|
||||
{{else}}
|
||||
<h1>Rediger favoritt</h1>
|
||||
{{end}}
|
||||
|
||||
<form method="POST"
|
||||
enctype="multipart/form-data"
|
||||
{{if .IsNew}}
|
||||
action="{{basePath}}/faves"
|
||||
{{else}}
|
||||
action="{{basePath}}/faves/{{.Fave.ID}}"
|
||||
{{end}}>
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
|
||||
<label for="description">
|
||||
Beskrivelse *
|
||||
<input type="text" id="description" name="description"
|
||||
value="{{.Description}}" required autofocus
|
||||
placeholder="F.eks. «Blade Runner 2049» eller «Rød tulipan»">
|
||||
</label>
|
||||
|
||||
<label for="url">
|
||||
Lenke (valgfri)
|
||||
<input type="url" id="url" name="url"
|
||||
value="{{.URL}}"
|
||||
placeholder="https://...">
|
||||
</label>
|
||||
|
||||
<label for="image">
|
||||
Bilde (valgfri)
|
||||
<input type="file" id="image" name="image"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp">
|
||||
</label>
|
||||
{{if not .IsNew}}
|
||||
{{if .Fave.ImagePath}}
|
||||
<div class="current-image">
|
||||
<img src="{{basePath}}/uploads/{{.Fave.ImagePath}}"
|
||||
alt="Nåværende bilde"
|
||||
style="max-width: 200px; max-height: 200px;">
|
||||
<label>
|
||||
<input type="checkbox" name="remove_image" value="1">
|
||||
Fjern bilde
|
||||
</label>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<label for="tags">
|
||||
Merkelapper (kommaseparert)
|
||||
<input type="text" id="tags" name="tags"
|
||||
value="{{.Tags}}"
|
||||
placeholder="film, sci-fi, favoritt"
|
||||
autocomplete="off"
|
||||
hx-get="{{basePath}}/tags/search"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#tag-suggestions"
|
||||
hx-params="*"
|
||||
hx-vals='{"q": ""}'
|
||||
aria-describedby="tags-help"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="tag-suggestions">
|
||||
<small id="tags-help">Skriv for å søke i eksisterende merkelapper. Maks {{maxTags}} stk.</small>
|
||||
</label>
|
||||
<ul id="tag-suggestions" role="listbox" class="tag-suggestions" aria-label="Merkelappforslag"></ul>
|
||||
|
||||
<fieldset>
|
||||
<legend>Synlighet</legend>
|
||||
<label>
|
||||
<input type="radio" name="privacy" value="public"
|
||||
{{if eq .Privacy "public"}}checked{{end}}
|
||||
{{if .IsNew}}{{if eq .DefaultPrivacy "public"}}checked{{end}}{{end}}>
|
||||
Offentlig
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="privacy" value="private"
|
||||
{{if eq .Privacy "private"}}checked{{end}}
|
||||
{{if .IsNew}}{{if eq .DefaultPrivacy "private"}}checked{{end}}{{end}}>
|
||||
Privat
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">
|
||||
{{if .IsNew}}Legg til{{else}}Lagre{{end}}
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
56
web/templates/pages/fave_list.html
Normal file
56
web/templates/pages/fave_list.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{{define "content"}}
|
||||
<header>
|
||||
<hgroup>
|
||||
<h1>Mine favoritter</h1>
|
||||
<p>{{with .Data}}{{.Total}} favoritter totalt{{end}}</p>
|
||||
</hgroup>
|
||||
<a href="{{basePath}}/faves/new" role="button">+ Ny favoritt</a>
|
||||
</header>
|
||||
|
||||
{{with .Data}}
|
||||
{{if .Faves}}
|
||||
<div class="fave-grid" role="list">
|
||||
{{range .Faves}}
|
||||
<article class="fave-card" role="listitem">
|
||||
{{if .ImagePath}}
|
||||
<img src="{{basePath}}/uploads/{{.ImagePath}}"
|
||||
alt="Bilde for: {{.Description}}"
|
||||
loading="lazy">
|
||||
{{end}}
|
||||
<header>
|
||||
<a href="{{basePath}}/faves/{{.ID}}">
|
||||
<strong>{{.Description}}</strong>
|
||||
</a>
|
||||
{{if eq .Privacy "private"}}
|
||||
<small class="badge-private" aria-label="Privat">Privat</small>
|
||||
{{end}}
|
||||
</header>
|
||||
{{if .Tags}}
|
||||
<footer>
|
||||
{{range .Tags}}
|
||||
<a href="{{basePath}}/tags/{{.Name}}" class="tag-chip">{{.Name}}</a>
|
||||
{{end}}
|
||||
</footer>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .TotalPages 1}}
|
||||
<nav aria-label="Sidenavigasjon">
|
||||
<ul>
|
||||
{{if gt .Page 1}}
|
||||
<li><a href="{{basePath}}/faves?page={{subtract .Page 1}}">← Forrige</a></li>
|
||||
{{end}}
|
||||
<li>Side {{.Page}} av {{.TotalPages}}</li>
|
||||
{{if lt .Page .TotalPages}}
|
||||
<li><a href="{{basePath}}/faves?page={{add .Page 1}}">Neste →</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p>Du har ingen favoritter ennå. <a href="{{basePath}}/faves/new">Legg til din første!</a></p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
13
web/templates/pages/home.html
Normal file
13
web/templates/pages/home.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{{define "content"}}
|
||||
<hgroup>
|
||||
<h1>Velkommen til {{.SiteName}}</h1>
|
||||
<p>Del dine favoritter med verden — eller behold dem for deg selv.</p>
|
||||
</hgroup>
|
||||
|
||||
{{if not .User}}
|
||||
<div class="grid">
|
||||
<a href="{{basePath}}/login" role="button">Logg inn</a>
|
||||
<a href="{{basePath}}/signup" role="button" class="outline">Registrer deg</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
24
web/templates/pages/login.html
Normal file
24
web/templates/pages/login.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{{define "head"}}
|
||||
<meta name="robots" content="noindex">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<article>
|
||||
<h1>Logg inn</h1>
|
||||
<form method="POST" action="{{basePath}}/login">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label for="username">
|
||||
Brukernavn
|
||||
<input type="text" id="username" name="username" required autofocus
|
||||
autocomplete="username" autocapitalize="none">
|
||||
</label>
|
||||
<label for="password">
|
||||
Passord
|
||||
<input type="password" id="password" name="password" required
|
||||
autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit">Logg inn</button>
|
||||
</form>
|
||||
<p><a href="{{basePath}}/signup">Har du ikke konto? Registrer deg</a></p>
|
||||
</article>
|
||||
{{end}}
|
||||
24
web/templates/pages/reset_password.html
Normal file
24
web/templates/pages/reset_password.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{{define "head"}}
|
||||
<meta name="robots" content="noindex">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<article>
|
||||
<h1>Endre passord</h1>
|
||||
<p>Du må velge et nytt passord før du kan fortsette.</p>
|
||||
<form method="POST" action="{{basePath}}/reset-password">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label for="password">
|
||||
Nytt passord
|
||||
<input type="password" id="password" name="password" required autofocus
|
||||
autocomplete="new-password" minlength="8">
|
||||
</label>
|
||||
<label for="password_confirm">
|
||||
Bekreft nytt passord
|
||||
<input type="password" id="password_confirm" name="password_confirm" required
|
||||
autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Lagre nytt passord</button>
|
||||
</form>
|
||||
</article>
|
||||
{{end}}
|
||||
62
web/templates/pages/signup.html
Normal file
62
web/templates/pages/signup.html
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{{define "head"}}
|
||||
<meta name="robots" content="noindex">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<article>
|
||||
{{with .Data}}
|
||||
{{if eq .Mode "closed"}}
|
||||
<h1>Registrering stengt</h1>
|
||||
<p>Nye registreringer er for øyeblikket ikke tilgjengelig. Kontakt administrator for tilgang.</p>
|
||||
{{else if eq .Mode "requests"}}
|
||||
<h1>Be om tilgang</h1>
|
||||
<p>Nye brukere må godkjennes av en administrator.</p>
|
||||
<form method="POST" action="{{basePath}}/signup">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<label for="username">
|
||||
Brukernavn
|
||||
<input type="text" id="username" name="username" required autofocus
|
||||
autocomplete="username" autocapitalize="none"
|
||||
pattern="[a-zA-Z0-9_-]+" title="Bare bokstaver, tall, bindestrek og understrek"
|
||||
minlength="2" maxlength="30">
|
||||
</label>
|
||||
<label for="password">
|
||||
Passord
|
||||
<input type="password" id="password" name="password" required
|
||||
autocomplete="new-password" minlength="8">
|
||||
</label>
|
||||
<label for="password_confirm">
|
||||
Bekreft passord
|
||||
<input type="password" id="password_confirm" name="password_confirm" required
|
||||
autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Send forespørsel</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<h1>Registrer deg</h1>
|
||||
<form method="POST" action="{{basePath}}/signup">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<label for="username">
|
||||
Brukernavn
|
||||
<input type="text" id="username" name="username" required autofocus
|
||||
autocomplete="username" autocapitalize="none"
|
||||
pattern="[a-zA-Z0-9_-]+" title="Bare bokstaver, tall, bindestrek og understrek"
|
||||
minlength="2" maxlength="30">
|
||||
</label>
|
||||
<label for="password">
|
||||
Passord
|
||||
<input type="password" id="password" name="password" required
|
||||
autocomplete="new-password" minlength="8">
|
||||
</label>
|
||||
<label for="password_confirm">
|
||||
Bekreft passord
|
||||
<input type="password" id="password_confirm" name="password_confirm" required
|
||||
autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Registrer</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<p><a href="{{basePath}}/login">Har du allerede konto? Logg inn</a></p>
|
||||
</article>
|
||||
{{end}}
|
||||
51
web/templates/pages/tag_browse.html
Normal file
51
web/templates/pages/tag_browse.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<hgroup>
|
||||
<h1>Merkelapp: {{.TagName}}</h1>
|
||||
<p>{{.Total}} offentlige favoritter</p>
|
||||
</hgroup>
|
||||
|
||||
{{if .Faves}}
|
||||
<div class="fave-grid" role="list">
|
||||
{{range .Faves}}
|
||||
<article class="fave-card" role="listitem">
|
||||
{{if .ImagePath}}
|
||||
<img src="{{basePath}}/uploads/{{.ImagePath}}"
|
||||
alt="Bilde for: {{.Description}}"
|
||||
loading="lazy">
|
||||
{{end}}
|
||||
<header>
|
||||
<a href="{{basePath}}/faves/{{.ID}}">
|
||||
<strong>{{.Description}}</strong>
|
||||
</a>
|
||||
<small>av <a href="{{basePath}}/u/{{.Username}}">{{.DisplayName}}</a></small>
|
||||
</header>
|
||||
{{if .Tags}}
|
||||
<footer>
|
||||
{{range .Tags}}
|
||||
<a href="{{basePath}}/tags/{{.Name}}" class="tag-chip">{{.Name}}</a>
|
||||
{{end}}
|
||||
</footer>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .TotalPages 1}}
|
||||
<nav aria-label="Sidenavigasjon">
|
||||
<ul>
|
||||
{{if gt .Page 1}}
|
||||
<li><a href="{{basePath}}/tags/{{.TagName}}?page={{subtract .Page 1}}">← Forrige</a></li>
|
||||
{{end}}
|
||||
<li>Side {{.Page}} av {{.TotalPages}}</li>
|
||||
{{if lt .Page .TotalPages}}
|
||||
<li><a href="{{basePath}}/tags/{{.TagName}}?page={{add .Page 1}}">Neste →</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p>Ingen offentlige favoritter med denne merkelappen.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
6
web/templates/partials/tag_suggestions.html
Normal file
6
web/templates/partials/tag_suggestions.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{{range .}}<li role="option"
|
||||
class="tag-suggestion"
|
||||
onclick="addTag(this, '{{.Name}}')"
|
||||
onkeydown="if(event.key==='Enter'){addTag(this, '{{.Name}}')}"
|
||||
tabindex="0">{{.Name}}</li>
|
||||
{{end}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue