ui: refine auth and account screens

This commit is contained in:
2026-04-11 18:06:04 +02:00
parent 6b83f6bde6
commit 76e9309e0d
10 changed files with 438 additions and 83 deletions

View File

@@ -6,7 +6,7 @@ import "fmt"
import "strings" import "strings"
templ AnimeDetails(anime jikan.Anime, currentStatus string) { templ AnimeDetails(anime jikan.Anime, currentStatus string) {
@Layout("mal - " + anime.DisplayTitle()) { @Layout("mal - " + anime.DisplayTitle(), true) {
<div class="anime-page"> <div class="anime-page">
<div class="anime-main"> <div class="anime-main">
<div class="anime-hero anime-surface"> <div class="anime-hero anime-surface">

View File

@@ -1,37 +1,46 @@
package templates package templates
templ Login() { templ Login(formError string, username string) {
@Layout("Login") { @Layout("Login", false) {
<div class="auth-shell">
<div class="login-container"> <div class="login-container">
<h2>Sign in</h2> <h2>Sign in</h2>
<p class="login-subtitle">Enter your credentials to continue.</p> <p class="login-subtitle">Enter your credentials to continue.</p>
<form action="/login" method="POST" class="login-form"> <form action="/login" method="POST" class="login-form">
<div class="form-group"> <div class="form-group">
<label for="username">Username / Email</label> <label for="username">Username / Email</label>
<input type="text" id="username" name="username" required placeholder="you@example.com"/> <input type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required placeholder="Your password"/> <input type="password" id="password" name="password" required placeholder="Your password"/>
</div> </div>
<button type="submit" class="login-button">Sign in</button> <button type="submit" class="login-button">Sign in</button>
if formError != "" {
<p class="auth-form-error" role="alert" aria-live="polite">{ formError }</p>
}
</form> </form>
<p class="auth-switch-row"> <p class="auth-switch-row">
Don't have an account? <a href="/register">Register</a> Don't have an account? <a href="/register">Register</a>
</p> </p>
<p class="auth-switch-row">
Lost access? <a href="/recover">Recover account</a>
</p>
</div>
</div> </div>
} }
} }
templ Register() { templ Register(formError string, username string) {
@Layout("Register") { @Layout("Register", false) {
<div class="auth-shell">
<div class="login-container"> <div class="login-container">
<h2>Register</h2> <h2>Register</h2>
<p class="login-subtitle">Create a new account to track anime.</p> <p class="login-subtitle">Create a new account to track anime.</p>
<form action="/register" method="POST" class="login-form"> <form action="/register" method="POST" class="login-form">
<div class="form-group"> <div class="form-group">
<label for="username">Username / Email</label> <label for="username">Username / Email</label>
<input type="text" id="username" name="username" required placeholder="you@example.com"/> <input type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password">Password</label>
@@ -41,10 +50,163 @@ templ Register() {
Password must be at least 12 characters and include an uppercase letter, lowercase letter, number, and special character. Password must be at least 12 characters and include an uppercase letter, lowercase letter, number, and special character.
</p> </p>
<button type="submit" class="login-button">Create account</button> <button type="submit" class="login-button">Create account</button>
if formError != "" {
<p class="auth-form-error" role="alert" aria-live="polite">{ formError }</p>
}
</form> </form>
<p class="auth-switch-row"> <p class="auth-switch-row">
Already have an account? <a href="/login">Sign in</a> Already have an account? <a href="/login">Sign in</a>
</p> </p>
</div> </div>
</div>
}
}
templ RegistrationRecoveryKey(recoveryKey string) {
@Layout("Save recovery key", false) {
<div class="auth-shell">
<div class="login-container">
<h2>Save your recovery key</h2>
<p class="login-subtitle">Store this key somewhere safe. It is shown only once.</p>
<div class="recovery-key-row">
<p class="recovery-key-box" id="registration-recovery-key">{ recoveryKey }</p>
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('registration-recovery-key', 'registration-copy-feedback')">Copy key</button>
</div>
<p class="auth-password-note recovery-copy-feedback" id="registration-copy-feedback" aria-live="polite"></p>
<p class="auth-password-note">If you lose your password, this key is the only way to recover your account without email.</p>
<p class="auth-switch-row">
<a href="/" class="auth-primary-link">I saved it, continue</a>
</p>
</div>
</div>
}
}
templ Recover(formError string, username string, recoveryKey string) {
@Layout("Recover account", false) {
<div class="auth-shell">
<div class="login-container">
<h2>Recover account</h2>
<p class="login-subtitle">Enter your username, recovery key, and a new password.</p>
<form action="/recover" method="POST" class="login-form">
<div class="form-group">
<label for="username">Username / Email</label>
<input type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
</div>
<div class="form-group">
<label for="recovery_key">Recovery key</label>
<input type="text" id="recovery_key" name="recovery_key" required value={ recoveryKey }/>
</div>
<div class="form-group">
<label for="new_password">New password</label>
<input type="password" id="new_password" name="new_password" required placeholder="Minimum 12 chars"/>
</div>
<button type="submit" class="login-button">Reset password</button>
if formError != "" {
<p class="auth-form-error" role="alert" aria-live="polite">{ formError }</p>
}
</form>
<p class="auth-switch-row">
Remembered your password? <a href="/login">Sign in</a>
</p>
</div>
</div>
}
}
templ RecoveryComplete(newRecoveryKey string) {
@Layout("Recovery complete", false) {
<div class="auth-shell">
<div class="login-container">
<h2>Account recovered</h2>
<p class="login-subtitle">Your password was reset and your recovery key was rotated.</p>
<div class="recovery-key-row">
<p class="recovery-key-box" id="recovery-complete-key">{ newRecoveryKey }</p>
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('recovery-complete-key', 'recovery-complete-feedback')">Copy key</button>
</div>
<p class="auth-password-note recovery-copy-feedback" id="recovery-complete-feedback" aria-live="polite"></p>
<p class="auth-password-note">Replace your old recovery key with this one.</p>
<p class="auth-switch-row">
<a href="/login" class="auth-primary-link">Go to login</a>
</p>
</div>
</div>
}
}
templ Account(username string, createdAt string, passwordError string, passwordSuccess string, recoveryError string, recoverySuccess string, recoveryKey string) {
@Layout("Account", true) {
<div class="account-page">
<section class="account-card">
<h2>Account</h2>
<div class="account-meta">
<div class="account-meta-row">
<span class="account-meta-label">Email / Username</span>
<span class="account-meta-value">{ username }</span>
</div>
<div class="account-meta-row">
<span class="account-meta-label">Created</span>
<span class="account-meta-value">{ createdAt }</span>
</div>
</div>
</section>
<section class="account-card">
<h3>Change password</h3>
<form action="/account/password" method="POST" class="account-form" onsubmit="return confirmDangerAction('Change your password now?')">
<div class="form-group">
<label for="current_password">Current password</label>
<input type="password" id="current_password" name="current_password" required/>
</div>
<div class="form-group">
<label for="new_password">New password</label>
<input type="password" id="new_password" name="new_password" required placeholder="Minimum 12 chars"/>
</div>
<div class="form-group">
<label for="confirm_new_password">Confirm new password</label>
<input type="password" id="confirm_new_password" name="confirm_new_password" required/>
</div>
<button type="submit" class="account-submit-btn">Update password</button>
if passwordError != "" {
<p class="auth-form-error" role="alert" aria-live="polite">{ passwordError }</p>
}
if passwordSuccess != "" {
<p class="account-success" role="status" aria-live="polite">{ passwordSuccess }</p>
}
</form>
</section>
<section class="account-card">
<h3>Recovery key</h3>
<p class="auth-password-note">To view a new recovery key, confirm your current password. This rotates your old key.</p>
<form action="/account/recovery-key" method="POST" class="account-form" onsubmit="return confirmDangerAction('Rotate recovery key now? Your old key will stop working.')">
<div class="form-group">
<label for="recovery_password">Current password</label>
<input type="password" id="recovery_password" name="password" required/>
</div>
<button type="submit" class="account-submit-btn">Show new recovery key</button>
if recoveryError != "" {
<p class="auth-form-error" role="alert" aria-live="polite">{ recoveryError }</p>
}
if recoverySuccess != "" {
<p class="account-success" role="status" aria-live="polite">{ recoverySuccess }</p>
}
</form>
if recoveryKey != "" {
<div class="recovery-key-row">
<p class="recovery-key-box" id="account-recovery-key">{ recoveryKey }</p>
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('account-recovery-key', 'account-copy-feedback')">Copy key</button>
</div>
<p class="auth-password-note recovery-copy-feedback" id="account-copy-feedback" aria-live="polite"></p>
}
</section>
<section class="account-card">
<h3>Danger zone</h3>
<form action="/logout" method="POST" class="account-form-inline" onsubmit="return confirmDangerAction('Log out of this account now?')">
<button type="submit" class="account-logout-btn">Log out</button>
</form>
</section>
</div>
} }
} }

View File

@@ -5,7 +5,7 @@ import "mal/internal/shared/ui"
import "fmt" import "fmt"
templ Catalog() { templ Catalog() {
@Layout("mal - catalog") { @Layout("mal - catalog", true) {
<div class="catalog-grid" id="catalog-content"> <div class="catalog-grid" id="catalog-content">
<div class="grid-full-width" hx-get="/api/catalog?page=1" hx-trigger="load" hx-swap="outerHTML"> <div class="grid-full-width" hx-get="/api/catalog?page=1" hx-trigger="load" hx-swap="outerHTML">
@ui.LoadingIndicator("Loading catalog") @ui.LoadingIndicator("Loading catalog")

View File

@@ -5,7 +5,7 @@ import "mal/internal/shared/ui"
import "fmt" import "fmt"
templ Discover() { templ Discover() {
@Layout("mal - discover") { @Layout("mal - discover", true) {
<div class="discover-container"> <div class="discover-container">
<div class="discover-header"> <div class="discover-header">
<h1>Discover</h1> <h1>Discover</h1>

View File

@@ -8,7 +8,7 @@ import (
) )
templ Search(q string) { templ Search(q string) {
@Layout("mal - search") { @Layout("mal - search", true) {
if q != "" { if q != "" {
<div id="loading" class="htmx-indicator"> <div id="loading" class="htmx-indicator">
@ui.LoadingIndicator("Searching...") @ui.LoadingIndicator("Searching...")

View File

@@ -2,7 +2,7 @@ package templates
import "mal/internal/shared/ui/icons" import "mal/internal/shared/ui/icons"
templ Layout(title string) { templ Layout(title string, showHeader bool) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -15,8 +15,10 @@ templ Layout(title string) {
<script src="/static/js/discover.js" defer></script> <script src="/static/js/discover.js" defer></script>
<script src="/static/js/anime.js" defer></script> <script src="/static/js/anime.js" defer></script>
<script src="/static/js/timezone.js" defer></script> <script src="/static/js/timezone.js" defer></script>
<script src="/static/js/auth.js" defer></script>
</head> </head>
<body> <body>
if showHeader {
<header> <header>
<div class="header-top"> <div class="header-top">
<div class="header-left"> <div class="header-left">
@@ -28,6 +30,7 @@ templ Layout(title string) {
<a href="/discover">Discover</a> <a href="/discover">Discover</a>
<a href="/notifications">Notifications</a> <a href="/notifications">Notifications</a>
<a href="/watchlist">Watchlist</a> <a href="/watchlist">Watchlist</a>
<a href="/account">Account</a>
</div> </div>
</div> </div>
<div class="header-search-wrapper"> <div class="header-search-wrapper">
@@ -38,7 +41,8 @@ templ Layout(title string) {
</div> </div>
</div> </div>
</header> </header>
<main> }
<main class={ "main-content", templ.KV("auth-main", !showHeader) }>
{ children... } { children... }
</main> </main>
<script src="/static/js/search.js"></script> <script src="/static/js/search.js"></script>

View File

@@ -11,7 +11,7 @@ type WatchingAnimeWithDetails struct {
} }
templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) { templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) {
@Layout("mal - notifications") { @Layout("mal - notifications", true) {
<div class="notifications-page"> <div class="notifications-page">
<h1>Notifications</h1> <h1>Notifications</h1>
<div class="status-tabs"> <div class="status-tabs">

View File

@@ -7,7 +7,7 @@ import (
) )
templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) { templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) {
@Layout("My Watchlist") { @Layout("My Watchlist", true) {
<div class="watchlist-header"> <div class="watchlist-header">
<div class="watchlist-heading"> <div class="watchlist-heading">
<h2>Watchlist</h2> <h2>Watchlist</h2>

View File

@@ -256,6 +256,14 @@ main {
padding: var(--space-5) var(--space-4) var(--space-8); padding: var(--space-5) var(--space-4) var(--space-8);
} }
.auth-main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--space-4);
}
.is-hidden { .is-hidden {
display: none !important; display: none !important;
} }
@@ -427,26 +435,31 @@ main {
font-size: 0.9rem; font-size: 0.9rem;
} }
.auth-shell {
width: min(560px, 100%);
}
.login-container { .login-container {
width: min(440px, 100%); width: min(560px, 100%);
margin: 7vh auto; margin: 0 auto;
padding: var(--space-5); padding: var(--space-6);
background: var(--panel); background: var(--panel);
} }
.login-container h2 { .login-container h2 {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.4rem;
} }
.login-subtitle { .login-subtitle {
margin: var(--space-2) 0 var(--space-4); margin: var(--space-3) 0 var(--space-5);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.95rem;
} }
.login-form { .login-form {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-4);
} }
.form-group { .form-group {
@@ -456,20 +469,25 @@ main {
.form-group label { .form-group label {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.82rem; font-size: 0.9rem;
} }
.form-group input { .form-group input {
height: 34px; height: 40px;
border: none; border: 1px solid transparent;
background: var(--surface-input); background: var(--surface-search);
color: var(--text); color: var(--text);
padding: 0 var(--space-2); padding: 0 var(--space-3);
font: inherit; font: inherit;
transition: border-color 120ms ease, background-color 120ms ease;
} }
.form-group input:focus { .form-group input:focus {
background: var(--surface-input-focus); border-color: var(--surface-search-focus-border);
}
.form-group input:focus-visible {
outline: none;
} }
.auth-password-note { .auth-password-note {
@@ -479,12 +497,164 @@ main {
line-height: 1.4; line-height: 1.4;
} }
.auth-form-error {
margin: var(--space-2) 0 0;
color: var(--danger);
font-size: 0.82rem;
}
.recovery-key-box {
margin: 0;
padding: var(--space-3);
background: var(--surface-search);
border: 1px solid var(--surface-search-focus-border);
color: var(--text);
word-break: break-all;
font-size: 0.86rem;
}
.recovery-key-row {
display: grid;
gap: var(--space-2);
}
.recovery-copy-btn {
justify-self: start;
min-width: 0;
padding: 0.42rem 0.72rem;
border: 1px solid var(--surface-search-focus-border);
background: var(--surface-search);
color: var(--text);
border-radius: 0;
font-size: 0.8rem;
line-height: 1;
cursor: pointer;
}
.recovery-copy-btn:hover {
background: var(--surface-input-focus);
color: var(--text);
text-decoration: none;
}
.recovery-copy-btn:focus-visible {
outline: none;
border-color: var(--accent);
}
.recovery-copy-feedback {
margin-top: var(--space-2);
min-height: 1.1rem;
}
.auth-primary-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0 var(--space-4);
border: 1px solid var(--surface-search-focus-border);
background: var(--surface-search);
color: var(--text);
text-decoration: none;
}
.auth-primary-link:hover {
background: var(--panel-soft);
color: var(--text);
text-decoration: none;
}
.auth-primary-link:focus-visible {
outline: none;
border-color: var(--accent);
}
.account-page {
width: min(720px, 100%);
margin: 0 auto;
display: grid;
gap: var(--space-4);
}
.account-card {
background: var(--panel);
padding: var(--space-5);
display: grid;
gap: var(--space-3);
}
.account-card h2,
.account-card h3 {
margin: 0;
}
.account-meta {
display: grid;
gap: var(--space-2);
}
.account-meta-row {
display: grid;
gap: var(--space-1);
}
.account-meta-label {
color: var(--text-faint);
font-size: 0.78rem;
}
.account-meta-value {
color: var(--text);
font-size: 0.95rem;
}
.account-form {
display: grid;
gap: var(--space-3);
}
.account-form .form-group input {
width: 100%;
}
.account-submit-btn,
.account-logout-btn {
height: 40px;
border: 1px solid var(--surface-search-focus-border);
background: var(--surface-search);
color: var(--text);
padding: 0 var(--space-4);
cursor: pointer;
}
.account-submit-btn:hover,
.account-logout-btn:hover {
background: var(--panel-soft);
}
.account-submit-btn:focus-visible,
.account-logout-btn:focus-visible {
outline: none;
border-color: var(--accent);
}
.account-success {
margin: 0;
color: var(--accent);
font-size: 0.82rem;
}
.account-form-inline {
display: inline-flex;
}
.login-button { .login-button {
height: 34px; height: 40px;
border: none; border: none;
background: var(--accent); background: var(--accent);
color: var(--text-on-accent); color: var(--text-on-accent);
font-size: 0.84rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
@@ -494,10 +664,10 @@ main {
} }
.auth-switch-row { .auth-switch-row {
margin: var(--space-4) 0 0; margin: var(--space-5) 0 0;
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.82rem; font-size: 0.9rem;
} }
.auth-switch-row a { .auth-switch-row a {

19
static/js/auth.js Normal file
View File

@@ -0,0 +1,19 @@
function copyRecoveryKey(keyElementId, feedbackElementId) {
var keyElement = document.getElementById(keyElementId)
var feedbackElement = document.getElementById(feedbackElementId)
if (!keyElement || !feedbackElement) {
return
}
var key = keyElement.textContent || ''
navigator.clipboard.writeText(key).then(function () {
feedbackElement.textContent = 'Recovery key copied.'
}).catch(function () {
feedbackElement.textContent = 'Copy failed. Select and copy manually.'
})
}
function confirmDangerAction(message) {
return window.confirm(message)
}