To gitea and beyond, let's go(-yco)
This commit is contained in:
63
internal/templates/base.gohtml
Normal file
63
internal/templates/base.gohtml
Normal file
@@ -0,0 +1,63 @@
|
||||
{{define "layout"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{if .Title}}{{.Title}}{{end}}</title>
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico" />
|
||||
<link rel="stylesheet" href="/static/css/main.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container header-bar">
|
||||
<a class="brand" href="/">{{.SiteTitle}}</a>
|
||||
<div class="header-search">
|
||||
<form class="search-form" action="/search" method="get">
|
||||
<input type="search" name="q" placeholder="Search posts..." value="{{.SearchQuery}}" class="search-input" />
|
||||
<button type="submit" class="search-button" aria-label="Search">
|
||||
<span aria-hidden="true">🔍</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<nav class="site-nav">
|
||||
<a href="/">Home</a>
|
||||
{{if .User}}
|
||||
<a href="/posts/new">Share</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<form class="nav-action" action="/logout" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a href="/register">Create account</a>
|
||||
<a href="/login">Sign in</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container content-stack">
|
||||
{{if .Errors}}
|
||||
<div class="alert alert-error">
|
||||
{{range .Errors}}
|
||||
<p>{{.}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Flash}}
|
||||
<div class="alert alert-success">{{.Flash}}</div>
|
||||
{{end}}
|
||||
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<small>Powered with ❤️ by <a href="https://github.com/sandrocazzaniga/goyco">Goyco</a></small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
56
internal/templates/confirm_delete.gohtml
Normal file
56
internal/templates/confirm_delete.gohtml
Normal file
@@ -0,0 +1,56 @@
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<h1 class="auth-card__message">Account deletion</h1>
|
||||
{{if .Flash}}
|
||||
<p class="auth-card__message">{{.Flash}}</p>
|
||||
<div class="auth-card__actions">
|
||||
<a class="button" href="/login">Go to sign in</a>
|
||||
<a class="button button-ghost muted" href="/">Back home</a>
|
||||
</div>
|
||||
{{else if .Errors}}
|
||||
{{range .Errors}}
|
||||
<p class="auth-card__message" data-state="error">{{.}}</p>
|
||||
{{end}}
|
||||
<div class="auth-card__actions">
|
||||
<a class="button" href="/settings">Return to settings</a>
|
||||
<a class="button button-ghost muted" href="/">Back home</a>
|
||||
</div>
|
||||
{{else if .HasPosts}}
|
||||
<div class="deletion-warning">
|
||||
<p class="auth-card__message" data-state="error">
|
||||
<strong>Warning:</strong> You have {{.PostCount}} post{{if ne .PostCount 1}}s{{end}} on this platform.
|
||||
</p>
|
||||
<p class="auth-card__message">
|
||||
What would you like to do with your posts?
|
||||
</p>
|
||||
<form method="post" action="/settings/delete/confirm" class="deletion-form">
|
||||
<input type="hidden" name="token" value="{{.Token}}" />
|
||||
<div class="deletion-options">
|
||||
<label class="deletion-option">
|
||||
<input type="radio" name="delete_posts" value="false" checked />
|
||||
<span>Keep my posts (they will be attributed to "(deleted)")</span>
|
||||
</label>
|
||||
<label class="deletion-option">
|
||||
<input type="radio" name="delete_posts" value="true" />
|
||||
<span>Delete all my posts permanently</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="auth-card__actions">
|
||||
<button type="submit" class="button button-danger">Confirm deletion</button>
|
||||
<a class="button button-ghost muted" href="/settings">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="auth-card__message">Are you sure you want to delete your account? This action cannot be undone.</p>
|
||||
<form method="post" action="/settings/delete/confirm" class="deletion-form">
|
||||
<input type="hidden" name="token" value="{{.Token}}" />
|
||||
<input type="hidden" name="delete_posts" value="false" />
|
||||
<div class="auth-card__actions">
|
||||
<button type="submit" class="button button-danger">Confirm deletion</button>
|
||||
<a class="button button-ghost muted" href="/settings">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
57
internal/templates/confirm_email.gohtml
Normal file
57
internal/templates/confirm_email.gohtml
Normal file
@@ -0,0 +1,57 @@
|
||||
{{define "content"}}
|
||||
<section class="auth-card confirmation-card">
|
||||
{{if .VerificationSuccess}}
|
||||
<div class="confirmation-success">
|
||||
<div class="confirmation-icon success-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="#10b981" stroke="#10b981" stroke-width="2"/>
|
||||
<path d="M9 12l2 2 4-4" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="confirmation-title">Email Confirmed! 🎉</h1>
|
||||
<p class="confirmation-message">Your account has been successfully verified. You can now sign in and start using {{.SiteTitle}}.</p>
|
||||
<div class="confirmation-actions">
|
||||
<a class="button button-primary" href="/login?verified=1">
|
||||
<svg class="svg-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M21 12H9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Continue to Sign In
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="confirmation-error">
|
||||
<div class="confirmation-icon error-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="#ef4444" stroke="#ef4444" stroke-width="2"/>
|
||||
<path d="M15 9l-6 6M9 9l6 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="confirmation-title">Verification Failed</h1>
|
||||
<p class="confirmation-message">We couldn't confirm this account with the link provided. The link may be invalid, expired, or already used.</p>
|
||||
<div class="confirmation-help">
|
||||
<h3>What can you do?</h3>
|
||||
<ul>
|
||||
<li>Check if you clicked the correct link from your email</li>
|
||||
<li>Request a new verification email</li>
|
||||
<li>Contact support if the problem persists</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="confirmation-actions">
|
||||
<a class="button button-primary" href="/login">
|
||||
<svg class="svg-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 12l2 2 4-4M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9 9 4.03 9 9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to Sign In
|
||||
</a>
|
||||
<a class="button button-secondary" href="/resend-verification">
|
||||
<svg class="svg-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8M3 3v5h5M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16M21 21v-5h-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Resend Email
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
15
internal/templates/error.gohtml
Normal file
15
internal/templates/error.gohtml
Normal file
@@ -0,0 +1,15 @@
|
||||
{{define "content"}}
|
||||
<section class="form-card">
|
||||
<h1>{{if .Title}}{{.Title}}{{else}}Something went wrong{{end}}</h1>
|
||||
{{if .Errors}}
|
||||
<ul class="error-list">
|
||||
{{range .Errors}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p>We couldn't handle that request this time.</p>
|
||||
{{end}}
|
||||
<p><a href="/">Back to home</a></p>
|
||||
</section>
|
||||
{{end}}
|
||||
23
internal/templates/forgot_password.gohtml
Normal file
23
internal/templates/forgot_password.gohtml
Normal file
@@ -0,0 +1,23 @@
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<h1>Reset your password</h1>
|
||||
<p class="auth-card__message">Enter your username or email address and we'll send you a link to reset your password.</p>
|
||||
|
||||
<form method="post" action="/forgot-password" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<label for="username_or_email">Username or email address</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username_or_email"
|
||||
name="username_or_email"
|
||||
value="{{index .FormValues "username_or_email"}}"
|
||||
required
|
||||
autofocus
|
||||
placeholder="username or you@example.com"
|
||||
/>
|
||||
|
||||
<button type="submit">Send reset link</button>
|
||||
<p class="hint"><a href="/login">Back to sign in</a></p>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
21
internal/templates/home.gohtml
Normal file
21
internal/templates/home.gohtml
Normal file
@@ -0,0 +1,21 @@
|
||||
{{define "content"}}
|
||||
<section class="page-header">
|
||||
<div class="page-heading">
|
||||
<div class="feed-toggle" role="tablist" aria-label="Sort posts">
|
||||
<a class="feed-toggle__link{{if ne .PostsSort "new"}} is-active{{end}}" href="{{.PostsSortTopURL}}" role="tab" aria-selected="{{if ne .PostsSort "new"}}true{{else}}false{{end}}">Top voted</a>
|
||||
<a class="feed-toggle__link{{if eq .PostsSort "new"}} is-active{{end}}" href="{{.PostsSortNewURL}}" role="tab" aria-selected="{{if eq .PostsSort "new"}}true{{else}}false{{end}}">Newest</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
{{if .User}}
|
||||
<a class="button" href="/posts/new">Share a link</a>
|
||||
{{else}}
|
||||
<a class="button" href="/login">Share a link</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="post-feed">
|
||||
{{template "post-list" .}}
|
||||
</section>
|
||||
{{end}}
|
||||
17
internal/templates/login.gohtml
Normal file
17
internal/templates/login.gohtml
Normal file
@@ -0,0 +1,17 @@
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<h1>Sign in</h1>
|
||||
<form method="post" action="/login" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{index .FormValues "username"}}" required autofocus />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required />
|
||||
|
||||
<button type="submit">Continue</button>
|
||||
<p class="hint">Forgot your password? <a href="/forgot-password">Reset it</a>.</p>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
91
internal/templates/new_post.gohtml
Normal file
91
internal/templates/new_post.gohtml
Normal file
@@ -0,0 +1,91 @@
|
||||
{{define "content"}}
|
||||
<section class="form-card">
|
||||
<h1>Share a link</h1>
|
||||
<form method="post" action="/posts">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<label for="title">Title</label>
|
||||
<div class="field-with-action">
|
||||
<input type="text" id="title" name="title" value="{{index .FormValues "title"}}" required />
|
||||
<button type="button" id="autofill-title" class="button-secondary">Autofill</button>
|
||||
</div>
|
||||
<p class="hint" id="autofill-status" aria-live="polite"></p>
|
||||
|
||||
<label for="url">Link</label>
|
||||
<input type="url" id="url" name="url" value="{{index .FormValues "url"}}" placeholder="https://example.com" required />
|
||||
|
||||
<label for="content">Notes</label>
|
||||
<textarea id="content" name="content" rows="4" placeholder="Optional context">{{index .FormValues "content"}}</textarea>
|
||||
|
||||
<button type="submit">Publish</button>
|
||||
</form>
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
(function () {
|
||||
const autofillButton = document.getElementById('autofill-title');
|
||||
const urlInput = document.getElementById('url');
|
||||
const titleInput = document.getElementById('title');
|
||||
const statusEl = document.getElementById('autofill-status');
|
||||
|
||||
if (!autofillButton || !urlInput || !titleInput || !statusEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setStatus = (message, isError) => {
|
||||
statusEl.textContent = message || '';
|
||||
if (!message) {
|
||||
delete statusEl.dataset.state;
|
||||
return;
|
||||
}
|
||||
statusEl.dataset.state = isError ? 'error' : 'info';
|
||||
};
|
||||
|
||||
autofillButton.addEventListener('click', async () => {
|
||||
const urlValue = urlInput.value.trim();
|
||||
if (!urlValue) {
|
||||
setStatus('Add a link first so we can pull its title.', true);
|
||||
urlInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Looking up the page title…', false);
|
||||
autofillButton.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/posts/title?url=${encodeURIComponent(urlValue)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (_) {
|
||||
body = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = body && body.error ? String(body.error) : 'Failed to fetch a title for that link.';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = body && body.data ? body.data : {};
|
||||
const fetchedTitle = data && typeof data.title === 'string' ? data.title.trim() : '';
|
||||
|
||||
if (!fetchedTitle) {
|
||||
throw new Error('No title found on that page.');
|
||||
}
|
||||
|
||||
titleInput.value = fetchedTitle;
|
||||
setStatus('Title copied from the link.', false);
|
||||
} catch (err) {
|
||||
setStatus(err.message || 'Unable to fetch the title right now.', true);
|
||||
} finally {
|
||||
autofillButton.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
{{end}}
|
||||
38
internal/templates/partials/post_list.gohtml
Normal file
38
internal/templates/partials/post_list.gohtml
Normal file
@@ -0,0 +1,38 @@
|
||||
{{define "post-list"}}
|
||||
{{range .Posts}}
|
||||
<article class="post-card">
|
||||
<header>
|
||||
{{if .URL}}
|
||||
<h2><a href="{{.URL}}" target="_blank" rel="noopener">{{.Title}}</a></h2>
|
||||
{{else}}
|
||||
<h2><a href="/posts/{{.ID}}">{{.Title}}</a></h2>
|
||||
{{end}}
|
||||
</header>
|
||||
<p class="post-meta">Shared by {{if .AuthorName}}{{if eq .AuthorName "(deleted)"}}(deleted){{else}}{{.AuthorName}}{{end}}{{else if and .AuthorID .Author.Username}}{{.Author.Username}}{{else}}unauthenticated user{{end}} · {{formatTime .CreatedAt}}</p>
|
||||
<div class="post-stats">
|
||||
<form action="/posts/{{.ID}}/vote" method="post" class="vote-form">
|
||||
{{if $.CSRFToken}}<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}" />{{end}}
|
||||
<input type="hidden" name="action" value="{{if eq .CurrentVote "up"}}clear{{else}}up{{end}}" />
|
||||
<input type="hidden" name="redirect" value="{{$.CurrentPath}}" />
|
||||
<button type="submit" class="vote-arrow{{if eq .CurrentVote "up"}} is-active{{end}}" aria-label="{{if eq .CurrentVote "up"}}Remove upvote{{else}}Upvote{{end}} post {{.Title}}" title="{{if eq .CurrentVote "up"}}Remove upvote{{else}}Upvote{{end}}">
|
||||
<span aria-hidden="true">▲</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="vote-totals">
|
||||
<span class="vote-score">Score {{.Score}}</span>
|
||||
<span class="vote-breakdown">▲ {{.UpVotes}} · ▼ {{.DownVotes}}</span>
|
||||
</div>
|
||||
<form action="/posts/{{.ID}}/vote" method="post" class="vote-form">
|
||||
{{if $.CSRFToken}}<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}" />{{end}}
|
||||
<input type="hidden" name="action" value="{{if eq .CurrentVote "down"}}clear{{else}}down{{end}}" />
|
||||
<input type="hidden" name="redirect" value="{{$.CurrentPath}}" />
|
||||
<button type="submit" class="vote-arrow{{if eq .CurrentVote "down"}} is-active{{end}}" aria-label="{{if eq .CurrentVote "down"}}Remove downvote{{else}}Downvote{{end}} post {{.Title}}" title="{{if eq .CurrentVote "down"}}Remove downvote{{else}}Downvote{{end}}">
|
||||
<span aria-hidden="true">▼</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{{else}}
|
||||
<p>No posts yet. Be the first to <a href="/posts/new">share something</a>.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
43
internal/templates/post.gohtml
Normal file
43
internal/templates/post.gohtml
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "content"}}
|
||||
<article class="post-detail">
|
||||
<header>
|
||||
{{if .Post.URL}}
|
||||
<h1><a href="{{.Post.URL}}" target="_blank" rel="noopener">{{.Post.Title}}</a></h1>
|
||||
{{else}}
|
||||
<h1>{{.Post.Title}}</h1>
|
||||
{{end}}
|
||||
<p class="post-meta">Shared by {{if .Post.AuthorName}}{{if eq .Post.AuthorName "(deleted)"}}(deleted){{else}}{{.Post.AuthorName}}{{end}}{{else if and .Post.AuthorID .Post.Author.Username}}{{.Post.Author.Username}}{{else}}unauthenticated user{{end}} · {{formatTime .Post.CreatedAt}}</p>
|
||||
</header>
|
||||
|
||||
{{if .Post.Content}}
|
||||
<section class="post-body">
|
||||
<p>{{.Post.Content}}</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="post-votes">
|
||||
<div class="vote-strip">
|
||||
<form action="/posts/{{.Post.ID}}/vote" method="post" class="vote-form">
|
||||
{{if .CSRFToken}}<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />{{end}}
|
||||
<input type="hidden" name="redirect" value="/posts/{{.Post.ID}}" />
|
||||
<input type="hidden" name="action" value="{{if eq .CurrentVote "up"}}clear{{else}}up{{end}}" />
|
||||
<button type="submit" class="vote-arrow{{if eq .CurrentVote "up"}} is-active{{end}}" aria-label="{{if eq .CurrentVote "up"}}Remove upvote{{else}}Upvote{{end}}">
|
||||
<span aria-hidden="true">▲</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="vote-totals">
|
||||
<span class="vote-score">Score {{.Post.Score}}</span>
|
||||
<span class="vote-breakdown">▲ {{.Post.UpVotes}} · ▼ {{.Post.DownVotes}}</span>
|
||||
</div>
|
||||
<form action="/posts/{{.Post.ID}}/vote" method="post" class="vote-form">
|
||||
{{if .CSRFToken}}<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />{{end}}
|
||||
<input type="hidden" name="redirect" value="/posts/{{.Post.ID}}" />
|
||||
<input type="hidden" name="action" value="{{if eq .CurrentVote "down"}}clear{{else}}down{{end}}" />
|
||||
<button type="submit" class="vote-arrow{{if eq .CurrentVote "down"}} is-active{{end}}" aria-label="{{if eq .CurrentVote "down"}}Remove downvote{{else}}Downvote{{end}}">
|
||||
<span aria-hidden="true">▼</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
{{end}}
|
||||
23
internal/templates/register.gohtml
Normal file
23
internal/templates/register.gohtml
Normal file
@@ -0,0 +1,23 @@
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<h1>Create account</h1>
|
||||
<form method="post" action="/register" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{index .FormValues "username"}}" placeholder="choose-a-name" required autofocus />
|
||||
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" value="{{index .FormValues "email"}}" placeholder="you@example.com" required />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required />
|
||||
<p class="hint">Use at least 8 characters. Passwords are case-sensitive.</p>
|
||||
|
||||
<label for="password_confirm">Confirm password</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" minlength="8" required />
|
||||
|
||||
<button type="submit">Create account</button>
|
||||
</form>
|
||||
<p class="hint">Already have an account? <a href="/login">Sign in</a>.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
69
internal/templates/resend_verification.gohtml
Normal file
69
internal/templates/resend_verification.gohtml
Normal file
@@ -0,0 +1,69 @@
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="resend-verification">
|
||||
<div class="resend-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8M3 3v5h5M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16M21 21v-5h-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="resend-title">Resend Verification Email</h1>
|
||||
<p class="resend-message">Enter your email address and we'll send you a new verification link.</p>
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="alert alert-success">
|
||||
{{.Flash}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Errors}}
|
||||
<div class="alert alert-error">
|
||||
{{range .Errors}}
|
||||
<div>{{.}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form class="resend-form" method="post" action="/resend-verification" autocomplete="email">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
|
||||
<label for="resend-email">Email address</label>
|
||||
<input
|
||||
id="resend-email"
|
||||
type="email"
|
||||
name="email"
|
||||
value="{{index .FormValues "email"}}"
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<button type="submit" class="button button-primary">
|
||||
<svg class="svg-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 8l7.89 4.26a2 2 0 0 0 2.22 0L21 8M5 19h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Send Verification Email
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="resend-help">
|
||||
<h3>Need help?</h3>
|
||||
<ul>
|
||||
<li>Check your spam/junk folder for the verification email</li>
|
||||
<li>Make sure you're using the email address you registered with</li>
|
||||
<li>Wait a few minutes for the email to arrive</li>
|
||||
<li>Contact support if you continue having issues</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="resend-actions">
|
||||
<a class="button button-secondary" href="/login">
|
||||
<svg class="svg-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M21 12H9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to Sign In
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
47
internal/templates/reset_password.gohtml
Normal file
47
internal/templates/reset_password.gohtml
Normal file
@@ -0,0 +1,47 @@
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<h1>Set new password</h1>
|
||||
<p class="auth-card__message">Enter your new password below.</p>
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="alert alert-success">{{.Flash}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Errors}}
|
||||
<div class="alert alert-error">
|
||||
{{range .Errors}}
|
||||
<p>{{.}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/reset-password" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<input type="hidden" name="token" value="{{.Token}}" />
|
||||
|
||||
<label for="password">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
minlength="8"
|
||||
required
|
||||
autofocus
|
||||
placeholder="Enter your new password"
|
||||
/>
|
||||
|
||||
<label for="confirm_password">Confirm new password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
minlength="8"
|
||||
required
|
||||
placeholder="Confirm your new password"
|
||||
/>
|
||||
|
||||
<button type="submit">Reset password</button>
|
||||
<p class="hint"><a href="/login">Back to sign in</a></p>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
27
internal/templates/search.gohtml
Normal file
27
internal/templates/search.gohtml
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "content"}}
|
||||
<section class="page-header">
|
||||
<div class="page-heading">
|
||||
<h1 class="page-title">Search results</h1>
|
||||
{{if .SearchQuery}}
|
||||
<p class="search-query">Results for "{{.SearchQuery}}"</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="post-feed">
|
||||
{{if .SearchQuery}}
|
||||
{{if .Posts}}
|
||||
{{template "post-list" .}}
|
||||
{{else}}
|
||||
<div class="no-results">
|
||||
<p>No posts found matching "{{.SearchQuery}}".</p>
|
||||
<p>Try different keywords or <a href="/posts/new">share something new</a>.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="no-results">
|
||||
<p>Enter a search term to find posts.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
151
internal/templates/settings.gohtml
Normal file
151
internal/templates/settings.gohtml
Normal file
@@ -0,0 +1,151 @@
|
||||
{{define "content"}}
|
||||
<section class="settings">
|
||||
<div class="page-header">
|
||||
<div class="page-heading">
|
||||
<h1 class="page-title">Account settings</h1>
|
||||
<p class="page-subtitle">Manage how you sign in and maintain your presence on {{.SiteTitle}}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-stack">
|
||||
<div class="settings-row">
|
||||
<article class="form-card settings-card">
|
||||
<header class="settings-card__header">
|
||||
<h2>Change email address</h2>
|
||||
<p class="settings-lead">Current email: <span class="settings-email">{{.User.Email}}</span></p>
|
||||
</header>
|
||||
<form class="settings-card__form" method="post" action="/settings/email" autocomplete="email">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<label for="settings-email">New email</label>
|
||||
<input
|
||||
id="settings-email"
|
||||
type="email"
|
||||
name="email"
|
||||
value="{{index .FormValues "email"}}"
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{{with index .FormErrors "email"}}
|
||||
<ul class="error-list">
|
||||
{{range .}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p class="hint">We'll send a confirmation link to the new address. You'll need to verify it before signing in again.</p>
|
||||
<button type="submit">Update email</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="form-card settings-card">
|
||||
<header class="settings-card__header">
|
||||
<h2>Change username</h2>
|
||||
<p class="settings-lead">Current username: <strong>{{.User.Username}}</strong></p>
|
||||
</header>
|
||||
<form class="settings-card__form" method="post" action="/settings/username" autocomplete="username">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<label for="settings-username">New username</label>
|
||||
<input
|
||||
id="settings-username"
|
||||
type="text"
|
||||
name="username"
|
||||
value="{{index .FormValues "username"}}"
|
||||
minlength="3"
|
||||
maxlength="50"
|
||||
required
|
||||
/>
|
||||
<p class="hint">Usernames are unique. Pick something between 3 and 50 characters.</p>
|
||||
<button type="submit">Update username</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="form-card settings-card settings-card--full">
|
||||
<header class="settings-card__header">
|
||||
<h2>Change password</h2>
|
||||
<p class="settings-lead">Update your account password to keep your account secure.</p>
|
||||
</header>
|
||||
<form class="settings-card__form" method="post" action="/settings/password" autocomplete="current-password">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<label for="settings-current-password">Current password</label>
|
||||
<input
|
||||
id="settings-current-password"
|
||||
type="password"
|
||||
name="current_password"
|
||||
required
|
||||
placeholder="Enter your current password"
|
||||
/>
|
||||
{{with index .FormErrors "current_password"}}
|
||||
<ul class="error-list">
|
||||
{{range .}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<label for="settings-new-password">New password</label>
|
||||
<input
|
||||
id="settings-new-password"
|
||||
type="password"
|
||||
name="new_password"
|
||||
minlength="8"
|
||||
required
|
||||
placeholder="Enter your new password"
|
||||
/>
|
||||
{{with index .FormErrors "new_password"}}
|
||||
<ul class="error-list">
|
||||
{{range .}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<label for="settings-confirm-password">Confirm new password</label>
|
||||
<input
|
||||
id="settings-confirm-password"
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
minlength="8"
|
||||
required
|
||||
placeholder="Confirm your new password"
|
||||
/>
|
||||
{{with index .FormErrors "confirm_password"}}
|
||||
<ul class="error-list">
|
||||
{{range .}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p class="hint">Password must be at least 8 characters long. Use a combination of letters, numbers, and symbols for better security.</p>
|
||||
<button type="submit">Update password</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="form-card settings-card settings-card--danger settings-card--full settings-card--deletion">
|
||||
<header class="settings-card__header">
|
||||
<h2 class="settings-card__title--danger">Request account deletion</h2>
|
||||
<p class="settings-lead">We'll send a confirmation link by email. Your account stays active until you confirm from that message.</p>
|
||||
</header>
|
||||
<form class="settings-card__form" method="post" action="/settings/delete">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
|
||||
<label for="settings-delete">Type DELETE to confirm</label>
|
||||
<input
|
||||
id="settings-delete"
|
||||
type="text"
|
||||
name="confirmation"
|
||||
placeholder="DELETE"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
{{with index .FormErrors "delete"}}
|
||||
<ul class="error-list">
|
||||
{{range .}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p class="hint hint--danger">We will send the confirmation email to {{.User.Email}}. The account is deleted only after you click the link.</p>
|
||||
<button type="submit" class="button button-danger">Send deletion email</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
86
internal/templates/template_test.go
Normal file
86
internal/templates/template_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTemplateParsing(t *testing.T) {
|
||||
templateDir := "./"
|
||||
|
||||
var templateFiles []string
|
||||
err := filepath.WalkDir(templateDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && filepath.Ext(path) == ".gohtml" {
|
||||
templateFiles = append(templateFiles, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tmpl := template.New("test")
|
||||
|
||||
tmpl = tmpl.Funcs(template.FuncMap{
|
||||
"formatTime": func(any) string { return "2024-01-01" },
|
||||
"eq": func(a, b any) bool { return a == b },
|
||||
"ne": func(a, b any) bool { return a != b },
|
||||
"len": func(s any) int { return 0 },
|
||||
"range": func(s any) any { return s },
|
||||
})
|
||||
|
||||
for _, file := range templateFiles {
|
||||
t.Run(file, func(t *testing.T) {
|
||||
_, err := tmpl.ParseFiles(file)
|
||||
assert.NoError(t, err, "Template %s should parse without errors", file)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateSyntax(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
shouldFail bool
|
||||
}{
|
||||
{
|
||||
name: "valid template",
|
||||
template: `{{define "test"}}<h1>{{.Title}}</h1>{{end}}`,
|
||||
shouldFail: false,
|
||||
},
|
||||
{
|
||||
name: "invalid define inside content",
|
||||
template: `<div>{{define "invalid"}}content{{end}}</div>{{define "test"}}<h1>{{.Title}}</h1>{{end}}`,
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "unclosed template tag",
|
||||
template: `{{define "test"}}<h1>{{.Title}}</h1>`,
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "valid nested template",
|
||||
template: `{{define "parent"}}<div>{{template "child" .}}</div>{{end}}{{define "child"}}<span>{{.Content}}</span>{{end}}`,
|
||||
shouldFail: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpl := template.New("test")
|
||||
_, err := tmpl.Parse(tt.template)
|
||||
|
||||
if tt.shouldFail {
|
||||
assert.Error(t, err, "Template should fail to parse")
|
||||
} else {
|
||||
assert.NoError(t, err, "Template should parse successfully")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user