Initial commit: runnable Ask a Plumber First server.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
{{define "admin-users"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap">
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1>Users</h1>
|
||||
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
||||
<div class="admin-users">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Username</th>
|
||||
<th scope="col">Role</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td><span class="mono">{{.Username}}</span></td>
|
||||
<td>{{.Role}}</td>
|
||||
<td class="user-actions">
|
||||
{{if eq .Role "admin"}}
|
||||
<form method="post" action="/admin/users/{{.ID}}/role">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="role" value="user">
|
||||
<button type="submit" class="btn btn-ghost">Make user</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/users/{{.ID}}/role">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="role" value="admin">
|
||||
<button type="submit" class="btn btn-ghost">Make admin</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="3">No users yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,73 @@
|
||||
{{define "header"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{if .Title}}{{.Title}} · {{end}}Ask a Plumber First</title>
|
||||
<meta name="description" content="Daily plumbing questions, ranked like a hunt. Ask a 22-year Bay Area plumber.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="top">
|
||||
<div class="top-inner">
|
||||
<a class="logo" href="/">
|
||||
<svg class="mark" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<circle cx="16" cy="16" r="14" fill="none" stroke="currentColor" stroke-width="2.25"/>
|
||||
<circle cx="16" cy="16" r="7.5" fill="none" stroke="currentColor" stroke-width="2.25"/>
|
||||
<circle cx="16" cy="16" r="3" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="logo-text">
|
||||
<span class="logo-name">Ask a Plumber First</span>
|
||||
<span class="tagline">{{locationTag .User}}</span>
|
||||
</span>
|
||||
</a>
|
||||
<nav class="top-nav" aria-label="Account">
|
||||
{{if not (isAdmin .User)}}
|
||||
<a class="btn btn-primary" href="/submit">Ask</a>
|
||||
{{end}}
|
||||
{{if .User}}
|
||||
<details class="account-menu">
|
||||
<summary class="btn btn-ghost account-menu-toggle" title="{{.User.Username}}">
|
||||
{{if .User.AvatarURL}}
|
||||
<img class="nav-avatar" src="{{.User.AvatarURL}}" alt="" width="28" height="28">
|
||||
{{else}}
|
||||
<span class="nav-avatar nav-avatar-ghost" aria-hidden="true">{{slice .User.Username 0 1}}</span>
|
||||
{{end}}
|
||||
<span class="who">{{.User.Username}}</span>
|
||||
<span class="account-menu-caret" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="account-menu-panel" role="menu">
|
||||
<a class="account-menu-item" role="menuitem" href="/profile">Profile</a>
|
||||
{{if isAdmin .User}}
|
||||
<a class="account-menu-item" role="menuitem" href="/admin/users">Users</a>
|
||||
{{end}}
|
||||
<form class="logout" method="post" action="/logout" role="none">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="account-menu-item" role="menuitem">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{{else}}
|
||||
<a class="btn btn-ghost" href="/login">Sign in</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div id="flash">
|
||||
{{if .Flash}}<p class="banner" role="status">{{.Flash}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "footer"}}
|
||||
<footer class="site-footer">
|
||||
<p>This site is not a substitute for a licensed plumber. Advice is general and based on the question as written. If you have a gas leak, flooding, or another emergency, leave the area if needed and call 911.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "hunt"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap">
|
||||
<div class="hunt-head">
|
||||
<div>
|
||||
<p class="eyebrow">{{if .IsToday}}Live board{{else if .IsYesterday}}Prior board{{else}}Archive{{end}}</p>
|
||||
<h1>{{.Label}}</h1>
|
||||
</div>
|
||||
<nav class="date-nav" aria-label="Hunt date">
|
||||
<a class="chip{{if .IsToday}} is-on{{end}}" href="/">Today</a>
|
||||
<form class="date-form" action="/archive" method="get">
|
||||
<label class="sr-only" for="hunt-date">Pick a date</label>
|
||||
<input id="hunt-date" type="date" name="date" value="{{.Date}}" max="{{.Today}}"
|
||||
onchange="this.form.requestSubmit()">
|
||||
</form>
|
||||
</nav>
|
||||
</div>
|
||||
{{template "leaderboard" .}}
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "login"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap auth-wrap">
|
||||
<p class="eyebrow">Access</p>
|
||||
<h1>Sign in</h1>
|
||||
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
||||
<form class="ask" method="post" action="/login">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="next" value="{{.Next}}">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" required maxlength="20" autocomplete="username" autocapitalize="off" spellcheck="false" value="{{.Username}}">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required minlength="8" autocomplete="current-password">
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</form>
|
||||
<p class="switch">New here? <a href="/register">Create an account</a></p>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,12 @@
|
||||
{{define "answer"}}
|
||||
<section id="answer-block" class="answer{{if .Answer}} is-in{{end}}">
|
||||
{{if .Answer}}
|
||||
<p class="answer-kicker">Shop response</p>
|
||||
<h2>Answer</h2>
|
||||
<p class="byline">{{.Answer.AuthorName}} · 22 years, Bay Area</p>
|
||||
<p class="answer-body">{{.Answer.Body}}</p>
|
||||
{{else}}
|
||||
<p class="waiting">No answer yet. Check back after the hunt.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "leaderboard"}}
|
||||
<ol id="leaderboard" class="board" start="1">
|
||||
{{if not .Questions}}
|
||||
<li class="empty">
|
||||
{{if eq .Date .Today}}
|
||||
<p class="empty-kicker">Queue empty</p>
|
||||
<p>No questions yet. Be the first to <a href="/submit">ask</a>.</p>
|
||||
{{else}}
|
||||
<p>No questions on this day.</p>
|
||||
{{end}}
|
||||
</li>
|
||||
{{else}}
|
||||
{{range $i, $q := .Questions}}
|
||||
<li class="row">
|
||||
<span class="rank" aria-hidden="true">{{rank $i}}</span>
|
||||
{{template "vote" (voteCtx $.User $.CSRF "list" $.Date $q)}}
|
||||
<div class="row-body">
|
||||
<a class="q-title" href="/questions/{{$q.ID}}">{{$q.Title}}</a>
|
||||
<p class="meta">
|
||||
<span>{{$q.AuthorName}}</span>
|
||||
{{if $q.City}}<span class="dot" aria-hidden="true">·</span><span>{{$q.City}}</span>{{end}}
|
||||
{{if $q.Answered}}<span class="badge">Answered</span>{{end}}
|
||||
</p>
|
||||
{{if isAdmin $.User}}
|
||||
<form class="inline-hide" method="post" action="/questions/{{$q.ID}}/hide"
|
||||
hx-post="/questions/{{$q.ID}}/hide" hx-target="#leaderboard" hx-swap="outerHTML">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="view" value="list">
|
||||
<button type="submit" class="linkish">Hide</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ol>
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "signin-prompt"}}
|
||||
<p class="banner" role="status">
|
||||
Sign in to vote or ask a question.
|
||||
<a href="/login">Sign in</a>
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<a href="/register">Create an account</a>
|
||||
</p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "vote"}}
|
||||
<div id="vote-{{.Question.ID}}" class="vote">
|
||||
{{if .User}}
|
||||
<form method="post" action="/questions/{{.Question.ID}}/vote"
|
||||
hx-post="/questions/{{.Question.ID}}/vote"
|
||||
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<input type="hidden" name="view" value="{{.View}}">
|
||||
<input type="hidden" name="date" value="{{.Date}}">
|
||||
<button type="submit" class="vote-btn{{if eq .Question.UserVote 1}} is-up{{end}}" aria-label="Upvote" aria-pressed="{{if eq .Question.UserVote 1}}true{{else}}false{{end}}">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 3.5 15 12H3z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
<span class="score" aria-label="Net score {{.Question.Score}}">{{.Question.Score}}</span>
|
||||
<form method="post" action="/questions/{{.Question.ID}}/vote"
|
||||
hx-post="/questions/{{.Question.ID}}/vote"
|
||||
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<input type="hidden" name="view" value="{{.View}}">
|
||||
<input type="hidden" name="date" value="{{.Date}}">
|
||||
<button type="submit" class="vote-btn{{if eq .Question.UserVote -1}} is-down{{end}}" aria-label="Downvote" aria-pressed="{{if eq .Question.UserVote -1}}true{{else}}false{{end}}">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 14.5 3 6h12z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a class="vote-btn" href="/login" hx-get="/auth/prompt" hx-target="#flash" aria-label="Sign in to upvote">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 3.5 15 12H3z" fill="currentColor"/></svg>
|
||||
</a>
|
||||
<span class="score" aria-label="Net score {{.Question.Score}}">{{.Question.Score}}</span>
|
||||
<a class="vote-btn" href="/login" hx-get="/auth/prompt" hx-target="#flash" aria-label="Sign in to downvote">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 14.5 3 6h12z" fill="currentColor"/></svg>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,57 @@
|
||||
{{define "profile"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap profile-page">
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Profile</h1>
|
||||
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
||||
|
||||
<form class="profile-form" method="post" action="/profile" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
|
||||
<div class="profile-avatar">
|
||||
{{if .User.AvatarURL}}
|
||||
<img class="avatar" src="{{.User.AvatarURL}}" alt="" width="96" height="96">
|
||||
{{else}}
|
||||
<div class="avatar avatar-empty" aria-hidden="true">{{slice .User.Username 0 1}}</div>
|
||||
{{end}}
|
||||
<div class="avatar-fields">
|
||||
<label for="avatar">Profile picture</label>
|
||||
{{if .UploadsEnabled}}
|
||||
<input id="avatar" name="avatar" type="file" accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp">
|
||||
<p class="hint">JPEG, PNG, or WebP · max 2MB</p>
|
||||
{{else}}
|
||||
<p class="hint">Avatar uploads are not configured on this server.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="state">State</label>
|
||||
<select id="state" name="state">
|
||||
<option value=""{{if eq .StateVal ""}} selected{{end}}>Prefer not to say</option>
|
||||
{{range .States}}
|
||||
<option value="{{.Code}}"{{if eq $.StateVal .Code}} selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<p class="hint">Optional. Sharing your state helps answers line up with local plumbing codes.</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save profile</button>
|
||||
</form>
|
||||
|
||||
<section class="profile-questions" aria-labelledby="profile-q-heading">
|
||||
<h2 id="profile-q-heading">{{.QuestionsLabel}}</h2>
|
||||
{{if .Questions}}
|
||||
<ul class="profile-q-list">
|
||||
{{range .Questions}}
|
||||
<li>
|
||||
<a href="/questions/{{.ID}}">{{.Title}}</a>
|
||||
<span class="meta">{{.HuntDate}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="muted">Nothing here yet.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "question"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap question-page">
|
||||
<p class="crumb"><a href="{{if eq .Question.HuntDate .Today}}/{{else}}/hunt/{{.Question.HuntDate}}{{end}}">← {{pacificLabel .Question.HuntDate}}</a></p>
|
||||
<article class="q-detail">
|
||||
{{template "vote" (voteCtx .User .CSRF "question" .Question.HuntDate .Question)}}
|
||||
<div>
|
||||
<h1>{{.Question.Title}}</h1>
|
||||
<p class="meta">
|
||||
<span>{{.Question.AuthorName}}</span>
|
||||
{{if .Question.City}}<span class="dot" aria-hidden="true">·</span><span>{{.Question.City}}</span>{{end}}
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<a href="{{if eq .Question.HuntDate .Today}}/{{else}}/hunt/{{.Question.HuntDate}}{{end}}">{{.Question.HuntDate}}</a>
|
||||
</p>
|
||||
<p class="q-body">{{.Question.Body}}</p>
|
||||
{{if isAdmin .User}}
|
||||
<form method="post" action="/questions/{{.Question.ID}}/hide"
|
||||
hx-post="/questions/{{.Question.ID}}/hide" hx-target="body">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="linkish">Hide this question</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{template "answer" .}}
|
||||
{{if isAdmin .User}}
|
||||
<form class="answer-form" method="post" action="/questions/{{.Question.ID}}/answer"
|
||||
hx-post="/questions/{{.Question.ID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label for="answer-body">{{if .Answer}}Edit answer{{else}}Write the answer{{end}}</label>
|
||||
<textarea id="answer-body" name="body" rows="8" required maxlength="12000">{{if .Answer}}{{.Answer.Body}}{{end}}</textarea>
|
||||
<button type="submit" class="btn btn-primary">Save answer</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{define "register"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap auth-wrap">
|
||||
<p class="eyebrow">New account</p>
|
||||
<h1>Create an account</h1>
|
||||
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
||||
<form class="ask" method="post" action="/register">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" required minlength="3" maxlength="20" pattern="[A-Za-z0-9_]+" autocomplete="username" autocapitalize="off" spellcheck="false" value="{{.Username}}">
|
||||
<p class="hint">3–20 letters, numbers, or underscores.</p>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required minlength="8" autocomplete="new-password">
|
||||
<p class="hint">At least 8 characters.</p>
|
||||
<button type="submit" class="btn btn-primary">Create account</button>
|
||||
</form>
|
||||
<p class="switch">Already have an account? <a href="/login">Sign in</a></p>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "submit"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap panel-wrap">
|
||||
<h1>Ask a question</h1>
|
||||
<p class="lede">It lands on today’s hunt (Pacific time). People vote; the ranking resets at midnight PT.</p>
|
||||
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
||||
<form class="ask" method="post" action="/submit">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label for="title">Title</label>
|
||||
<input id="title" name="title" type="text" required maxlength="120" value="{{.TitleVal}}" placeholder="Water heater popping after showers">
|
||||
<label for="body">What is going on?</label>
|
||||
<textarea id="body" name="body" rows="8" required maxlength="8000" placeholder="Age of the house, what you already tried, where you are in the Bay if it helps.">{{.BodyVal}}</textarea>
|
||||
<label for="city">City <span class="optional">(optional)</span></label>
|
||||
<input id="city" name="city" type="text" maxlength="80" value="{{.CityVal}}" placeholder="Oakland">
|
||||
<button type="submit" class="btn btn-primary">Submit to today’s hunt</button>
|
||||
</form>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user