aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/templates
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-16 19:10:13 +0300
committergrm <grm@eyesin.space>2026-09-16 19:10:13 +0300
commit4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88 (patch)
treecc39ea660046d9c977647e7c9c36349c126be214 /internal/web/templates
parent6b6b5d2e35ff182a0732da245f4eb37c8afa0564 (diff)
downloadblogspace-4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88.tar.gz
blogspace-4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88.tar.bz2
blogspace-4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88.zip
Merge the Layout and Design tabs into one form with a single Save
The look and the layout of a blog were split over two tabs that cross-linked each other, and the Layout tab saved every toggle, move and add straight away through a dozen tiny forms. Now one Design tab holds it all, organised by part of the blog (colours & fonts, header, menu, content, side columns, footer, logo & icon) behind a sticky side index, and one Save stores the theme, every module and the menu atomically (BlogStore.SaveDesign). Module settings are edited inline; Discard changes throws the edits away; a validation error shows the form again exactly as it was sent. The image pickers no longer render the whole library four times over: a select of the newest images, and a script-driven panel that fetches the library page by page (GET /files as JSON) with lazy thumbnails. New options: posts per page, site title size, footer alignment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/templates')
-rw-r--r--internal/web/templates/dashboard/design.html435
-rw-r--r--internal/web/templates/dashboard/layout.html113
-rw-r--r--internal/web/templates/dashboard/module_form.html41
-rw-r--r--internal/web/templates/dashboard/overview.html2
-rw-r--r--internal/web/templates/dashboard/pages.html2
-rw-r--r--internal/web/templates/dashboard/section_form.html2
-rw-r--r--internal/web/templates/partials/dashnav.html1
-rw-r--r--internal/web/templates/partials/imagepick.html21
8 files changed, 391 insertions, 226 deletions
diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html
index e961388..261eb69 100644
--- a/internal/web/templates/dashboard/design.html
+++ b/internal/web/templates/dashboard/design.html
@@ -1,31 +1,44 @@
{{define "title"}}{{t "Design"}} · {{.Blog.Title}}{{end}}
{{define "content"}}
{{$t := .Data.theme}}
+{{$sub := .Blog.Subdomain}}
<div class="pagehead">
<h1>{{t "Design"}}</h1>
- <p class="lead muted">{{t "Changes are live as soon as you save; refresh"}} <a href="{{.BlogURL}}" target="_blank">{{t "your blog"}} &#8599;</a> {{t "to see them."}}</p>
+ <p class="lead muted">{{t "Everything about how your blog looks and what goes where, saved together with the one button at the bottom. Changes are live as soon as you save; refresh"}} <a href="{{.BlogURL}}" target="_blank">{{t "your blog"}} &#8599;</a> {{t "to see them."}}</p>
</div>
-<p class="jumps"><a href="#schemes">{{t "Schemes"}}</a><a href="#background">{{t "Background"}}</a><a href="#text">{{t "Text"}}</a><a href="#header">{{t "Header"}}</a><a href="#menu">{{t "Menu"}}</a><a href="#posts">{{t "Posts"}}</a><a href="#footer">{{t "Footer"}}</a><a href="#logo">{{t "Logo & icon"}}</a></p>
-<p class="muted small">{{t "What appears where — header, columns, footer and their modules — is arranged on the"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a> {{t "tab."}}</p>
-<div class="card" id="schemes">
- <h2>{{t "Colour schemes"}}</h2>
- <p class="muted small">{{t "A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept."}}</p>
- <form method="post" action="/b/{{.Blog.Subdomain}}/design/preset" class="presets">
- <input type="hidden" name="_csrf" value="{{.CSRF}}">
- {{range .Data.presets}}<button type="submit" name="preset" value="{{.Key}}" class="preset" title="{{tf "Apply %s" (t .Name)}}">
+<div class="design">
+<nav class="sidenav">
+ <a href="#colours">{{t "Colours & fonts"}}</a>
+ <a href="#header">{{t "Header"}}</a>
+ <a href="#menu">{{t "Menu"}}</a>
+ <a href="#content">{{t "Content"}}</a>
+ <a href="#columns">{{t "Side columns"}}</a>
+ <a href="#footer">{{t "Footer"}}</a>
+ <a href="#logo">{{t "Logo & icon"}}</a>
+</nav>
+
+<form method="post" action="/b/{{$sub}}/design" enctype="multipart/form-data" id="design" data-next="{{.Data.nextIndex}}" data-sub="{{$sub}}">
+<input type="hidden" name="_csrf" value="{{.CSRF}}">
+<input type="hidden" name="at" value="">
+
+<div class="card" id="colours">
+ <h2>{{t "Colours & fonts"}}</h2>
+ <h3>{{t "Colour schemes"}}</h3>
+ <p class="muted small">{{t "A quick start: a scheme fills in every colour below. Images, fonts and layout are kept."}}</p>
+ <div class="presets jsonly">
+ {{range .Data.presets}}<button type="button" class="preset" data-colors="{{.Colors}}" title="{{tf "Apply %s" (t .Name)}}">
<span class="sw" style="background:{{.HeaderBg}}"></span><span class="sw" style="background:{{.Bg}}"></span><span class="sw" style="background:{{.ContentBg}}"></span><span class="sw" style="background:{{.Link}}"></span>
<span class="pname">{{t .Name}}</span>
</button>
{{end}}
- </form>
-</div>
-
-<form method="post" action="/b/{{.Blog.Subdomain}}/design" enctype="multipart/form-data">
-<input type="hidden" name="_csrf" value="{{.CSRF}}">
+ </div>
+ <label class="nojs">{{t "Apply a scheme when saving"}}<br><select name="preset">
+ <option value="">{{t "Keep the colours below"}}</option>
+ {{range .Data.presets}}<option value="{{.Key}}">{{t .Name}}</option>{{end}}
+ </select></label>
-<div class="card" id="background">
- <h2>{{t "Background"}}</h2>
+ <h3>{{t "Page background"}}</h3>
<div class="row">
<label>{{t "Colour"}}<br><input type="color" name="bg_color" value="{{$t.BgColor}}"></label>
<label>{{t "Image style"}}<br><select name="bg_mode">
@@ -34,21 +47,25 @@
<option value="tile"{{if eq $t.BgMode "tile"}} selected{{end}}>{{t "Repeat (tile)"}}</option>
</select></label>
</div>
- {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" .Blog.Subdomain "images" .Data.images "label" (t "Background image"))}}
-</div>
+ {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" $sub "images" .Data.images "names" .Data.imageNames "label" (t "Background image"))}}
-<div class="card" id="text">
- <h2>{{t "Text & content area"}}</h2>
+ <h3>{{t "Text & links"}}</h3>
<div class="row">
- <label>{{t "Content background"}}<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label>
<label>{{t "Text colour"}}<br><input type="color" name="text_color" value="{{$t.TextColor}}"></label>
<label>{{t "Link colour"}}<br><input type="color" name="link_color" value="{{$t.LinkColor}}"></label>
+ <label>{{t "Underline links"}}<br><select name="link_underline">
+ <option value="always"{{if eq $t.LinkUnderline "always"}} selected{{end}}>{{t "Always"}}</option>
+ <option value="hover"{{if eq $t.LinkUnderline "hover"}} selected{{end}}>{{t "Only on hover"}}</option>
+ <option value="never"{{if eq $t.LinkUnderline "never"}} selected{{end}}>{{t "Never"}}</option>
+ </select></label>
</div>
<div class="hoverpick">
<input type="checkbox" name="link_hover_custom" id="link-hover-custom"{{if $t.LinkHover}} checked{{end}}>
<label for="link-hover-custom" class="check">{{t "Change colour when the mouse is over a link"}}</label>
<label class="hover-color">{{t "Hover colour"}}<br><input type="color" name="link_hover" value="{{if $t.LinkHover}}{{$t.LinkHover}}{{else}}{{$t.LinkColor}}{{end}}"></label>
</div>
+
+ <h3>{{t "Fonts"}}</h3>
<div class="row">
<label>{{t "Font"}}<br><select name="font">
<option value="sans"{{if eq $t.Font "sans"}} selected{{end}}>Sans-serif (Helvetica/Arial)</option>
@@ -66,28 +83,12 @@
<option value="normal"{{if eq $t.FontSize "normal"}} selected{{end}}>{{t "Normal"}}</option>
<option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>{{t "Large"}}</option>
</select></label>
- <label>{{t "Underline links"}}<br><select name="link_underline">
- <option value="always"{{if eq $t.LinkUnderline "always"}} selected{{end}}>{{t "Always"}}</option>
- <option value="hover"{{if eq $t.LinkUnderline "hover"}} selected{{end}}>{{t "Only on hover"}}</option>
- <option value="never"{{if eq $t.LinkUnderline "never"}} selected{{end}}>{{t "Never"}}</option>
- </select></label>
</div>
- <div class="row">
- <label>{{t "Box style"}}<br><select name="content_style">
- <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>{{t "Card (shadow, rounded corners)"}}</option>
- <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>{{t "Flat"}}</option>
- </select></label>
- <label>{{t "Padding"}} <span class="muted">({{t "inside the box"}})</span><br><select name="content_padding">
- <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>{{t "Compact"}}</option>
- <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>{{t "Normal"}}</option>
- <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>{{t "Roomy"}}</option>
- </select></label>
- </div>
- <p class="muted small">{{t "The content spans the whole page; how much of it the posts get is set by the column widths on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#columns">{{t "Layout"}}</a> {{t "tab."}}</p>
</div>
<div class="card" id="header">
<h2>{{t "Header"}}</h2>
+ <label class="check"><input type="checkbox" name="header_on" data-area="header"{{if $t.HeaderOn}} checked{{end}}> {{t "Show the header"}} <span class="muted">({{t "the top of every page"}})</span></label>
<div class="row">
<label>{{t "Background"}}<br><input type="color" name="header_bg" value="{{$t.HeaderBg}}"></label>
<label>{{t "Text colour"}}<br><input type="color" name="header_text" value="{{$t.HeaderText}}"></label>
@@ -95,19 +96,44 @@
<option value="left"{{if eq $t.HeaderAlign "left"}} selected{{end}}>{{t "Left"}}</option>
<option value="center"{{if eq $t.HeaderAlign "center"}} selected{{end}}>{{t "Centred"}}</option>
</select></label>
- <label>{{t "Image height"}}<br><select name="header_image_height">
- <option value="short"{{if eq $t.HeaderImageHeight "short"}} selected{{end}}>{{t "Short"}}</option>
- <option value="medium"{{if eq $t.HeaderImageHeight "medium"}} selected{{end}}>{{t "Medium"}}</option>
- <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>{{t "Tall"}}</option>
+ <label>{{t "Title size"}}<br><select name="title_size">
+ <option value="small"{{if eq $t.TitleSize "small"}} selected{{end}}>{{t "Small"}}</option>
+ <option value="normal"{{if eq $t.TitleSize "normal"}} selected{{end}}>{{t "Normal"}}</option>
+ <option value="large"{{if eq $t.TitleSize "large"}} selected{{end}}>{{t "Large"}}</option>
</select></label>
</div>
- <p class="muted small">{{t "The title, logo and menu are header modules — add, remove and order them on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#header">{{t "Layout"}}</a> {{t "tab."}} {{t "The banner below is shown across the top of the header whenever one is chosen."}}</p>
- {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "sub" .Blog.Subdomain "images" .Data.images "label" (t "Header image (banner)"))}}
+ {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "sub" $sub "images" .Data.images "names" .Data.imageNames "label" (t "Banner image") "hint" (t "Shown across the top of the header whenever one is chosen."))}}
+ <div class="row"><label>{{t "Banner height"}}<br><select name="header_image_height">
+ <option value="short"{{if eq $t.HeaderImageHeight "short"}} selected{{end}}>{{t "Short"}}</option>
+ <option value="medium"{{if eq $t.HeaderImageHeight "medium"}} selected{{end}}>{{t "Medium"}}</option>
+ <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>{{t "Tall"}}</option>
+ </select></label></div>
+ {{template "arealist" (dict "area" "header" "root" . "hint" (t "Stack the title, a logo, the menu and a search box in the order you like."))}}
</div>
<div class="card" id="menu">
<h2>{{t "Menu"}}</h2>
- <p class="muted small">{{t "Which links the menu shows, and where it sits, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#menu">{{t "Layout"}}</a> {{t "tab."}} {{t "Here you choose how it looks."}}</p>
+ <p class="muted small">{{t "The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking “Show in menu” on a page does the same as adding it here."}}</p>
+ <ul class="modlist plain" id="menulist">
+ {{range .Data.menuRows}}{{template "menurow" (dict "i" .I "item" .Item "pos" .Pos)}}{{end}}
+ </ul>
+ <p class="muted empty"{{if .Data.menuRows}} hidden{{end}}>{{t "The menu is empty."}}</p>
+ <div class="addrow cols">
+ <div>
+ <h3>{{t "Add a page"}}</h3>
+ {{if .Data.freePages}}<label>{{t "Page"}}<br><select name="menu_add_page"><option value="">—</option>{{range .Data.freePages}}<option value="{{.ID}}">{{.Title}}</option>{{end}}</select></label>
+ <button type="button" class="secondary jsonly" data-menu="page">{{t "Add page"}}</button>
+ {{end}}<p class="muted small nopages"{{if .Data.freePages}} hidden{{end}}>{{t "Every page is already in the menu."}}</p>
+ </div>
+ <div>
+ <h3>{{t "Add a link"}}</h3>
+ <label>{{t "Text"}}<br><input name="menu_add_label" maxlength="60" placeholder="{{t "My photos"}}"></label>
+ <label>{{t "Address"}}<br><input name="menu_add_url" maxlength="500" placeholder="https://example.org"></label>
+ <button type="button" class="secondary jsonly" data-menu="link">{{t "Add link"}}</button>
+ </div>
+ </div>
+ <p class="muted small nojs">{{t "A page or link filled in here is added when you save."}}</p>
+ <h3>{{t "How the menu looks"}}</h3>
<div class="row">
<label>{{t "Alignment"}} <span class="muted">({{t "menu in the header"}})</span><br><select name="nav_align">
<option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>{{t "Left"}}</option>
@@ -118,9 +144,6 @@
<option value="normal"{{if eq $t.NavStyle "normal"}} selected{{end}}>{{t "Normal"}}</option>
<option value="uppercase"{{if eq $t.NavStyle "uppercase"}} selected{{end}}>{{t "Uppercase, spaced"}}</option>
</select></label>
- </div>
- <h3>{{t "Menu colours"}}</h3>
- <div class="row">
<label>{{t "Background"}}<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label>
<label>{{t "Text"}}<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label>
</div>
@@ -131,8 +154,22 @@
</div>
</div>
-<div class="card" id="posts">
- <h2>{{t "Posts & dates"}}</h2>
+<div class="card" id="content">
+ <h2>{{t "Content"}}</h2>
+ <p class="muted small">{{t "The box your posts and pages are shown in. It spans whatever the side columns leave."}}</p>
+ <div class="row">
+ <label>{{t "Background"}}<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label>
+ <label>{{t "Box style"}}<br><select name="content_style">
+ <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>{{t "Card (shadow, rounded corners)"}}</option>
+ <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>{{t "Flat"}}</option>
+ </select></label>
+ <label>{{t "Padding"}} <span class="muted">({{t "inside the box"}})</span><br><select name="content_padding">
+ <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>{{t "Compact"}}</option>
+ <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>{{t "Normal"}}</option>
+ <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>{{t "Roomy"}}</option>
+ </select></label>
+ </div>
+ <h3>{{t "Posts"}}</h3>
<label class="check"><input type="checkbox" name="show_dates"{{if $t.ShowDates}} checked{{end}}> {{t "Show the date on posts"}}</label>
<div class="row">
<label>{{t "Date format"}}<br><select name="date_format">
@@ -140,43 +177,315 @@
<option value="short"{{if eq $t.DateFormat "short"}} selected{{end}}>{{postdate "short" .Data.today}}</option>
<option value="iso"{{if eq $t.DateFormat "iso"}} selected{{end}}>{{postdate "iso" .Data.today}}</option>
</select></label>
+ <label>{{t "Posts per page"}}<br><select name="posts_per_page">
+ {{range .Data.perPage}}<option value="{{.}}"{{if eq . $t.PostsPerPage}} selected{{end}}>{{.}}</option>{{end}}
+ </select></label>
</div>
+ <h3>{{t "Around the posts"}}</h3>
+ <p class="muted small">{{t "Your own HTML before and after the posts — an intro box, a newsletter form, a badge…"}}</p>
+ {{template "arealist" (dict "area" "above" "root" .)}}
+ {{template "arealist" (dict "area" "below" "root" .)}}
+</div>
+
+<div class="card" id="columns">
+ <h2>{{t "Side columns"}}</h2>
+ <div class="lay">
+ <div class="lay-row lay-header{{if not $t.HeaderOn}} off{{end}}">{{t "Header"}}</div>
+ <div class="lay-cols">
+ <div class="lay-col lay-side lay-left{{if not $t.LeftOn}} off{{end}}{{if not (index .Data.rows "left")}} empty{{end}}" style="width:{{$t.LeftWidth}}%">{{t "Left"}} <span class="pct">{{$t.LeftWidth}}</span>%</div>
+ <div class="lay-col lay-main" style="width:{{$t.MainWidth}}%">{{t "Main content"}} <span class="pct">{{$t.MainWidth}}</span>%</div>
+ <div class="lay-col lay-side lay-right{{if not $t.RightOn}} off{{end}}{{if not (index .Data.rows "right")}} empty{{end}}" style="width:{{$t.RightWidth}}%">{{t "Right"}} <span class="pct">{{$t.RightWidth}}</span>%</div>
+ </div>
+ <div class="lay-row lay-footer{{if not $t.FooterOn}} off{{end}}">{{t "Footer"}}</div>
+ </div>
+ <div class="row">
+ <label>{{t "Left column width"}}<br><input type="number" name="left_width" value="{{$t.LeftWidth}}" min="15" max="40" step="5"> %</label>
+ <label>{{t "Right column width"}}<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label>
+ </div>
+ <p class="muted small">{{t "Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer."}}</p>
+ <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> {{t "Always keep the columns' space, even when a column is empty or hidden"}} <span class="muted">({{t "the main content then keeps its width and stays centred"}})</span></label>
+ <h3>{{t "Left column"}}</h3>
+ <label class="check"><input type="checkbox" name="left_on" data-area="left"{{if $t.LeftOn}} checked{{end}}> {{t "Show the left column"}}</label>
+ {{template "arealist" (dict "area" "left" "root" . "hint" (t "A narrow column beside your posts. Good for the menu, an archive or recent posts."))}}
+ <h3>{{t "Right column"}}</h3>
+ <label class="check"><input type="checkbox" name="right_on" data-area="right"{{if $t.RightOn}} checked{{end}}> {{t "Show the right column"}}</label>
+ {{template "arealist" (dict "area" "right" "root" . "hint" (t "A narrow column on the other side of your posts. Same choices as the left one."))}}
</div>
<div class="card" id="footer">
<h2>{{t "Footer"}}</h2>
- <p class="muted small">{{t "The footer text, RSS link and other footer modules live on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#footer">{{t "Layout"}}</a> {{t "tab."}}</p>
+ <label class="check"><input type="checkbox" name="footer_on" data-area="footer"{{if $t.FooterOn}} checked{{end}}> {{t "Show the footer"}} <span class="muted">({{t "the bottom of every page"}})</span></label>
<div class="row">
<label>{{t "Background"}}<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label>
<label>{{t "Text colour"}}<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label>
+ <label>{{t "Alignment"}}<br><select name="footer_align">
+ <option value="center"{{if eq $t.FooterAlign "center"}} selected{{end}}>{{t "Centred"}}</option>
+ <option value="left"{{if eq $t.FooterAlign "left"}} selected{{end}}>{{t "Left"}}</option>
+ </select></label>
</div>
+ {{template "arealist" (dict "area" "footer" "root" . "hint" (t "A copyright line, the RSS link, a list of your pages."))}}
</div>
<div class="card" id="logo">
<h2>{{t "Logo & site icon"}}</h2>
- <div class="row">
- <label>{{t "Logo size"}}<br><select name="logo_size">
- <option value="small"{{if eq $t.LogoSize "small"}} selected{{end}}>{{t "Small (48 px tall)"}}</option>
- <option value="medium"{{if eq $t.LogoSize "medium"}} selected{{end}}>{{t "Medium (80 px)"}}</option>
- <option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>{{t "Large (140 px)"}}</option>
- </select></label>
- </div>
- {{template "imagepick" (dict "name" "logo" "current" $t.Logo "sub" .Blog.Subdomain "images" .Data.images "label" (t "Logo") "hint" (t "Shown wherever you place a Logo module (Layout tab); it links to your front page."))}}
- {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "sub" .Blog.Subdomain "images" .Data.images "label" (t "Favicon") "hint" (t "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon.") "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}}
+ {{template "imagepick" (dict "name" "logo" "current" $t.Logo "sub" $sub "images" .Data.images "names" .Data.imageNames "label" (t "Logo") "hint" (t "Shown wherever you place a Logo module; it links to your front page."))}}
+ <div class="row"><label>{{t "Logo size"}}<br><select name="logo_size">
+ <option value="small"{{if eq $t.LogoSize "small"}} selected{{end}}>{{t "Small (48 px tall)"}}</option>
+ <option value="medium"{{if eq $t.LogoSize "medium"}} selected{{end}}>{{t "Medium (80 px)"}}</option>
+ <option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>{{t "Large (140 px)"}}</option>
+ </select></label></div>
+ {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "sub" $sub "images" .Data.images "names" .Data.imageNames "label" (t "Favicon") "hint" (t "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon.") "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}}
</div>
<div class="savebar">
- <button type="submit">{{t "Save design"}}</button>
- <a class="btn secondary" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} &#8599;</a>
+ <button type="submit">{{t "Save"}}</button>
+ <a class="btn secondary" href="/b/{{$sub}}/design" id="discard">{{t "Discard changes"}}</a>
+ <span class="unsaved" hidden>{{t "Unsaved changes"}}</span>
+ <a class="viewlink" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} &#8599;</a>
</div>
+
+{{/* Row templates the script clones: one per area and kind, and the two menu entries. */}}
+{{range .Data.templates}}<template id="tpl-{{.Area}}-{{.Kind}}">{{template "modrow" (dict "i" "__I__" "m" .M "pos" 0 "root" $)}}</template>
+{{end}}<template id="tpl-menu-page">{{template "menurow" (dict "i" "__I__" "item" .Data.pageItem "pos" 0)}}</template>
+<template id="tpl-menu-link">{{template "menurow" (dict "i" "__I__" "item" .Data.linkItem "pos" 0)}}</template>
</form>
<details class="help reset">
<summary>{{t "Reset to defaults"}}</summary>
<p>{{t "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library)."}}</p>
- <form method="post" action="/b/{{.Blog.Subdomain}}/design/reset">
+ <form method="post" action="/b/{{$sub}}/design/reset">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<button type="submit" class="danger">{{t "Reset everything"}}</button>
</form>
</details>
+</div>
+
+{{/* The library panel: filled by the script from /files?kind=image as JSON, one page at a time. */}}
+<div class="picker" id="picker" hidden>
+ <div class="picker-box">
+ <div class="picker-head">
+ <input type="search" placeholder="{{t "Filter by name…"}}" aria-label="{{t "Filter by name…"}}">
+ <span class="muted small pcount">{{tf "%d images" .Data.imageCount}}</span>
+ <button type="button" class="secondary small" data-pick="close">{{t "Close"}}</button>
+ </div>
+ <div class="picks"></div>
+ <p class="muted small pnone" hidden>{{t "No images match."}}</p>
+ <div class="pager"><button type="button" class="mini" data-pick="prev">&larr; {{t "Newer"}}</button> <span class="pnum"></span> <button type="button" class="mini" data-pick="next">{{t "Older"}} &rarr;</button></div>
+ </div>
+</div>
+
+<script>
+(function () {
+ var form = document.getElementById("design");
+ if (!form || !("content" in document.createElement("template")) || !form.classList || !form.closest) return;
+ form.className += " js";
+ var next = +form.getAttribute("data-next") || 0, dirty = false, sending = false;
+ function q(sel, el) { return (el || document).querySelector(sel); }
+ function qa(sel, el) { return Array.prototype.slice.call((el || document).querySelectorAll(sel)); }
+ function touch() { dirty = true; q(".unsaved").hidden = false; }
+ form.addEventListener("input", touch);
+ form.addEventListener("change", touch);
+ window.addEventListener("beforeunload", function (e) { if (dirty && !sending) { e.preventDefault(); e.returnValue = ""; } });
+ q("#discard").addEventListener("click", function (e) { if (dirty && !confirm({{t "Throw away your unsaved changes?"}})) e.preventDefault(); else dirty = false; });
+
+ // ---- module and menu rows: order, remove, add
+ function rows(list) { return qa(".modrow", list); }
+ function refresh(list) { // the empty note and the "add" control of a one-module area
+ var n = rows(list).length, card = list.parentNode;
+ var empty = q(".empty", card); if (empty) empty.hidden = n > 0;
+ var add = q(".addmod", card); if (add && list.getAttribute("data-max")) add.hidden = n >= +list.getAttribute("data-max");
+ }
+ function clone(id) { // a row template with a fresh index in its field names
+ var tpl = document.getElementById(id);
+ if (!tpl) return null;
+ var frag = document.importNode(tpl.content, true), row = q(".modrow", frag), i = next++;
+ qa("[name]", row).forEach(function (el) { el.name = el.name.replace("__I__", i); });
+ return row;
+ }
+ form.addEventListener("click", function (e) {
+ var b = e.target.closest("[data-act],[data-menu],[data-pick],.preset");
+ if (!b) return;
+ var row = b.closest(".modrow"), list = row && row.parentNode;
+ switch (b.getAttribute("data-act")) {
+ case "up": if (row.previousElementSibling) list.insertBefore(row, row.previousElementSibling); touch(); return;
+ case "down": if (row.nextElementSibling) list.insertBefore(row.nextElementSibling, row); touch(); return;
+ case "remove":
+ if (row.getAttribute("data-confirm") && !confirm({{t "Remove this module and its text?"}})) return;
+ list.removeChild(row); refresh(list); touch(); return;
+ }
+ switch (b.getAttribute("data-menu")) {
+ case "page":
+ var sel = q("[name=menu_add_page]"), opt = sel && sel.options[sel.selectedIndex];
+ if (!opt || !opt.value) return;
+ var pr = clone("tpl-menu-page");
+ q("[data-field=page]", pr).value = opt.value; q(".modname", pr).textContent = opt.text;
+ q("#menulist").appendChild(pr); sel.removeChild(opt); sel.selectedIndex = 0;
+ q(".nopages", sel.parentNode).hidden = sel.options.length > 1;
+ refresh(q("#menulist")); touch(); return;
+ case "link":
+ var lab = q("[name=menu_add_label]"), url = q("[name=menu_add_url]");
+ if (!lab.value.trim() || !url.value.trim()) { (lab.value.trim() ? url : lab).focus(); return; }
+ var lr = clone("tpl-menu-link");
+ q("[data-field=label]", lr).value = lab.value.trim(); q("[data-field=url]", lr).value = url.value.trim();
+ q("#menulist").appendChild(lr); lab.value = url.value = "";
+ refresh(q("#menulist")); touch(); return;
+ }
+ if (b.className.indexOf("preset") >= 0) { // fill the colour inputs; nothing is saved yet
+ var c = JSON.parse(b.getAttribute("data-colors"));
+ for (var k in c) { var inp = q("[name=" + k + "]", form); if (inp) inp.value = c[k]; }
+ q("[name=link_hover_custom]").checked = q("[name=nav_hover_custom]").checked = false;
+ touch(); return;
+ }
+ pickAction(b);
+ });
+ form.addEventListener("change", function (e) {
+ var t = e.target;
+ if (t.getAttribute("data-add") && t.value) { // a chosen kind becomes a row at once
+ var list = q(".modlist", t.closest(".arealist")), row = clone("tpl-" + list.getAttribute("data-area") + "-" + t.value);
+ if (row) { list.appendChild(row); var d = q("details", row); if (d) d.open = true; refresh(list); }
+ t.selectedIndex = 0;
+ }
+ if (t.getAttribute("data-area")) q(".arealist[data-area=" + t.getAttribute("data-area") + "]").classList.toggle("area-off", !t.checked);
+ });
+ // a heading or text typed into a row shows in its folded summary
+ form.addEventListener("input", function (e) {
+ var t = e.target, row = t.closest(".modrow");
+ if (!row || !t.getAttribute("data-sum")) return;
+ var s = q(".modsum", row); if (s) s.textContent = t.value.replace(/\s+/g, " ").slice(0, 60);
+ });
+ qa(".modlist").forEach(refresh);
+ // the column diagram follows the width boxes
+ function widths() {
+ var l = +q("[name=left_width]").value || 0, r = +q("[name=right_width]").value || 0, m = 100 - l - r;
+ q(".lay-left").style.width = l + "%"; q(".lay-right").style.width = r + "%"; q(".lay-main").style.width = m + "%";
+ q(".lay-left .pct").textContent = l; q(".lay-right .pct").textContent = r; q(".lay-main .pct").textContent = m;
+ }
+ q("[name=left_width]").addEventListener("input", widths);
+ q("[name=right_width]").addEventListener("input", widths);
+ // the side index marks the section in view
+ var cards = qa(".card[id]", form), links = qa(".sidenav a"), spy;
+ function section() {
+ var y = window.pageYOffset + 120, at = cards[0];
+ cards.forEach(function (c) { if (c.offsetTop <= y) at = c; });
+ return at.id;
+ }
+ function mark() { var id = section(); links.forEach(function (a) { a.className = a.getAttribute("href") === "#" + id ? "active" : ""; }); }
+ window.addEventListener("scroll", function () { clearTimeout(spy); spy = setTimeout(mark, 80); });
+ mark();
+
+ form.addEventListener("submit", function () {
+ sending = true;
+ qa(".modlist").forEach(function (list) { rows(list).forEach(function (r, i) { q(".pos", r).value = i; }); });
+ q("[name=at]").value = section(); // so the page comes back to the section in view
+ });
+
+ // ---- the image library panel: one shared panel, fetched page by page
+ var panel = document.getElementById("picker"), pick = null, page = 1, filter = "", timer;
+ if (!window.fetch) { qa(".imagepick .jsonly").forEach(function (el) { el.hidden = true; }); qa(".imagepick select").forEach(function (el) { el.className = ""; }); return; }
+ function pickAction(b) {
+ var act = b.getAttribute("data-pick"), ip = b.closest(".imagepick");
+ if (act === "open") { pick = ip; panel.hidden = false; q("input", panel).value = filter = ""; load(1); q("input", panel).focus(); }
+ else if (act === "clear") { choose(ip, "none", ""); }
+ }
+ panel.addEventListener("click", function (e) {
+ var b = e.target.closest("[data-pick],.pick");
+ if (!b) return;
+ if (b.className.indexOf("pick") === 0) { choose(pick, b.getAttribute("data-id"), b.getAttribute("data-name")); panel.hidden = true; return; }
+ switch (b.getAttribute("data-pick")) {
+ case "close": panel.hidden = true; break;
+ case "prev": load(page - 1); break;
+ case "next": load(page + 1); break;
+ }
+ });
+ panel.addEventListener("keydown", function (e) { if (e.key === "Escape") panel.hidden = true; });
+ q("input", panel).addEventListener("input", function () { clearTimeout(timer); timer = setTimeout(function () { filter = q("input", panel).value.trim(); load(1); }, 250); });
+ function load(n) {
+ var url = "/b/" + form.getAttribute("data-sub") + "/files?kind=image&p=" + n + (filter ? "&q=" + encodeURIComponent(filter) : "");
+ fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" }).then(function (r) { return r.json(); }).then(function (d) {
+ page = d.page;
+ var grid = q(".picks", panel); grid.innerHTML = "";
+ (d.files || []).forEach(function (f) {
+ var b = document.createElement("button"), img = document.createElement("img"), s = document.createElement("span");
+ b.type = "button"; b.className = "pick"; b.setAttribute("data-id", f.id); b.setAttribute("data-name", f.filename);
+ img.loading = "lazy"; img.src = "/b/" + form.getAttribute("data-sub") + "/media/" + f.id; img.alt = "";
+ s.className = "name"; s.textContent = f.filename;
+ b.appendChild(img); b.appendChild(s); grid.appendChild(b);
+ });
+ q(".pnone", panel).hidden = !!(d.files && d.files.length);
+ q(".pnum", panel).textContent = d.last > 1 ? d.page + " / " + d.last : "";
+ q("[data-pick=prev]", panel).disabled = d.page <= 1; q("[data-pick=next]", panel).disabled = d.page >= d.last;
+ });
+ }
+ function choose(ip, id, name) { // the select stays the value the form sends
+ var sel = q("select", ip), pv = q(".preview", ip), found = false;
+ for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === id) found = true;
+ if (!found) { var o = document.createElement("option"); o.value = id; o.textContent = name; sel.appendChild(o); }
+ sel.value = id;
+ pv.innerHTML = id === "none" ? '<span class="swatch">&times;</span>' : "";
+ if (id !== "none") { var img = document.createElement("img"); img.src = "/b/" + form.getAttribute("data-sub") + "/media/" + id; img.alt = ""; pv.appendChild(img); }
+ touch();
+ }
+})();
+</script>
{{end}}
+
+{{/* One area's modules: the list, its empty note and the "add" select. */}}
+{{define "arealist"}}{{$r := .root}}{{$area := .area}}{{$rows := index $r.Data.rows $area}}{{$single := index $r.Data.single $area}}
+<div class="arealist{{if not ($r.Data.theme.AreaOn $area)}} area-off{{end}}" data-area="{{$area}}">
+ {{if $single}}<h4>{{t (index $r.Data.areaNames $area)}}</h4>{{end}}
+ {{with .hint}}<p class="muted small">{{.}}</p>{{end}}
+ <ul class="modlist plain" data-area="{{$area}}"{{if $single}} data-max="1"{{end}}>
+ {{range $rows}}{{template "modrow" (dict "i" .I "m" .M "pos" .Pos "root" $r)}}{{end}}
+ </ul>
+ <p class="muted small empty"{{if $rows}} hidden{{end}}>{{if $single}}{{t "Nothing extra."}}{{else if eq $area "header"}}{{t "Nothing here yet."}}{{else}}{{t "Nothing here yet — this area is not shown until you add a module."}}{{end}}</p>
+ <div class="addmod"{{if and $single $rows}} hidden{{end}}>
+ <label>{{if $single}}{{t "Add custom HTML"}}{{else}}{{t "Add a module"}}{{end}}<br><select name="add.{{$area}}" data-add="1"><option value="">—</option>{{range index $r.Data.kinds $area}}<option value="{{.}}">{{t (index $r.Data.names .)}}</option>{{end}}</select></label>
+ </div>
+</div>{{end}}
+
+{{/* One module row. .i is the field index (or __I__ in a template), .m the module. */}}
+{{define "modrow"}}{{$i := .i}}{{$m := .m}}{{$r := .root}}
+<li class="modrow" data-kind="{{$m.Kind}}"{{if $m.Body}} data-confirm="1"{{end}}>
+ <input type="hidden" name="mod.{{$i}}.id" value="{{$m.ID}}"><input type="hidden" name="mod.{{$i}}.area" value="{{$m.Area}}"><input type="hidden" name="mod.{{$i}}.kind" value="{{$m.Kind}}">
+ <span class="order"><button type="button" class="mini jsonly" data-act="up" title="{{t "Move up"}}">&#9650;</button><button type="button" class="mini jsonly" data-act="down" title="{{t "Move down"}}">&#9660;</button><input type="number" class="nojs pos" name="mod.{{$i}}.pos" value="{{.pos}}" min="0" title="{{t "Position"}}"></span>
+ <span class="modname">{{t (index $r.Data.names $m.Kind)}}</span>
+ {{if and (eq $m.Kind "logo") (not $r.Data.theme.Logo)}}<span class="tag off">{{t "no logo chosen"}}</span> <a class="small" href="#logo">{{t "pick one"}}</a>{{end}}
+ <span class="modact"><label class="check nojs"><input type="checkbox" name="mod.{{$i}}.del"> {{t "Remove"}}</label><button type="button" class="mini danger jsonly" data-act="remove" title="{{t "Remove"}}">&#10005;</button></span>
+ {{if modsettings $m}}<details class="modset">
+ <summary>{{t "Settings"}} <span class="muted modsum">{{modsummary $m}}</span></summary>
+ {{if or (ne $m.Kind "html") (and (ne $m.Area "above") (ne $m.Area "below"))}}{{if ne $m.Kind "text"}}
+ <label>{{t "Heading"}} <span class="muted">({{t "optional"}})</span><br><input name="mod.{{$i}}.title" value="{{$m.Title}}" maxlength="120" data-sum="1"></label>
+ {{end}}{{end}}
+ {{if eq $m.Kind "recent"}}
+ <div class="row"><label>{{t "How many posts"}}<br><select name="mod.{{$i}}.count">{{range $n := $r.Data.counts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div>
+ <p class="muted small">{{t "The newest published posts from every page of your blog, newest first."}}</p>
+ {{else if eq $m.Kind "archive"}}
+ <p class="muted small">{{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}</p>
+ {{else if eq $m.Kind "sitemap"}}
+ <p class="muted small">{{t "Links to every page of your blog."}}</p>
+ {{else if eq $m.Kind "tags"}}
+ <div class="row"><label>{{t "How many tags"}}<br><select name="mod.{{$i}}.count"><option value="0"{{if eq $m.Count 0}} selected{{end}}>{{t "All"}}</option>{{range $n := $r.Data.tagCounts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div>
+ <p class="muted small">{{t "Your tags with the number of posts in each, most used first. Only published posts count."}}</p>
+ {{else if eq $m.Kind "tagcloud"}}
+ <p class="muted small">{{t "Every tag in alphabetical order, bigger the more posts use it."}}</p>
+ {{else if eq $m.Kind "search"}}
+ <p class="muted small">{{t "A box that searches the published posts of every page by title and text; the results open on your blog’s search page. Case does not matter, and several words find posts that contain them in that order, with anything in between."}}</p>
+ {{else if eq $m.Kind "html"}}
+ <label>HTML<br><textarea name="mod.{{$i}}.body" rows="6" class="mono" spellcheck="false" placeholder="&lt;p&gt;{{t "Hello!"}}&lt;/p&gt;" data-sum="1">{{$m.Body}}</textarea></label>
+ <p class="muted small">{{t "Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most."}}</p>
+ {{else if eq $m.Kind "text"}}
+ <label>{{t "Text"}}<br><textarea name="mod.{{$i}}.body" rows="3" placeholder="{{t "© 2026 Me"}}" data-sum="1">{{$m.Body}}</textarea></label>
+ <p class="muted small">{{t "Plain text; each line becomes its own paragraph."}}</p>
+ {{end}}
+ </details>{{end}}
+</li>{{end}}
+
+{{/* One menu entry: a page (fixed) or a link (text and address editable). */}}
+{{define "menurow"}}{{$i := .i}}{{$it := .item}}
+<li class="modrow menurow">
+ <input type="hidden" name="menu.{{$i}}.id" value="{{$it.ID}}">
+ <span class="order"><button type="button" class="mini jsonly" data-act="up" title="{{t "Move up"}}">&#9650;</button><button type="button" class="mini jsonly" data-act="down" title="{{t "Move down"}}">&#9660;</button><input type="number" class="nojs pos" name="menu.{{$i}}.pos" value="{{.pos}}" min="0" title="{{t "Position"}}"></span>
+ {{if $it.PageID}}<input type="hidden" name="menu.{{$i}}.page" value="{{deref64 $it.PageID}}" data-field="page"><span class="modname">{{$it.PageTitle}}</span> <span class="tag">{{t "page"}}</span>
+ {{else}}<span class="tag">{{t "link"}}</span><span class="linkfields"><input name="menu.{{$i}}.label" value="{{$it.Label}}" maxlength="60" placeholder="{{t "Text"}}" data-field="label"><input name="menu.{{$i}}.url" value="{{$it.URL}}" maxlength="500" placeholder="https://example.org" data-field="url"></span>{{end}}
+ <span class="modact"><label class="check nojs"><input type="checkbox" name="menu.{{$i}}.del"> {{t "Remove"}}</label><button type="button" class="mini danger jsonly" data-act="remove" title="{{t "Remove"}}">&#10005;</button></span>
+</li>{{end}}
diff --git a/internal/web/templates/dashboard/layout.html b/internal/web/templates/dashboard/layout.html
deleted file mode 100644
index 5c9332a..0000000
--- a/internal/web/templates/dashboard/layout.html
+++ /dev/null
@@ -1,113 +0,0 @@
-{{define "title"}}{{t "Layout"}} · {{.Blog.Title}}{{end}}
-{{define "content"}}
-{{$t := .Data.theme}}
-{{$u := print "/b/" .Blog.Subdomain "/layout"}}
-<div class="pagehead">
- <h1>{{t "Layout"}}</h1>
- <p class="lead muted">{{t "Decide what goes where on your blog. Every change here is saved straight away; refresh"}} <a href="{{.BlogURL}}" target="_blank">{{t "your blog"}} &#8599;</a> {{t "to see it."}} {{t "Colours and fonts are on the"}} <a href="/b/{{.Blog.Subdomain}}/design">{{t "Design"}}</a> {{t "tab."}}</p>
-</div>
-<p class="jumps"><a href="#columns">{{t "Columns"}}</a><a href="#header">{{t "Header"}}</a><a href="#left">{{t "Left column"}}</a><a href="#main">{{t "Main content"}}</a><a href="#right">{{t "Right column"}}</a><a href="#footer">{{t "Footer"}}</a><a href="#menu">{{t "Menu"}}</a></p>
-
-<div class="card" id="columns">
- <h2>{{t "Columns"}}</h2>
- <div class="lay">
- <div class="lay-row lay-header{{if not $t.HeaderOn}} off{{end}}">{{t "Header"}}{{if not $t.HeaderOn}} ({{t "hidden"}}){{end}}</div>
- <div class="lay-cols">
- <div class="lay-col lay-side{{if not $t.LeftOn}} off{{end}}{{if not (index .Data.areas "left")}} empty{{end}}" style="width:{{$t.LeftWidth}}%">{{t "Left"}} {{$t.LeftWidth}}%</div>
- <div class="lay-col lay-main" style="width:{{$t.MainWidth}}%">{{t "Main content"}} {{$t.MainWidth}}%</div>
- <div class="lay-col lay-side{{if not $t.RightOn}} off{{end}}{{if not (index .Data.areas "right")}} empty{{end}}" style="width:{{$t.RightWidth}}%">{{t "Right"}} {{$t.RightWidth}}%</div>
- </div>
- <div class="lay-row lay-footer{{if not $t.FooterOn}} off{{end}}">{{t "Footer"}}{{if not $t.FooterOn}} ({{t "hidden"}}){{end}}</div>
- </div>
- <form method="post" action="{{$u}}/widths">
- <input type="hidden" name="_csrf" value="{{.CSRF}}">
- <div class="row">
- <label>{{t "Left column width"}}<br><input type="number" name="left_width" value="{{$t.LeftWidth}}" min="15" max="40" step="5"> %</label>
- <label>{{t "Right column width"}}<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label>
- </div>
- <p class="muted small">{{t "Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer."}}</p>
- <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> {{t "Always keep the columns' space, even when a column is empty or hidden"}} <span class="muted">({{t "the main content then keeps its width and stays centred"}})</span></label>
- <p class="actionrow"><button type="submit">{{t "Save columns"}}</button></p>
- </form>
-</div>
-
-{{template "area" (dict "area" "header" "root" . "hint" (t "The top of every page. Stack the title, a logo and the menu in the order you like. A banner image can be chosen on the Design tab."))}}
-{{template "area" (dict "area" "left" "root" . "hint" (t "A narrow column beside your posts. Good for the menu, an archive or recent posts."))}}
-
-<div class="card" id="main">
- <div class="cardhead"><h2>{{t "Main content"}}</h2></div>
- <p class="muted small">{{t "Your posts and pages always show here. You can add your own HTML before and after them — an intro box, a newsletter form, a badge…"}}</p>
- <table class="modules">
- {{range $area := .Data.areaOrder}}{{$mods := index $.Data.areas $area}}
- <tr><th>{{t (index $.Data.areaNames $area)}}</th>
- {{if $mods}}{{$m := index $mods 0}}
- <td>{{t (index $.Data.names $m.Kind)}}{{with modsummary $m}} <span class="muted">— {{.}}</span>{{end}}</td>
- <td class="nowrap actions"><a href="{{$u}}/modules/{{$m.ID}}/edit">{{t "edit"}}</a> <a class="danger" href="{{$u}}/modules/{{$m.ID}}/delete">{{t "remove"}}</a></td>
- {{else}}
- <td class="muted">{{t "Nothing extra."}}</td>
- <td class="nowrap actions"><form method="post" action="{{$u}}/areas/{{$area}}/add" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="kind" value="html"><button class="mini">+ {{t "Add custom HTML"}}</button></form></td>
- {{end}}
- </tr>{{end}}
- </table>
-</div>
-
-{{template "area" (dict "area" "right" "root" . "hint" (t "A narrow column on the other side of your posts. Same choices as the left one."))}}
-{{template "area" (dict "area" "footer" "root" . "hint" (t "The bottom of every page: a copyright line, the RSS link, a list of your pages."))}}
-
-<div class="card" id="menu">
- <h2>{{t "Menu"}}</h2>
- <p class="muted small">{{t "The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking “Show in menu” on a page does the same as adding it here."}}</p>
- {{if .Data.menu}}<table class="modules">
- {{range .Data.menu}}<tr>
- <td class="nowrap order">
- <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">&#9650;</button></form>
- <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">&#9660;</button></form>
- </td>
- <td>{{.Text}} {{if .PageID}}<span class="tag">{{t "page"}}</span>{{else}}<span class="tag">{{t "link"}}</span> <span class="muted small">{{.URL}}</span>{{end}}</td>
- <td class="nowrap actions"><form method="post" action="{{$u}}/menu/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">{{t "Remove"}}</button></form></td>
- </tr>{{end}}
- </table>{{else}}<p class="muted">{{t "The menu is empty."}}</p>{{end}}
- <div class="cols addrow">
- <form method="post" action="{{$u}}/menu/add-page" class="add">
- <input type="hidden" name="_csrf" value="{{.CSRF}}">
- <h3>{{t "Add a page"}}</h3>
- {{if .Data.freePages}}<label>{{t "Page"}}<br><select name="page">{{range .Data.freePages}}<option value="{{.ID}}">{{.Title}}</option>{{end}}</select></label>
- <button type="submit" class="secondary">{{t "Add page"}}</button>
- {{else}}<p class="muted small">{{t "Every page is already in the menu."}}</p>{{end}}
- </form>
- <form method="post" action="{{$u}}/menu/add-link" class="add">
- <input type="hidden" name="_csrf" value="{{.CSRF}}">
- <h3>{{t "Add a link"}}</h3>
- <label>{{t "Text"}}<br><input name="label" maxlength="60" placeholder="{{t "My photos"}}"></label>
- <label>{{t "Address"}}<br><input name="url" maxlength="500" placeholder="https://example.org"></label>
- <button type="submit" class="secondary">{{t "Add link"}}</button>
- </form>
- </div>
-</div>
-{{end}}
-
-{{/* One area card: on/off switch, its modules in order, and an "add" form. */}}
-{{define "area"}}{{$r := .root}}{{$area := .area}}{{$u := print "/b/" $r.Blog.Subdomain "/layout"}}{{$on := $r.Data.theme.AreaOn $area}}{{$mods := index $r.Data.areas $area}}
-<div class="card{{if not $on}} area-off{{end}}" id="{{$area}}">
- <div class="cardhead">
- <h2>{{t (index $r.Data.areaNames $area)}}{{if not $on}} <span class="tag off">{{t "hidden"}}</span>{{end}}</h2>
- <form method="post" action="{{$u}}/areas/{{$area}}/toggle" class="inline switch"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><button class="mini">{{if $on}}{{t "Hide this area"}}{{else}}{{t "Show this area"}}{{end}}</button></form>
- </div>
- <p class="muted small">{{.hint}}</p>
- {{if $mods}}<table class="modules">
- {{range $mods}}<tr>
- <td class="nowrap order">
- <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">&#9650;</button></form>
- <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">&#9660;</button></form>
- </td>
- <td>{{t (index $r.Data.names .Kind)}}{{with modsummary .}} <span class="muted">— {{.}}</span>{{end}}
- {{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}<span class="tag off">{{t "no logo chosen"}}</span> <span class="muted small">{{t "pick one on the"}} <a href="/b/{{$r.Blog.Subdomain}}/design#logo">{{t "Design"}}</a> {{t "tab."}}</span>{{end}}</td>
- <td class="nowrap actions">{{if modsettings .}}<a href="{{$u}}/modules/{{.ID}}/edit">{{t "edit"}}</a> {{end}}<a class="danger" href="{{$u}}/modules/{{.ID}}/delete">{{t "remove"}}</a></td>
- </tr>{{end}}
- </table>{{else}}<p class="muted">{{if ne $area "header"}}{{t "Nothing here yet — this area is not shown until you add a module."}}{{else}}{{t "Nothing here yet."}}{{end}}</p>{{end}}
- <form method="post" action="{{$u}}/areas/{{$area}}/add" class="addmod">
- <input type="hidden" name="_csrf" value="{{$r.CSRF}}">
- <label>{{t "Add a module"}}<br><select name="kind">{{range index $r.Data.kinds $area}}<option value="{{.}}">{{t (index $r.Data.names .)}}</option>{{end}}</select></label>
- <button type="submit" class="secondary">{{t "Add"}}</button>
- </form>
-</div>{{end}}
diff --git a/internal/web/templates/dashboard/module_form.html b/internal/web/templates/dashboard/module_form.html
deleted file mode 100644
index a0fe95b..0000000
--- a/internal/web/templates/dashboard/module_form.html
+++ /dev/null
@@ -1,41 +0,0 @@
-{{define "title"}}{{.Data.name}} · {{.Blog.Title}}{{end}}
-{{define "content"}}
-{{$m := .Data.module}}
-<div class="card">
- <h1>{{.Data.name}} <span class="muted small">{{tf "in the %s" (lower .Data.area)}}</span></h1>
- <form method="post">
- <input type="hidden" name="_csrf" value="{{.CSRF}}">
- {{if or (eq $m.Kind "recent") (eq $m.Kind "archive") (eq $m.Kind "sitemap") (eq $m.Kind "tags") (eq $m.Kind "tagcloud") (eq $m.Kind "search") (and (eq $m.Kind "html") (or (eq $m.Area "left") (eq $m.Area "right") (eq $m.Area "footer")))}}
- <label>{{t "Heading"}} <span class="muted">({{t "optional"}})</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}{{t "Recent posts"}}{{else if eq $m.Kind "archive"}}{{t "Archive"}}{{else if eq $m.Kind "sitemap"}}{{t "Pages"}}{{else if eq $m.Kind "tags"}}{{t "Tags"}}{{else if eq $m.Kind "tagcloud"}}{{t "Tag cloud"}}{{else if eq $m.Kind "search"}}{{t "Search"}}{{end}}"></label>
- {{end}}
- {{if eq $m.Kind "recent"}}
- <div class="row"><label>{{t "How many posts"}}<br><select name="count">{{range $n := .Data.counts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div>
- <p class="muted small">{{t "The newest published posts from every page of your blog, newest first."}}</p>
- {{else if eq $m.Kind "archive"}}
- <p class="muted small">{{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}</p>
- {{else if eq $m.Kind "sitemap"}}
- <p class="muted small">{{t "Links to every page of your blog."}}</p>
- {{else if eq $m.Kind "tags"}}
- <div class="row"><label>{{t "How many tags"}}<br><select name="count"><option value="0"{{if eq $m.Count 0}} selected{{end}}>{{t "All"}}</option>{{range $n := .Data.tagCounts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div>
- <p class="muted small">{{t "Your tags with the number of posts in each, most used first. Only published posts count."}}</p>
- {{else if eq $m.Kind "tagcloud"}}
- <p class="muted small">{{t "Every tag in alphabetical order, bigger the more posts use it."}}</p>
- {{else if eq $m.Kind "search"}}
- <p class="muted small">{{t "A box that searches the published posts of every page by title and text; the results open on your blog’s search page. Case does not matter, and several words find posts that contain them in that order, with anything in between."}}</p>
- {{else if eq $m.Kind "html"}}
- <label for="mod-body">HTML</label>
- <textarea id="mod-body" name="body" rows="14" class="mono" spellcheck="false" placeholder="&lt;p&gt;{{t "Hello!"}}&lt;/p&gt;">{{$m.Body}}</textarea>
- <p class="muted small">{{t "Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most."}}</p>
- {{else if eq $m.Kind "text"}}
- <label for="mod-body">{{t "Text"}}</label>
- <textarea id="mod-body" name="body" rows="4" placeholder="{{t "© 2026 Me"}}">{{$m.Body}}</textarea>
- <p class="muted small">{{t "Plain text; each line becomes its own paragraph."}}</p>
- {{end}}
- <p class="actionrow">
- <button type="submit">{{t "Save"}}</button>
- <a class="btn secondary" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} &#8599;</a>
- <a href="/b/{{.Blog.Subdomain}}/layout#{{if or (eq $m.Area "above") (eq $m.Area "below")}}main{{else}}{{$m.Area}}{{end}}">{{t "Back to layout"}}</a>
- </p>
- </form>
-</div>
-{{end}}
diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html
index 0f611cd..e239445 100644
--- a/internal/web/templates/dashboard/overview.html
+++ b/internal/web/templates/dashboard/overview.html
@@ -7,7 +7,7 @@
<div class="card">
<h2>{{t "Quick actions"}}</h2>
<p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">{{t "Write a new post"}}</a></p>
- <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Arrange the layout"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/files">{{t "Upload files"}}</a></p>
+ <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/files">{{t "Upload files"}}</a></p>
</div>
<div class="card">
<h2>{{t "Pages"}}</h2>
diff --git a/internal/web/templates/dashboard/pages.html b/internal/web/templates/dashboard/pages.html
index cb40eb4..6c3b56d 100644
--- a/internal/web/templates/dashboard/pages.html
+++ b/internal/web/templates/dashboard/pages.html
@@ -3,7 +3,7 @@
<div class="pagehead">
<h1>{{t "Pages"}}</h1>
<div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/pages/new">+ {{t "New page"}}</a></div>
- <p class="lead muted">{{t "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#menu">{{t "Layout"}}</a> {{t "tab."}}</p>
+ <p class="lead muted">{{t "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/design#menu">{{t "Design"}}</a> {{t "tab."}}</p>
</div>
<div class="card">
<table>
diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html
index d5b4d25..0171aaa 100644
--- a/internal/web/templates/dashboard/section_form.html
+++ b/internal/web/templates/dashboard/section_form.html
@@ -22,7 +22,7 @@
<option value="plain"{{if eq $s.Style "plain"}} selected{{end}}>{{t "Plain"}}</option>
</select></label>
</div>
- <p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a>) {{t "the announcement moves to the main content instead."}}</p>
+ <p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/design#columns">{{t "Design"}}</a>) {{t "the announcement moves to the main content instead."}}</p>
<label for="ed-body">{{t "Text"}}</label>
{{template "editor" (dict "name" "body" "value" $s.BodyMD "format" $s.Format "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
<label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> {{t "Shown on the blog"}}</label>
diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html
index 40ca33f..5482e65 100644
--- a/internal/web/templates/partials/dashnav.html
+++ b/internal/web/templates/partials/dashnav.html
@@ -21,7 +21,6 @@
<a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>{{t "Posts"}}</a>
<a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>{{t "Pages"}}</a>
<a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>{{t "Announcements"}}</a>
- <a href="{{$b}}layout"{{if hasPrefix .Path (print $b "layout")}} class="active"{{end}}>{{t "Layout"}}</a>
<a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>{{t "Design"}}</a>
<a href="{{$b}}files"{{if hasPrefix .Path (print $b "files")}} class="active"{{end}}>{{t "Files"}}</a>
<a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>{{t "Settings"}}</a>
diff --git a/internal/web/templates/partials/imagepick.html b/internal/web/templates/partials/imagepick.html
index 911b355..47c2146 100644
--- a/internal/web/templates/partials/imagepick.html
+++ b/internal/web/templates/partials/imagepick.html
@@ -1,9 +1,20 @@
-{{define "imagepick"}}<div class="imagepick">
+{{/* One image field of the design form. The select lists the recent images
+ (plus the chosen one) so it works without the script; with it the select is
+ hidden and "Choose from library…" opens the shared panel at the end of
+ design.html, which fetches the library page by page. */}}
+{{define "imagepick"}}{{$cur := .current}}<div class="imagepick" data-name="{{.name}}">
<label>{{.label}}</label>
{{if .hint}}<p class="muted small">{{.hint}}</p>{{end}}
- <div class="picks">
- <label class="pick none"><input type="radio" name="{{.name}}" value="none"{{if not .current}} checked{{end}}><span class="swatch">&times;</span><span class="name">{{t "None"}}</span></label>
- {{range .images}}<label class="pick"><input type="radio" name="{{$.name}}" value="{{.ID}}"{{if eq .ID.String $.current}} checked{{end}}><img src="/b/{{$.sub}}/media/{{.ID}}" alt=""><span class="name">{{.Filename}}</span></label>{{end}}
+ <div class="pickrow">
+ <span class="preview">{{if $cur}}<img src="/b/{{.sub}}/media/{{$cur}}" alt="">{{else}}<span class="swatch">&times;</span>{{end}}</span>
+ <span class="pickctl">
+ <select name="{{.name}}" class="nojs">
+ <option value="none"{{if not $cur}} selected{{end}}>{{t "None"}}</option>
+ {{range .images}}<option value="{{.ID}}"{{if eq .ID.String $cur}} selected{{end}}>{{.Filename}}</option>{{end}}
+ {{if and $cur (not (index .names $cur))}}<option value="{{$cur}}" selected>{{t "Current image"}}</option>{{end}}
+ </select>
+ <span class="jsonly"><button type="button" class="secondary small" data-pick="open">{{t "Choose from library…"}}</button> <button type="button" class="secondary small" data-pick="clear">{{t "None"}}</button></span>
+ <label class="upload">{{t "…or upload a new one"}}<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label>
+ </span>
</div>
- <label>{{t "…or upload a new one"}}<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label>
</div>{{end}}