diff options
| author | gramanas <grm@eyesin.space> | 2026-09-12 11:24:17 +0300 |
|---|---|---|
| committer | gramanas <grm@eyesin.space> | 2026-09-12 11:24:17 +0300 |
| commit | 3eb04b1a2bdf9e53231fe862cfd76327371a9741 (patch) | |
| tree | b38b2d82a47233fd8e0bb18c59e4a8f3dd2412d7 /internal/web/templates/dashboard | |
| download | blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.gz blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.tar.bz2 blogspace-3eb04b1a2bdf9e53231fe862cfd76327371a9741.zip | |
Initial multi-tenant blog host
Go + Postgres application serving a management dashboard on the base
domain and one public blog per subdomain. Markdown posts organised in
pages, form-based theme customisation, image uploads stored in Postgres,
JWT cookie sessions with CSRF, superadmin user management, RSS feeds.
Docker/compose deployment and a Makefile-driven dev environment with
seed data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/templates/dashboard')
| -rw-r--r-- | internal/web/templates/dashboard/confirm.html | 17 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/design.html | 87 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/images.html | 22 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/overview.html | 35 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/page_form.html | 21 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/pages.html | 24 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/password.html | 13 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/post_form.html | 26 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/posts.html | 25 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/settings.html | 13 |
10 files changed, 283 insertions, 0 deletions
diff --git a/internal/web/templates/dashboard/confirm.html b/internal/web/templates/dashboard/confirm.html new file mode 100644 index 0000000..eefa1da --- /dev/null +++ b/internal/web/templates/dashboard/confirm.html @@ -0,0 +1,17 @@ +{{define "title"}}Confirm · Blogspace{{end}} +{{define "content"}} +<div class="card narrow"> + <h1>Are you sure?</h1> + {{if .Data.isHome}} + <p>This is the home page; it cannot be deleted. Make another page the home page first.</p> + <p><a class="btn secondary" href="{{.Data.back}}">Back</a></p> + {{else}} + <p>You are about to permanently delete {{.Data.what}}. This cannot be undone.</p> + <form method="post" action="{{.Data.action}}"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <button type="submit" class="danger">Yes, delete</button> + <a class="btn secondary" href="{{.Data.back}}">Cancel</a> + </form> + {{end}} +</div> +{{end}} diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html new file mode 100644 index 0000000..27ce3c7 --- /dev/null +++ b/internal/web/templates/dashboard/design.html @@ -0,0 +1,87 @@ +{{define "title"}}Design · {{.Blog.Title}}{{end}} +{{define "content"}} +{{$t := .Data.theme}} +<h1>Design</h1> +<p class="muted">Save, then refresh <a href="{{.BlogURL}}" target="_blank">your blog ↗</a> to see the result. Colours are hex values like <code>#336699</code>.</p> +<form method="post" action="/b/{{.Blog.Subdomain}}/design" enctype="multipart/form-data"> +<input type="hidden" name="_csrf" value="{{.CSRF}}"> + +<div class="card"> + <h2>Background</h2> + <div class="row"> + <label>Colour<br><input type="color" name="bg_color" value="{{$t.BgColor}}"></label> + <label>Image style<br><select name="bg_mode"> + <option value="cover"{{if eq $t.BgMode "cover"}} selected{{end}}>Stretch to fill</option> + <option value="fixed"{{if eq $t.BgMode "fixed"}} selected{{end}}>Fill, fixed while scrolling</option> + <option value="tile"{{if eq $t.BgMode "tile"}} selected{{end}}>Repeat (tile)</option> + </select></label> + </div> + {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "images" .Data.images "label" "Background image")}} +</div> + +<div class="card"> + <h2>Text & content area</h2> + <div class="row"> + <label>Content background<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label> + <label>Text colour<br><input type="color" name="text_color" value="{{$t.TextColor}}"></label> + <label>Link colour<br><input type="color" name="link_color" value="{{$t.LinkColor}}"></label> + </div> + <div class="row"> + <label>Font<br><select name="font"> + <option value="sans"{{if eq $t.Font "sans"}} selected{{end}}>Sans-serif (Helvetica/Arial)</option> + <option value="serif"{{if eq $t.Font "serif"}} selected{{end}}>Serif (Georgia/Times)</option> + <option value="mono"{{if eq $t.Font "mono"}} selected{{end}}>Monospace (Courier)</option> + </select></label> + <label>Text size<br><select name="font_size"> + <option value="small"{{if eq $t.FontSize "small"}} selected{{end}}>Small</option> + <option value="normal"{{if eq $t.FontSize "normal"}} selected{{end}}>Normal</option> + <option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>Large</option> + </select></label> + <label>Width<br><select name="content_width"> + <option value="narrow"{{if eq $t.ContentWidth "narrow"}} selected{{end}}>Narrow</option> + <option value="medium"{{if eq $t.ContentWidth "medium"}} selected{{end}}>Medium</option> + <option value="wide"{{if eq $t.ContentWidth "wide"}} selected{{end}}>Wide</option> + </select></label> + </div> +</div> + +<div class="card"> + <h2>Header</h2> + <div class="row"> + <label>Background<br><input type="color" name="header_bg" value="{{$t.HeaderBg}}"></label> + <label>Text colour<br><input type="color" name="header_text" value="{{$t.HeaderText}}"></label> + <label>Alignment<br><select name="header_align"> + <option value="left"{{if eq $t.HeaderAlign "left"}} selected{{end}}>Left</option> + <option value="center"{{if eq $t.HeaderAlign "center"}} selected{{end}}>Centred</option> + </select></label> + </div> + <label class="check"><input type="checkbox" name="header_show_title"{{if $t.HeaderShowTitle}} checked{{end}}> Show blog title and tagline in the header</label> + {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "images" .Data.images "label" "Header image (banner)")}} +</div> + +<div class="card"> + <h2>Menu</h2> + <div class="row"> + <label>Position<br><select name="nav_position"> + <option value="below-header"{{if eq $t.NavPosition "below-header"}} selected{{end}}>Below the header</option> + <option value="top-bar"{{if eq $t.NavPosition "top-bar"}} selected{{end}}>Bar at the very top</option> + <option value="left-sidebar"{{if eq $t.NavPosition "left-sidebar"}} selected{{end}}>Sidebar on the left</option> + </select></label> + <label>Background<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label> + <label>Text colour<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label> + </div> + <label class="check"><input type="checkbox" name="nav_show_home"{{if $t.NavShowHome}} checked{{end}}> Include the home page in the menu</label> +</div> + +<div class="card"> + <h2>Footer</h2> + <label>Footer text<br><input name="footer_text" value="{{$t.FooterText}}" maxlength="2000" placeholder="© 2026 Me"></label> + <div class="row"> + <label>Background<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label> + <label>Text colour<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label> + </div> +</div> + +<p><button type="submit">Save design</button></p> +</form> +{{end}} diff --git a/internal/web/templates/dashboard/images.html b/internal/web/templates/dashboard/images.html new file mode 100644 index 0000000..5383e73 --- /dev/null +++ b/internal/web/templates/dashboard/images.html @@ -0,0 +1,22 @@ +{{define "title"}}Images · {{.Blog.Title}}{{end}} +{{define "content"}} +<h1>Images</h1> +<div class="card"> + <form method="post" action="/b/{{.Blog.Subdomain}}/images/upload" enctype="multipart/form-data"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Upload an image <span class="muted">(PNG, JPEG, GIF or WebP)</span><br><input type="file" name="file" accept="image/*" required></label> + <button type="submit">Upload</button> + </form> +</div> +<p class="muted">To put an image in a post, copy its Markdown line into the post text.</p> +<div class="gallery"> +{{range .Data.images}} + <div class="card thumb"> + <a href="/media/{{.ID}}" target="_blank"><img src="/media/{{.ID}}" alt="{{.Filename}}"></a> + <div class="meta">{{.Filename}} <span class="muted">({{kb .Size}})</span></div> + <input class="copy" readonly value="" onclick="this.select()"> + <form method="post" action="/b/{{$.Blog.Subdomain}}/images/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">delete</button></form> + </div> +{{else}}<p class="muted">No images yet.</p>{{end}} +</div> +{{end}} diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html new file mode 100644 index 0000000..0ec72e6 --- /dev/null +++ b/internal/web/templates/dashboard/overview.html @@ -0,0 +1,35 @@ +{{define "title"}}{{.Blog.Title}} · Blogspace{{end}} +{{define "content"}} +<h1>{{.Blog.Title}}</h1> +<p class="muted">Your blog is live at <a href="{{.BlogURL}}" target="_blank">{{.BlogURL}}</a>. Every change you save here shows up there as soon as you refresh.</p> + +<div class="cols"> + <div class="card"> + <h2>Quick actions</h2> + <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">Write a new post</a></p> + <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> · <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> · <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p> + </div> + <div class="card"> + <h2>Pages</h2> + <ul class="plain"> + {{range .Data.pages}}<li><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.Title}}</a> <span class="muted">({{.PostCount}} posts{{if .IsHome}}, home{{end}}{{if not .ShowInNav}}, hidden from menu{{end}})</span></li>{{end}} + </ul> + </div> +</div> + +<div class="card"> + <h2>Latest posts</h2> + {{if .Data.posts}} + <table> + <tr><th>Title</th><th>Page</th><th>Date</th><th></th></tr> + {{range .Data.posts}}<tr> + <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">hidden</span>{{end}}</td> + <td>{{.PageTitle}}</td> + <td>{{date .CreatedAt}}</td> + <td><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">view ↗</a></td> + </tr>{{end}} + </table> + <p><a href="/b/{{.Blog.Subdomain}}/posts">All posts</a></p> + {{else}}<p class="muted">No posts yet. <a href="/b/{{.Blog.Subdomain}}/posts/new">Write the first one.</a></p>{{end}} +</div> +{{end}} diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html new file mode 100644 index 0000000..df94c7e --- /dev/null +++ b/internal/web/templates/dashboard/page_form.html @@ -0,0 +1,21 @@ +{{define "title"}}{{if .Data.page.ID}}Edit page{{else}}New page{{end}} · {{.Blog.Title}}{{end}} +{{define "content"}} +<div class="card"> + <h1>{{if .Data.page.ID}}Edit page{{else}}New page{{end}}</h1> + <form method="post"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Title<br><input name="title" value="{{.Data.page.Title}}" required maxlength="120"></label> + <label>Address <span class="muted">(leave empty to make one from the title; e.g. <code>about</code> → {{.BlogURL}}/about)</span><br> + <input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> + <label>Intro text <span class="muted">(Markdown, shown above the posts; optional)</span><br> + <textarea name="intro" rows="8">{{.Data.page.IntroMD}}</textarea></label> + <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> Show in menu</label> + <p> + <button type="submit">Save</button> + {{if .Data.page.ID}}<a class="btn secondary" href="{{.BlogURL}}{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}" target="_blank">View page ↗</a>{{end}} + <a href="/b/{{.Blog.Subdomain}}/pages">Back to pages</a> + </p> + </form> +</div> +{{template "mdhelp"}} +{{end}} diff --git a/internal/web/templates/dashboard/pages.html b/internal/web/templates/dashboard/pages.html new file mode 100644 index 0000000..6703e7f --- /dev/null +++ b/internal/web/templates/dashboard/pages.html @@ -0,0 +1,24 @@ +{{define "title"}}Pages · {{.Blog.Title}}{{end}} +{{define "content"}} +<h1>Pages <a class="btn small" href="/b/{{.Blog.Subdomain}}/pages/new">+ New page</a></h1> +<p class="muted">Pages appear in your blog's menu in this order. Each page holds its own list of posts.</p> +<div class="card"> +<table> + <tr><th>Menu order</th><th>Title</th><th>Address</th><th>Posts</th><th></th></tr> + {{range .Data.pages}}<tr> + <td class="nowrap"> + <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> + <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> + </td> + <td><a href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/edit">{{.Title}}</a> + {{if .IsHome}}<span class="tag">home</span>{{end}}{{if not .ShowInNav}}<span class="tag">hidden</span>{{end}}</td> + <td><a href="{{$.BlogURL}}{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}" target="_blank">{{if .IsHome}}/{{else}}/{{.Slug}}{{end}} ↗</a></td> + <td><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.PostCount}}</a></td> + <td class="nowrap"> + {{if not .IsHome}}<form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/home" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">Make home</button></form> + <a class="danger" href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/delete">delete</a>{{end}} + </td> + </tr>{{end}} +</table> +</div> +{{end}} diff --git a/internal/web/templates/dashboard/password.html b/internal/web/templates/dashboard/password.html new file mode 100644 index 0000000..f333bd7 --- /dev/null +++ b/internal/web/templates/dashboard/password.html @@ -0,0 +1,13 @@ +{{define "title"}}Change password · Blogspace{{end}} +{{define "content"}} +<div class="card narrow"> + <h1>Change password</h1> + <form method="post" action="/account/password"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Current password<br><input type="password" name="current" required autocomplete="current-password"></label> + <label>New password<br><input type="password" name="password" required minlength="8" autocomplete="new-password"></label> + <label>Repeat new password<br><input type="password" name="password2" required minlength="8" autocomplete="new-password"></label> + <button type="submit">Change password</button> + </form> +</div> +{{end}} diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html new file mode 100644 index 0000000..520b9e6 --- /dev/null +++ b/internal/web/templates/dashboard/post_form.html @@ -0,0 +1,26 @@ +{{define "title"}}{{if .Data.post.ID}}Edit post{{else}}New post{{end}} · {{.Blog.Title}}{{end}} +{{define "content"}} +<div class="card"> + <h1>{{if .Data.post.ID}}Edit post{{else}}New post{{end}}</h1> + <form method="post"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Title<br><input name="title" value="{{.Data.post.Title}}" required maxlength="200" autofocus></label> + <div class="row"> + <label>Page<br> + <select name="page_id">{{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.post.PageID}} selected{{end}}>{{.Title}}</option>{{end}}</select> + </label> + <label>Address <span class="muted">(optional, made from the title)</span><br> + <input name="slug" value="{{.Data.post.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> + </div> + <label>Content <span class="muted">(Markdown)</span><br> + <textarea name="body" rows="24" class="editor">{{.Data.post.BodyMD}}</textarea></label> + <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> Visible on the blog</label> + <p> + <button type="submit">Save</button> + {{if .Data.post.ID}}<a class="btn secondary" href="{{.BlogURL}}/{{.Data.post.PageSlug}}/{{.Data.post.Slug}}" target="_blank">View post ↗</a>{{end}} + <a href="/b/{{.Blog.Subdomain}}/posts">Back to posts</a> + </p> + </form> +</div> +{{template "mdhelp"}} +{{end}} diff --git a/internal/web/templates/dashboard/posts.html b/internal/web/templates/dashboard/posts.html new file mode 100644 index 0000000..24e86bb --- /dev/null +++ b/internal/web/templates/dashboard/posts.html @@ -0,0 +1,25 @@ +{{define "title"}}Posts · {{.Blog.Title}}{{end}} +{{define "content"}} +<h1>Posts <a class="btn small" href="/b/{{.Blog.Subdomain}}/posts/new{{if .Data.pageID}}?page={{.Data.pageID}}{{end}}">+ New post</a></h1> +<form method="get" class="filter"> + <label>Page: + <select name="page" onchange="this.form.submit()"> + <option value="0">All pages</option> + {{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.pageID}} selected{{end}}>{{.Title}}</option>{{end}} + </select></label> + <noscript><button type="submit">Filter</button></noscript> +</form> +<div class="card"> +{{if .Data.posts}} +<table> + <tr><th>Title</th><th>Page</th><th>Date</th><th></th></tr> + {{range .Data.posts}}<tr> + <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">hidden</span>{{end}}</td> + <td>{{.PageTitle}}</td> + <td class="nowrap">{{date .CreatedAt}}</td> + <td class="nowrap"><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">view ↗</a> · <a class="danger" href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/delete">delete</a></td> + </tr>{{end}} +</table> +{{else}}<p class="muted">No posts here yet.</p>{{end}} +</div> +{{end}} diff --git a/internal/web/templates/dashboard/settings.html b/internal/web/templates/dashboard/settings.html new file mode 100644 index 0000000..24dffb4 --- /dev/null +++ b/internal/web/templates/dashboard/settings.html @@ -0,0 +1,13 @@ +{{define "title"}}Settings · {{.Blog.Title}}{{end}} +{{define "content"}} +<div class="card narrow"> + <h1>Blog settings</h1> + <form method="post" action="/b/{{.Blog.Subdomain}}/settings"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Blog title<br><input name="title" value="{{.Blog.Title}}" required maxlength="120"></label> + <label>Tagline <span class="muted">(shown under the title)</span><br><input name="tagline" value="{{.Blog.Tagline}}" maxlength="300"></label> + <p class="muted">Address: <code>{{.BlogURL}}</code> — only the administrator can change this.</p> + <button type="submit">Save</button> + </form> +</div> +{{end}} |
