diff options
| author | grm <grm@eyesin.space> | 2026-09-16 19:10:13 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-16 19:10:13 +0300 |
| commit | 4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88 (patch) | |
| tree | cc39ea660046d9c977647e7c9c36349c126be214 | |
| parent | 6b6b5d2e35ff182a0732da245f4eb37c8afa0564 (diff) | |
| download | blogspace-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
23 files changed, 1215 insertions, 878 deletions
@@ -39,7 +39,7 @@ table and deployment notes. - Changes are live immediately — there is no draft/preview system. The UX is "save, then refresh your blog tab"; keep the "View blog ↗" links. - Theme customisation is a structured form only; **no custom CSS input**. - The one escape hatch is the **custom HTML module** (Layout tab), which the + The one escape hatch is the **custom HTML module** (Design tab), which the owner chose to store and output **unsanitised**. The blast radius is the blogger's own origin: the session cookie is host-only on the base domain and `HttpOnly`, management POSTs need the HMAC `_csrf` token, and the @@ -63,8 +63,9 @@ internal/i18n/ languages, T/Tf (English keys → catalog), FormatDate/Mon el.go is the Greek catalog, el_months.go the month tables internal/slug/ Make/Valid/WithSuffix (Greek transliteration included) internal/web/ server.go (host router, middleware, render helpers) - routes.go (all routes), handlers_*.go (sections = announcements, layout = modules + menu, - files = the upload library), filetype.go (what an upload is, how it may be served) + routes.go (all routes), handlers_*.go (sections = announcements, design = the one + look-and-layout form, files = the upload library), design_form.go (parsing the + design form's module and menu rows), filetype.go (what an upload is, how it may be served) theme.go (colours/fonts/layout switches), layout.go (module kinds, archive grouping) templates/ (embedded; layouts/, partials/, auth/, dashboard/, admin/, blog/) static/ (dashboard.css — lean look; custom properties, flexbox and grid @@ -136,28 +137,57 @@ internal/web/ server.go (host router, middleware, render helpers) Flash messages travel as `?ok=` query param via `redirectOK` (a `#anchor` in the target is kept after the query). Add new templates to `TestAllTemplatesParse`. -- **Theme** (`web/theme.go`): struct stored as jsonb on `blogs.theme`. +- **Design tab** (`handlers_design.go`, `dashboard/design.html`, + `GET|POST /b/{sub}/design`): the whole look and layout of a blog is **one + form with one Save** — theme values, the area switches and column widths, + the modules of every area (with their settings inline, folded in a + `<details>`) and the menu — stored atomically by `BlogStore.SaveDesign` + (theme + full module list + full menu in one transaction: rows with an id + are updated, without one inserted, missing ones deleted). Sections are cards + with ids (`designSections`) a sticky side index jumps to; the hidden `at` + field says which one was in view so the flash returns there. Module rows are + `mod.<i>.{id,area,kind,title,body,count,pos,del}` and menu rows + `menu.<i>.{id,page,label,url,pos,del}` (`design_form.go`: `parseModules`, + `parseMenu`); `i` only tells rows apart, `pos` orders them. The inline + script reorders rows in the DOM (renumbering `pos` on submit), clones the + `<template id="tpl-<area>-<kind>">` rows to add modules, fills the colour + inputs from a scheme button, tracks unsaved changes (badge, `beforeunload`, + confirm on "Discard changes") — all of it optional: without it `pos` is a + number box, Remove a checkbox, `add.<area>` / `menu_add_page` / + `menu_add_label`+`url` / the `preset` select are applied on save. A + validation error re-renders the form **with what was sent** (the parsers + return every row along with the error; a bad link stays as a row to fix). + `POST /b/{sub}/design/reset` stores `DefaultTheme()` and calls + `ResetModules`. `GET /b/{sub}/layout` (the old tab) redirects to + `/design#columns`. +- **Theme** (`web/theme.go`): struct stored as jsonb on `settings.theme`. `ParseTheme` merges over `DefaultTheme()` and `normalize()` clamps every value to an allowlist (hex colours, enum strings, uuid image ids, column percentages) — this is what makes inlining the generated CSS safe. Add new options in the struct, `normalize`, `ThemeFromForm`, the CSS template and `dashboard/design.html`; defaults must reproduce the look blogs had before the option existed. Non-CSS options (`ShowDates`, `DateFormat` via - `FormatDate`, `Favicon`, `Logo`) are read by the blog templates. + `FormatDate`, `PostsPerPage`, `Favicon`, `Logo`) are read by the blog + templates and handlers. `TitleSize` is emitted inside the wide-screen media + query so blog.css's phone rule keeps winning. Optional colours (`LinkHover`, `NavHover`) are `""` = inherit and come from a colour input paired with a `<name>_custom` checkbox, shown/hidden - with CSS only (`.hoverpick > input:not(:checked) ~ …`). Image fields - use the `imagepick` partial's radios (the library's `kind = image` files, - `ListFiles(ctx, "image", "", 0, 0)`): `none` clears, a uuid selects, no - value keeps (`pickImage`); an upload in `<name>_file` wins. - `Presets()`/`WithPreset` are the colour schemes (`POST /b/{sub}/design/preset`, - colours only); `POST /b/{sub}/design/reset` stores `DefaultTheme()` and - calls `ResetModules`. - The layout switches (`HeaderOn/LeftOn/RightOn/FooterOn`, `LeftWidth`, - `RightWidth`, `KeepColumns`) also live in the theme but are edited from the Layout tab - (`LayoutFromForm`, `SetAreaOn`); `ThemeFromForm` never touches them. + with CSS only (`.hoverpick > input:not(:checked) ~ …`). Image fields use the + `imagepick` partial: a `<select>` of the `recentImages` newest library + images (`kind = image`) plus the chosen one (`imageNames` looks up an older + one's filename), so the page never grows with the library; `none` clears, a + uuid selects, no value keeps (`pickImage`); an upload in `<name>_file` wins. + With the script the select is hidden and "Choose from library…" opens one + shared panel that fetches `GET /b/{sub}/files?kind=image&q=&p=` with + `Accept: application/json` (`{files:[{id,filename}], page, last}`, 50 a + page, thumbnails `loading=lazy`) and writes the choice into the select. + `Presets()`/`WithPreset` are the colour schemes (colours only; `Preset.Colors` + is the JSON the scheme buttons carry). The layout switches + (`HeaderOn/LeftOn/RightOn/FooterOn`, `LeftWidth`, `RightWidth`, + `KeepColumns`) live in the theme and are read by `ThemeFromForm` like + everything else (checkboxes: unticked = off). - **Layout** (`modules` table, `store/modules.go`, `web/layout.go`, - `handlers_layout.go`, `/b/{sub}/layout…`): a blog page is six areas — + `design_form.go`): a blog page is six areas — `header`, `left`, `above`, `below` (the two custom-HTML slots inside the main column, one module at most), `right`, `footer` — each an ordered list of modules (`kind`: title, logo, menu, search, archive, recent, tags, @@ -185,7 +215,7 @@ internal/web/ server.go (host router, middleware, render helpers) on delete) and custom links (`label` + `url`, validated by `validLinkURL`: http(s), mailto or a `/path`). `Page.ShowInNav` is derived (`EXISTS` on `menu_items`); `CreatePage`/`UpdatePage` add or - remove the entry so the page form's checkbox and the Layout tab agree. + remove the entry so the page form's checkbox and the Design tab agree. - **Favicon**: `static/favicon.svg` + `favicon.png` are the defaults, linked from both layouts. A blog can set `theme.favicon` (an image id) which the blog layout links instead. `GET /favicon.ico` on every host @@ -19,15 +19,18 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones. - Every blog has **pages** (Home, About, News, …); each page holds posts. Posts can carry **tags**, shown under the date; each tag has its own page listing every post with it. -- **Layout** tab: the page is five areas — header, left column, main content, right - column, footer — each holding modules in any order: blog title, logo, menu, archive - (posts by year and month), recent posts, tags (with counts), tag cloud, a search - box (finds posts by title and text, any case; several words match in order with - anything in between), custom HTML, footer text, RSS link, site map. - Column widths are percentages; on phones the columns stack under the posts. The - **menu** mixes blog pages and outside links. -- **Design** form: colour schemes, background colour/image, fonts, link style, content box, header, menu, post dates, footer, logo and site icon (favicon). -- Changes are live immediately: save, then refresh the blog tab. +- **Design** tab: one form, one Save, organised by part of the blog — colours & fonts + (schemes, background colour/image, text and link colours, fonts), header, menu, + content, side columns, footer, logo & site icon. The page is five areas — header, + left column, main content, right column, footer — each holding modules in any + order: blog title, logo, menu, archive (posts by year and month), recent posts, + tags (with counts), tag cloud, a search box (finds posts by title and text, any + case; several words match in order with anything in between), custom HTML, footer + text, RSS link, site map. Column widths are percentages; on phones the columns + stack under the posts. The **menu** mixes blog pages and outside links. Posts per + page, the date format, the site title size and the footer alignment live here too. +- Changes are live once saved: save, then refresh the blog tab. Nothing is saved + until you press Save, and Discard changes forgets the edits. - **Languages**: English or Greek, chosen per blog on the Settings tab. It switches the whole dashboard and the blog's fixed text — dates, archive months, "RSS feed", the pager, the 404 page. What the blogger writes is never translated. diff --git a/internal/i18n/el.go b/internal/i18n/el.go index 26e3a60..15270c1 100644 --- a/internal/i18n/el.go +++ b/internal/i18n/el.go @@ -16,19 +16,15 @@ var el = map[string]string{ "Posts": "Δημοσιεύσεις", "Pages": "Σελίδες", "Announcements": "Ανακοινώσεις", - "Layout": "Διάταξη", "Design": "Σχεδίαση", "Settings": "Ρυθμίσεις", "View blog": "Προβολή ιστολογίου", "View page": "Προβολή σελίδας", "View post": "Προβολή δημοσίευσης", "view": "προβολή", - "edit": "επεξεργασία", "delete": "διαγραφή", - "remove": "αφαίρεση", "Remove": "Αφαίρεση", "Save": "Αποθήκευση", - "Add": "Προσθήκη", "Back": "Πίσω", "Cancel": "Άκυρο", "Create": "Δημιουργία", @@ -57,7 +53,6 @@ var el = map[string]string{ "you": "εσείς", "tab.": "καρτέλα.", "your blog": "το ιστολόγιό σας", - "to see it.": "για να τη δείτε.", "to see them.": "για να τις δείτε.", // ---- overview ---- @@ -67,7 +62,6 @@ var el = map[string]string{ "Write a new post": "Γράψτε νέα δημοσίευση", "Add a page": "Προσθήκη σελίδας", "Post an announcement": "Νέα ανακοίνωση", - "Arrange the layout": "Ρύθμιση διάταξης", "Change the look": "Αλλαγή εμφάνισης", "%d posts": "%d δημοσιεύσεις", "hidden from menu": "εκτός μενού", @@ -265,25 +259,18 @@ var el = map[string]string{ "Wrong username or password.": "Λάθος όνομα χρήστη ή κωδικός.", // ---- layout ---- - "Decide what goes where on your blog. Every change here is saved straight away; refresh": "Αποφασίστε τι πάει πού στο ιστολόγιό σας. Κάθε αλλαγή εδώ αποθηκεύεται αμέσως· ανανεώστε", - "Colours and fonts are on the": "Τα χρώματα και οι γραμματοσειρές είναι στην", - "Columns": "Στήλες", - "Header": "Κεφαλίδα", - "Footer": "Υποσέλιδο", - "Menu": "Μενού", - "Left": "Αριστερά", - "Right": "Δεξιά", - "Left column width": "Πλάτος αριστερής στήλης", - "Right column width": "Πλάτος δεξιάς στήλης", + "Header": "Κεφαλίδα", + "Footer": "Υποσέλιδο", + "Menu": "Μενού", + "Left": "Αριστερά", + "Right": "Δεξιά", + "Left column width": "Πλάτος αριστερής στήλης", + "Right column width": "Πλάτος δεξιάς στήλης", "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.": "Από 15% έως 40% η καθεμία· το κύριο περιεχόμενο παίρνει το υπόλοιπο. Μια στήλη εμφανίζεται μόνο όταν έχει τουλάχιστον ένα στοιχείο· χωρίς στήλες οι δημοσιεύσεις παίρνουν όλο το πλάτος. Στα κινητά οι στήλες στοιβάζονται: πρώτα το κύριο περιεχόμενο, μετά η αριστερή και η δεξιά στήλη, μετά το υποσέλιδο.", - "Always keep the columns' space, even when a column is empty or hidden": "Να κρατιέται πάντα ο χώρος των στηλών, ακόμα κι όταν μια στήλη είναι άδεια ή κρυφή", - "the main content then keeps its width and stays centred": "το κύριο περιεχόμενο κρατά έτσι το πλάτος του και μένει κεντραρισμένο", - "Save columns": "Αποθήκευση στηλών", - "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.": "Η κορυφή κάθε σελίδας. Βάλτε τον τίτλο, ένα λογότυπο και το μενού με όποια σειρά θέλετε. Μια εικόνα-πανό επιλέγεται στην καρτέλα Σχεδίαση.", - "A narrow column beside your posts. Good for the menu, an archive or recent posts.": "Μια στενή στήλη δίπλα στις δημοσιεύσεις σας. Καλή για το μενού, το αρχείο ή τις πρόσφατες δημοσιεύσεις.", - "A narrow column on the other side of your posts. Same choices as the left one.": "Μια στενή στήλη από την άλλη πλευρά των δημοσιεύσεων. Ίδιες επιλογές με την αριστερή.", - "The bottom of every page: a copyright line, the RSS link, a list of your pages.": "Το τέλος κάθε σελίδας: μια γραμμή copyright, ο σύνδεσμος RSS, μια λίστα με τις σελίδες σας.", - "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…": "Οι δημοσιεύσεις και οι σελίδες σας εμφανίζονται πάντα εδώ. Μπορείτε να προσθέσετε δικό σας HTML πριν και μετά — ένα εισαγωγικό πλαίσιο, μια φόρμα newsletter, ένα σήμα…", + "Always keep the columns' space, even when a column is empty or hidden": "Να κρατιέται πάντα ο χώρος των στηλών, ακόμα κι όταν μια στήλη είναι άδεια ή κρυφή", + "the main content then keeps its width and stays centred": "το κύριο περιεχόμενο κρατά έτσι το πλάτος του και μένει κεντραρισμένο", + "A narrow column beside your posts. Good for the menu, an archive or recent posts.": "Μια στενή στήλη δίπλα στις δημοσιεύσεις σας. Καλή για το μενού, το αρχείο ή τις πρόσφατες δημοσιεύσεις.", + "A narrow column on the other side of your posts. Same choices as the left one.": "Μια στενή στήλη από την άλλη πλευρά των δημοσιεύσεων. Ίδιες επιλογές με την αριστερή.", "Nothing extra.": "Τίποτα επιπλέον.", "Add custom HTML": "Προσθήκη δικού σας HTML", "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.": "Οι σύνδεσμοι που δείχνει το στοιχείο Μενού, όπου κι αν το βάλετε. Σελίδες και εξωτερικοί σύνδεσμοι μπορούν να αναμειχθούν με όποια σειρά θέλετε. Το «Εμφάνιση στο μενού» σε μια σελίδα κάνει το ίδιο με την προσθήκη της εδώ.", @@ -295,28 +282,12 @@ var el = map[string]string{ "Add a link": "Προσθήκη συνδέσμου", "Add link": "Προσθήκη συνδέσμου", "My photos": "Οι φωτογραφίες μου", - "Hide this area": "Απόκρυψη περιοχής", - "Show this area": "Εμφάνιση περιοχής", "no logo chosen": "δεν έχει επιλεγεί λογότυπο", - "pick one on the": "επιλέξτε ένα στην", "Nothing here yet.": "Τίποτα εδώ ακόμα.", "Nothing here yet — this area is not shown until you add a module.": "Τίποτα εδώ ακόμα — η περιοχή δεν εμφανίζεται μέχρι να προσθέσετε ένα στοιχείο.", "Add a module": "Προσθήκη στοιχείου", - "Columns saved. Refresh your blog to see them.": "Οι στήλες αποθηκεύτηκαν. Ανανεώστε το ιστολόγιό σας για να τις δείτε.", - "%s hidden.": "%s: κρύφτηκε.", - "%s shown again. Refresh your blog to see it.": "%s: εμφανίζεται ξανά. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", - "That module cannot go in that area.": "Αυτό το στοιχείο δεν μπορεί να μπει σε αυτή την περιοχή.", - "There is already a module there; edit or remove it first.": "Υπάρχει ήδη στοιχείο εκεί· επεξεργαστείτε το ή αφαιρέστε το πρώτα.", - "Added. Now write what it should show.": "Προστέθηκε. Γράψτε τώρα τι θα δείχνει.", - "%s added. Refresh your blog to see it.": "%s: προστέθηκε. Ανανεώστε το ιστολόγιό σας για να το δείτε.", - "%s removed.": "%s: αφαιρέθηκε.", - "the “%s” module and its text": "το στοιχείο «%s» και το κείμενό του", - "Pick a page first.": "Επιλέξτε πρώτα μια σελίδα.", - "Page added to the menu. Refresh your blog to see it.": "Η σελίδα προστέθηκε στο μενού. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", - "Give the link a short text (max 60 characters).": "Δώστε στον σύνδεσμο ένα σύντομο κείμενο (έως 60 χαρακτήρες).", + "Give the link a short text (max 60 characters).": "Δώστε στον σύνδεσμο ένα σύντομο κείμενο (έως 60 χαρακτήρες).", "That address does not look right. Use a full web address like https://example.org or a path like /about.": "Η διεύθυνση δεν φαίνεται σωστή. Χρησιμοποιήστε πλήρη διεύθυνση όπως https://example.org ή διαδρομή όπως /about.", - "Link added to the menu. Refresh your blog to see it.": "Ο σύνδεσμος προστέθηκε στο μενού. Ανανεώστε το ιστολόγιό σας για να τον δείτε.", - "Removed from the menu.": "Αφαιρέθηκε από το μενού.", // module and area names (web.moduleNames / areaNames) "Blog title & tagline": "Τίτλος & υπότιτλος ιστολογίου", @@ -339,7 +310,6 @@ var el = map[string]string{ "all tags": "όλες οι ετικέτες", // ---- module form ---- - "in the %s": "— %s", "How many posts": "Πόσες δημοσιεύσεις", "The newest published posts from every page of your blog, newest first.": "Οι νεότερες δημοσιεύσεις από όλες τις σελίδες του ιστολογίου, με τις πιο πρόσφατες πρώτα.", "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open.": "Όλες οι δημοσιεύσεις ανά έτος και μήνα, με τις πιο πρόσφατες πρώτα. Έτη και μήνες ανοιγοκλείνουν· το τελευταίο έτος ξεκινά ανοιχτό.", @@ -352,18 +322,13 @@ var el = map[string]string{ "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.": "Μπαίνει στη σελίδα σας ακριβώς όπως γράφεται — ενσωματώσεις, σήματα και scripts δουλεύουν όλα. Ό,τι είναι λάθος εδώ (π.χ. μια ετικέτα που δεν κλείνει) μπορεί να χαλάσει τη σελίδα γύρω του, γι' αυτό ελέγξτε το ιστολόγιό σας μετά την αποθήκευση. Έως 20 KB.", "© 2026 Me": "© 2026 Εγώ", "Plain text; each line becomes its own paragraph.": "Απλό κείμενο· κάθε γραμμή γίνεται ξεχωριστή παράγραφος.", - "Back to layout": "Πίσω στη διάταξη", - "Heading is too long (max 120 characters).": "Η επικεφαλίδα είναι πολύ μεγάλη (έως 120 χαρακτήρες).", - "Content is too long (20 KB max).": "Το περιεχόμενο είναι πολύ μεγάλο (έως 20 KB).", + "Heading is too long (max 120 characters).": "Η επικεφαλίδα είναι πολύ μεγάλη (έως 120 χαρακτήρες).", + "Content is too long (20 KB max).": "Το περιεχόμενο είναι πολύ μεγάλο (έως 20 KB).", // ---- design ---- - "Changes are live as soon as you save; refresh": "Οι αλλαγές ισχύουν μόλις αποθηκεύσετε· ανανεώστε", - "Schemes": "Συνδυασμοί", - "Background": "Φόντο", - "Logo & icon": "Λογότυπο & εικονίδιο", - "What appears where — header, columns, footer and their modules — is arranged on the": "Τι εμφανίζεται πού — κεφαλίδα, στήλες, υποσέλιδο και τα στοιχεία τους — ρυθμίζεται στην", - "Colour schemes": "Χρωματικοί συνδυασμοί", - "A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept.": "Για γρήγορη αρχή: ένας συνδυασμός ορίζει όλα τα παρακάτω χρώματα και αποθηκεύεται αμέσως. Εικόνες, γραμματοσειρές και διάταξη διατηρούνται.", + "Background": "Φόντο", + "Logo & icon": "Λογότυπο & εικονίδιο", + "Colour schemes": "Χρωματικοί συνδυασμοί", "Apply %s": "Εφαρμογή: %s", "Paper": "Χαρτί", "Night": "Νύχτα", @@ -377,8 +342,6 @@ var el = map[string]string{ "Fill, fixed while scrolling": "Γέμισμα, σταθερό στην κύλιση", "Repeat (tile)": "Επανάληψη (πλακάκι)", "Background image": "Εικόνα φόντου", - "Text & content area": "Κείμενο & περιοχή περιεχομένου", - "Content background": "Φόντο περιεχομένου", "Text colour": "Χρώμα κειμένου", "Link colour": "Χρώμα συνδέσμων", "Change colour when the mouse is over a link": "Αλλαγή χρώματος όταν το ποντίκι είναι πάνω από σύνδεσμο", @@ -401,44 +364,30 @@ var el = map[string]string{ "inside the box": "μέσα στο πλαίσιο", "Compact": "Σφιχτό", "Roomy": "Άνετο", - "The content spans the whole page; how much of it the posts get is set by the column widths on the": "Το περιεχόμενο απλώνεται σε όλη τη σελίδα· πόσο από αυτό παίρνουν οι δημοσιεύσεις ορίζεται από τα πλάτη των στηλών στην", - "Alignment": "Στοίχιση", - "Centred": "Στο κέντρο", - "Image height": "Ύψος εικόνας", - "Short": "Χαμηλό", - "Medium": "Μεσαίο", - "Tall": "Ψηλό", - "The title, logo and menu are header modules — add, remove and order them on the": "Ο τίτλος, το λογότυπο και το μενού είναι στοιχεία της κεφαλίδας — προσθέστε, αφαιρέστε και ταξινομήστε τα στην", - "The banner below is shown across the top of the header whenever one is chosen.": "Το πανό παρακάτω εμφανίζεται κατά μήκος της κορυφής της κεφαλίδας όποτε έχει επιλεγεί κάποιο.", - "Header image (banner)": "Εικόνα κεφαλίδας (πανό)", - "Which links the menu shows, and where it sits, is set on the": "Ποιους συνδέσμους δείχνει το μενού, και πού βρίσκεται, ορίζεται στην", - "Here you choose how it looks.": "Εδώ επιλέγετε πώς φαίνεται.", - "menu in the header": "μενού στην κεφαλίδα", - "Link style": "Στυλ συνδέσμων", - "Uppercase, spaced": "Κεφαλαία, αραιά", - "Menu colours": "Χρώματα μενού", - "Posts & dates": "Δημοσιεύσεις & ημερομηνίες", - "Show the date on posts": "Εμφάνιση ημερομηνίας στις δημοσιεύσεις", - "Date format": "Μορφή ημερομηνίας", - "The footer text, RSS link and other footer modules live on the": "Το κείμενο υποσέλιδου, ο σύνδεσμος RSS και τα άλλα στοιχεία του υποσέλιδου βρίσκονται στην", - "Logo & site icon": "Λογότυπο & εικονίδιο ιστολογίου", - "Logo size": "Μέγεθος λογότυπου", - "Small (48 px tall)": "Μικρό (48 px ύψος)", - "Medium (80 px)": "Μεσαίο (80 px)", - "Large (140 px)": "Μεγάλο (140 px)", - "Shown wherever you place a Logo module (Layout tab); it links to your front page.": "Εμφανίζεται όπου βάλετε στοιχείο Λογότυπο (καρτέλα Διάταξη)· οδηγεί στην αρχική σας σελίδα.", - "Favicon": "Εικονίδιο (favicon)", + "Alignment": "Στοίχιση", + "Centred": "Στο κέντρο", + "Short": "Χαμηλό", + "Medium": "Μεσαίο", + "Tall": "Ψηλό", + "menu in the header": "μενού στην κεφαλίδα", + "Link style": "Στυλ συνδέσμων", + "Uppercase, spaced": "Κεφαλαία, αραιά", + "Show the date on posts": "Εμφάνιση ημερομηνίας στις δημοσιεύσεις", + "Date format": "Μορφή ημερομηνίας", + "Logo & site icon": "Λογότυπο & εικονίδιο ιστολογίου", + "Logo size": "Μέγεθος λογότυπου", + "Small (48 px tall)": "Μικρό (48 px ύψος)", + "Medium (80 px)": "Μεσαίο (80 px)", + "Large (140 px)": "Μεγάλο (140 px)", + "Favicon": "Εικονίδιο (favicon)", "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.": "Το μικρό εικονίδιο που δείχνουν οι φυλλομετρητές στην καρτέλα. Ένα τετράγωνο PNG ή ICO 32–64 px είναι ιδανικό· επιλέξτε Κανένα για το εικονίδιο του Blogspace.", - "Save design": "Αποθήκευση σχεδίασης", "Reset to defaults": "Επαναφορά προεπιλογών", "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library).": "Επαναφέρει κάθε χρώμα, γραμματοσειρά και επιλογή διάταξης όπως ξεκινά ένα νέο ιστολόγιο και ξεχνά τις επιλεγμένες εικόνες (μένουν στη συλλογή σας).", "Reset everything": "Επαναφορά όλων", "None": "Κανένα", "…or upload a new one": "…ή μεταφορτώστε καινούργια", - "Design saved. Refresh your blog to see it.": "Η σχεδίαση αποθηκεύτηκε. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", - "Unknown colour scheme.": "Άγνωστος χρωματικός συνδυασμός.", - "Applied the %s scheme. Refresh your blog to see it.": "Εφαρμόστηκε ο συνδυασμός %s. Ανανεώστε το ιστολόγιό σας για να τον δείτε.", - "Design and layout reset to the defaults.": "Σχεδίαση και διάταξη επανήλθαν στις προεπιλογές.", + "Design saved. Refresh your blog to see it.": "Η σχεδίαση αποθηκεύτηκε. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", + "Design and layout reset to the defaults.": "Σχεδίαση και διάταξη επανήλθαν στις προεπιλογές.", // ---- admin ---- "Users & blogs": "Χρήστες & ιστολόγια", @@ -496,4 +445,46 @@ var el = map[string]string{ "Nothing matched “%s”.": "Τίποτα δεν ταιριάζει με «%s».", "One post matches.": "Μία δημοσίευση ταιριάζει.", "%d posts match.": "%d δημοσιεύσεις ταιριάζουν.", + + // ---- design: the one tab for look and layout ---- + "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": "Ό,τι αφορά την εμφάνιση του ιστολογίου σας και το τι πάει πού, αποθηκευμένο μαζί με το ένα κουμπί στο τέλος. Οι αλλαγές ισχύουν μόλις αποθηκεύσετε· ανανεώστε", + "Colours & fonts": "Χρώματα & γραμματοσειρές", + "Side columns": "Πλαϊνές στήλες", + "Page background": "Φόντο σελίδας", + "Text & links": "Κείμενο & σύνδεσμοι", + "Fonts": "Γραμματοσειρές", + "A quick start: a scheme fills in every colour below. Images, fonts and layout are kept.": "Για γρήγορη αρχή: ένας συνδυασμός συμπληρώνει όλα τα παρακάτω χρώματα. Εικόνες, γραμματοσειρές και διάταξη διατηρούνται.", + "Apply a scheme when saving": "Εφαρμογή συνδυασμού κατά την αποθήκευση", + "Keep the colours below": "Διατήρηση των παρακάτω χρωμάτων", + "Show the header": "Εμφάνιση της κεφαλίδας", + "the top of every page": "η κορυφή κάθε σελίδας", + "Title size": "Μέγεθος τίτλου", + "Banner image": "Εικόνα-πανό", + "Banner height": "Ύψος πανό", + "Shown across the top of the header whenever one is chosen.": "Εμφανίζεται κατά μήκος της κορυφής της κεφαλίδας όποτε έχει επιλεγεί κάποια.", + "Stack the title, a logo, the menu and a search box in the order you like.": "Βάλτε τον τίτλο, ένα λογότυπο, το μενού και ένα πλαίσιο αναζήτησης με όποια σειρά θέλετε.", + "A page or link filled in here is added when you save.": "Μια σελίδα ή ένας σύνδεσμος που συμπληρώνετε εδώ προστίθεται όταν αποθηκεύσετε.", + "How the menu looks": "Πώς φαίνεται το μενού", + "The box your posts and pages are shown in. It spans whatever the side columns leave.": "Το πλαίσιο στο οποίο εμφανίζονται οι δημοσιεύσεις και οι σελίδες σας. Απλώνεται σε ό,τι αφήνουν οι πλαϊνές στήλες.", + "Posts per page": "Δημοσιεύσεις ανά σελίδα", + "Around the posts": "Γύρω από τις δημοσιεύσεις", + "Your own HTML before and after the posts — an intro box, a newsletter form, a badge…": "Δικό σας HTML πριν και μετά τις δημοσιεύσεις — ένα εισαγωγικό πλαίσιο, μια φόρμα newsletter, ένα σήμα…", + "Show the left column": "Εμφάνιση της αριστερής στήλης", + "Show the right column": "Εμφάνιση της δεξιάς στήλης", + "Show the footer": "Εμφάνιση του υποσέλιδου", + "the bottom of every page": "το τέλος κάθε σελίδας", + "A copyright line, the RSS link, a list of your pages.": "Μια γραμμή copyright, ο σύνδεσμος RSS, μια λίστα με τις σελίδες σας.", + "Shown wherever you place a Logo module; it links to your front page.": "Εμφανίζεται όπου βάλετε στοιχείο Λογότυπο· οδηγεί στην αρχική σας σελίδα.", + "pick one": "επιλέξτε ένα", + "Discard changes": "Απόρριψη αλλαγών", + "Unsaved changes": "Μη αποθηκευμένες αλλαγές", + "Throw away your unsaved changes?": "Να πεταχτούν οι μη αποθηκευμένες αλλαγές σας;", + "Remove this module and its text?": "Να αφαιρεθεί αυτό το στοιχείο και το κείμενό του;", + "Only one module fits before or after the posts; remove one first.": "Μόνο ένα στοιχείο χωράει πριν ή μετά τις δημοσιεύσεις· αφαιρέστε πρώτα ένα.", + "Choose from library…": "Επιλογή από τη συλλογή…", + "Current image": "Τρέχουσα εικόνα", + "Filter by name…": "Φιλτράρισμα κατά όνομα…", + "%d images": "%d εικόνες", + "No images match.": "Καμία εικόνα δεν ταιριάζει.", + "Close": "Κλείσιμο", } diff --git a/internal/store/design.go b/internal/store/design.go new file mode 100644 index 0000000..4556d96 --- /dev/null +++ b/internal/store/design.go @@ -0,0 +1,69 @@ +package store + +import ( + "context" + "encoding/json" +) + +// SaveDesign stores the design form in one go: the theme, the modules of every +// area and the menu, so a half-applied save is impossible. mods and menu are +// the complete new state in display order: rows with an ID are updated, rows +// without one inserted, and anything else is deleted. +func (bs *BlogStore) SaveDesign(ctx context.Context, theme json.RawMessage, mods []Module, menu []MenuItem) error { + tx, err := bs.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `UPDATE settings SET theme=$1, updated_at=now()`, theme); err != nil { + return err + } + + keep := []int64{} + for _, m := range mods { + if m.ID != 0 { + keep = append(keep, m.ID) + } + } + if _, err := tx.Exec(ctx, `DELETE FROM modules WHERE id <> ALL($1)`, keep); err != nil { + return err + } + order := map[string]int{} // sort_order restarts in every area + for _, m := range mods { + pos := order[m.Area] + order[m.Area]++ + if m.ID != 0 { // area and kind never change + _, err = tx.Exec(ctx, `UPDATE modules SET title=$2, body=$3, count=$4, sort_order=$5, updated_at=now() WHERE id=$1`, m.ID, m.Title, m.Body, m.Count, pos) + } else { + _, err = tx.Exec(ctx, `INSERT INTO modules (area, kind, title, body, count, sort_order) VALUES ($1,$2,$3,$4,$5,$6)`, m.Area, m.Kind, m.Title, m.Body, m.Count, pos) + } + if err != nil { + return wrap(err) + } + } + + keep = keep[:0] + for _, it := range menu { + if it.ID != 0 { + keep = append(keep, it.ID) + } + } + if _, err := tx.Exec(ctx, `DELETE FROM menu_items WHERE id <> ALL($1)`, keep); err != nil { + return err + } + for i, it := range menu { + switch { + case it.ID != 0: + _, err = tx.Exec(ctx, `UPDATE menu_items SET label=$2, url=$3, sort_order=$4 WHERE id=$1`, it.ID, it.Label, it.URL, i) + case it.PageID != nil: // a page deleted meanwhile simply adds nothing + _, err = tx.Exec(ctx, `INSERT INTO menu_items (page_id, sort_order) SELECT id, $2 FROM pages WHERE id=$1 + ON CONFLICT (page_id) WHERE page_id IS NOT NULL DO UPDATE SET sort_order=excluded.sort_order`, *it.PageID, i) + default: + _, err = tx.Exec(ctx, `INSERT INTO menu_items (label, url, sort_order) VALUES ($1,$2,$3)`, it.Label, it.URL, i) + } + if err != nil { + return wrap(err) + } + } + return tx.Commit(ctx) +} diff --git a/internal/store/menu.go b/internal/store/menu.go index cedb7ad..e2314af 100644 --- a/internal/store/menu.go +++ b/internal/store/menu.go @@ -76,45 +76,3 @@ func (bs *BlogStore) SetPageInMenu(ctx context.Context, pageID int64, on bool) e _, err := bs.db.Exec(ctx, `DELETE FROM menu_items WHERE page_id=$1`, pageID) return err } - -// AddMenuLink appends a custom link to the menu. -func (bs *BlogStore) AddMenuLink(ctx context.Context, label, url string) error { - _, err := bs.db.Exec(ctx, `INSERT INTO menu_items (label, url, sort_order) - VALUES ($1,$2,(SELECT coalesce(max(sort_order),-1)+1 FROM menu_items))`, label, url) - return err -} - -func (bs *BlogStore) DeleteMenuItem(ctx context.Context, id int64) error { - _, err := bs.db.Exec(ctx, `DELETE FROM menu_items WHERE id=$1`, id) - return err -} - -// MoveMenuItem swaps sort_order with the neighbouring entry (dir = -1 up, +1 down). -func (bs *BlogStore) MoveMenuItem(ctx context.Context, id int64, dir int) error { - items, err := bs.ListMenu(ctx) - if err != nil { - return err - } - idx := -1 - for i := range items { - if items[i].ID == id { - idx = i - } - } - j := idx + dir - if idx < 0 || j < 0 || j >= len(items) { - return nil - } - items[idx], items[j] = items[j], items[idx] - tx, err := bs.db.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) - for i, m := range items { // renumber everything; keeps orders dense - if _, err := tx.Exec(ctx, `UPDATE menu_items SET sort_order=$2 WHERE id=$1`, m.ID, i); err != nil { - return err - } - } - return tx.Commit(ctx) -} diff --git a/internal/store/modules.go b/internal/store/modules.go index d077975..99aed1a 100644 --- a/internal/store/modules.go +++ b/internal/store/modules.go @@ -48,10 +48,6 @@ func (bs *BlogStore) ListModules(ctx context.Context) ([]Module, error) { return out, rows.Err() } -func (bs *BlogStore) ModuleByID(ctx context.Context, id int64) (*Module, error) { - return scanModule(bs.db.QueryRow(ctx, `SELECT `+moduleCols+` FROM modules WHERE id=$1`, id)) -} - // CreateModule appends a module to the end of its area. func (bs *BlogStore) CreateModule(ctx context.Context, m *Module) (*Module, error) { return scanModule(bs.db.QueryRow(ctx, `INSERT INTO modules (area, kind, title, body, count, sort_order) @@ -59,57 +55,6 @@ func (bs *BlogStore) CreateModule(ctx context.Context, m *Module) (*Module, erro m.Area, m.Kind, m.Title, m.Body, m.Count)) } -// UpdateModule saves the editable settings; area and kind never change. -func (bs *BlogStore) UpdateModule(ctx context.Context, m *Module) error { - _, err := bs.db.Exec(ctx, `UPDATE modules SET title=$2, body=$3, count=$4, updated_at=now() WHERE id=$1`, - m.ID, m.Title, m.Body, m.Count) - return wrap(err) -} - -func (bs *BlogStore) DeleteModule(ctx context.Context, id int64) error { - _, err := bs.db.Exec(ctx, `DELETE FROM modules WHERE id=$1`, id) - return err -} - -// MoveModule swaps sort_order with the neighbouring module of the same area (dir = -1 up, +1 down). -func (bs *BlogStore) MoveModule(ctx context.Context, id int64, dir int) error { - cur, err := bs.ModuleByID(ctx, id) - if err != nil { - return err - } - all, err := bs.ListModules(ctx) - if err != nil { - return err - } - var mods []Module - idx := -1 - for _, m := range all { - if m.Area != cur.Area { - continue - } - if m.ID == id { - idx = len(mods) - } - mods = append(mods, m) - } - j := idx + dir - if idx < 0 || j < 0 || j >= len(mods) { - return nil - } - mods[idx], mods[j] = mods[j], mods[idx] - tx, err := bs.db.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) - for i, m := range mods { // renumber the area; keeps orders dense - if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order=$2 WHERE id=$1`, m.ID, i); err != nil { - return err - } - } - return tx.Commit(ctx) -} - // ResetModules puts the layout back to what a new blog starts with. func (bs *BlogStore) ResetModules(ctx context.Context) error { tx, err := bs.db.Begin(ctx) diff --git a/internal/web/design_form.go b/internal/web/design_form.go new file mode 100644 index 0000000..9edf8e3 --- /dev/null +++ b/internal/web/design_form.go @@ -0,0 +1,228 @@ +package web + +import ( + "errors" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/gramanas/blogspace/internal/i18n" + "github.com/gramanas/blogspace/internal/store" +) + +// The design form carries the whole layout as indexed fields: every module +// row is mod.<i>.{id,area,kind,title,body,count,pos,del} and every menu +// entry menu.<i>.{id,page,label,url,pos,del}. The index only tells rows +// apart; the order is pos (renumbered from the page by the script, typed in +// by hand without it). add.<area>, menu_add_page and menu_add_label/url are +// the no-script way of adding: they are appended on save. + +const ( + maxModuleBytes = 20_000 + maxRecentCount = 20 + maxMenuLabel = 60 +) + +// newModule is a module as it is first added: sensible headings, in the +// blog's language, so it reads well before it is edited. +func newModule(area, kind, blogLang string) store.Module { + m := store.Module{Area: area, Kind: kind, Count: 5} + switch kind { + case "archive": + m.Title = i18n.T(blogLang, "Archive") + case "recent": + m.Title = i18n.T(blogLang, "Recent posts") + case "sitemap": + m.Title = i18n.T(blogLang, "Pages") + case "tags": + m.Title = i18n.T(blogLang, "Tags") + m.Count = 10 + case "tagcloud": + m.Title = i18n.T(blogLang, "Tag cloud") + } + return m +} + +// formIndexes lists the row indexes present under a prefix ("mod." → the i +// of every mod.<i>.<field>), in numeric order. +func formIndexes(f url.Values, prefix string) []int { + seen := map[int]bool{} + for k := range f { + if !strings.HasPrefix(k, prefix) { + continue + } + rest := k[len(prefix):] + dot := strings.IndexByte(rest, '.') + if dot < 0 { + continue + } + if i, err := strconv.Atoi(rest[:dot]); err == nil && !seen[i] { + seen[i] = true + } + } + out := make([]int, 0, len(seen)) + for i := range seen { + out = append(out, i) + } + sort.Ints(out) + return out +} + +type formRow struct { + pos, idx int +} + +// byPos orders rows by their pos field, the index breaking ties (so rows +// without one keep the order they were rendered in). +func byPos(rows []formRow) { + sort.SliceStable(rows, func(a, b int) bool { + if rows[a].pos != rows[b].pos { + return rows[a].pos < rows[b].pos + } + return rows[a].idx < rows[b].idx + }) +} + +// parseModules reads the module rows of the design form, in display order +// area by area, and appends the add.<area> choices. lang is the viewer's, +// for the messages; blogLang the blog's, for new headings. The modules come +// back even with an error, so the form can be shown again as it was sent. +func parseModules(f url.Values, lang, blogLang string) ([]store.Module, error) { + get := func(i int, k string) string { return f.Get("mod." + strconv.Itoa(i) + "." + k) } + var rows []formRow + for _, i := range formIndexes(f, "mod.") { + if get(i, "del") == "on" { + continue + } + pos, _ := strconv.Atoi(get(i, "pos")) + rows = append(rows, formRow{pos, i}) + } + byPos(rows) + var mods []store.Module + var err error + fail := func(msg string) { // the first problem is the one reported + if err == nil { + err = errors.New(msg) + } + } + for _, r := range rows { + i := r.idx + m := store.Module{Area: get(i, "area"), Kind: get(i, "kind")} + m.ID, _ = strconv.ParseInt(get(i, "id"), 10, 64) + if !allowedKind(m.Area, m.Kind) { // not something the form offers: drop it + continue + } + m.Title = strings.TrimSpace(get(i, "title")) + m.Body = strings.ReplaceAll(get(i, "body"), "\r\n", "\n") + if n, err := strconv.Atoi(get(i, "count")); err == nil { + if m.Kind == "tags" { // 0 = all of them + m.Count = clampInt(n, 0, maxTagListCount) + } else { + m.Count = clampInt(n, 1, maxRecentCount) + } + } + switch { + case len(m.Title) > 120: + fail(i18n.T(lang, "Heading is too long (max 120 characters).")) + case len(m.Body) > maxModuleBytes: + fail(i18n.T(lang, "Content is too long (20 KB max).")) + } + mods = append(mods, m) + } + for _, area := range Areas { + if kind := f.Get("add." + area); kind != "" && allowedKind(area, kind) { + mods = append(mods, newModule(area, kind, blogLang)) + } + } + // stable: the rows are already in order within each area + sort.SliceStable(mods, func(a, b int) bool { return areaIndex(mods[a].Area) < areaIndex(mods[b].Area) }) + seen := map[string]bool{} + for _, m := range mods { + if singleModuleAreas[m.Area] && seen[m.Area] { + fail(i18n.T(lang, "Only one module fits before or after the posts; remove one first.")) + } + seen[m.Area] = true + } + return mods, err +} + +func areaIndex(area string) int { + for i, a := range Areas { + if a == area { + return i + } + } + return len(Areas) +} + +// parseMenu reads the menu rows of the design form, in display order, and +// appends the "add a page" / "add a link" fields. Page entries are filled in +// from pages (title, slug, home) so the form can be shown again as sent — +// which it is, with a bad link as a row to fix, when there is an error. +func parseMenu(f url.Values, lang string, pages []store.Page) ([]store.MenuItem, error) { + byID := map[int64]store.Page{} + for _, p := range pages { + byID[p.ID] = p + } + get := func(i int, k string) string { return strings.TrimSpace(f.Get("menu." + strconv.Itoa(i) + "." + k)) } + var rows []formRow + for _, i := range formIndexes(f, "menu.") { + if get(i, "del") == "on" { + continue + } + pos, _ := strconv.Atoi(get(i, "pos")) + rows = append(rows, formRow{pos, i}) + } + byPos(rows) + var menu []store.MenuItem + var err error + fail := func(msg string) { // the first problem is the one reported + if err == nil { + err = errors.New(msg) + } + } + inMenu := map[int64]bool{} + addPage := func(id int64) { + p, ok := byID[id] + if !ok || inMenu[id] { // deleted meanwhile, or listed twice: drop it + return + } + inMenu[id] = true + menu = append(menu, store.MenuItem{PageID: &p.ID, PageTitle: p.Title, PageSlug: p.Slug, IsHome: p.IsHome}) + } + addLink := func(label, typed string) { + link := typed + if link != "" && !strings.HasPrefix(link, "/") && !strings.Contains(link, ":") { + link = "https://" + link // "example.org" is what people type + } + switch { + case label == "" || len(label) > maxMenuLabel: + fail(i18n.T(lang, "Give the link a short text (max 60 characters).")) + case !validLinkURL(link): + fail(i18n.T(lang, "That address does not look right. Use a full web address like https://example.org or a path like /about.")) + link = typed // shown again as typed, to be fixed + } + menu = append(menu, store.MenuItem{Label: label, URL: link}) + } + for _, r := range rows { + i := r.idx + id, _ := strconv.ParseInt(get(i, "id"), 10, 64) + n := len(menu) + if pageID, _ := strconv.ParseInt(get(i, "page"), 10, 64); pageID != 0 { + addPage(pageID) + } else { + addLink(get(i, "label"), get(i, "url")) + } + if len(menu) > n { + menu[n].ID = id + } + } + if pageID, _ := strconv.ParseInt(strings.TrimSpace(f.Get("menu_add_page")), 10, 64); pageID != 0 { + addPage(pageID) + } + if label, link := strings.TrimSpace(f.Get("menu_add_label")), strings.TrimSpace(f.Get("menu_add_url")); label != "" || link != "" { + addLink(label, link) + } + return menu, err +} diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index 6538c34..53ab891 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -13,8 +13,6 @@ import ( "github.com/gramanas/blogspace/internal/store" ) -const postsPerPage = 10 - // blogView gathers what every public page needs: theme css, the layout with // its modules and their data, the menu, announcements, the blog. func (s *Server) blogView(r *http.Request) (map[string]any, error) { @@ -102,13 +100,13 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store. s.serverError(w, err) return } - n := pageNum(r) - posts, total, err := blogStore(r).PublishedPosts(r.Context(), page.ID, postsPerPage, (n-1)*postsPerPage) + n, per := pageNum(r), v["theme"].(Theme).PostsPerPage + posts, total, err := blogStore(r).PublishedPosts(r.Context(), page.ID, per, (n-1)*per) if err != nil { s.serverError(w, err) return } - last := (total + postsPerPage - 1) / postsPerPage + last := (total + per - 1) / per v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last // The pager appends ?p=N to base: no trailing slash, which /{page} would not match. v["base"] = "/" + page.Slug @@ -126,22 +124,22 @@ func (s *Server) handleBlogTag(w http.ResponseWriter, r *http.Request) { s.blogNotFound(w, r) return } - n := pageNum(r) - posts, total, err := blogStore(r).PublishedPostsByTag(r.Context(), tag.ID, postsPerPage, (n-1)*postsPerPage) + v, err := s.blogView(r) if err != nil { s.serverError(w, err) return } - if total == 0 { - s.blogNotFound(w, r) - return - } - v, err := s.blogView(r) + n, per := pageNum(r), v["theme"].(Theme).PostsPerPage + posts, total, err := blogStore(r).PublishedPostsByTag(r.Context(), tag.ID, per, (n-1)*per) if err != nil { s.serverError(w, err) return } - last := (total + postsPerPage - 1) / postsPerPage + if total == 0 { + s.blogNotFound(w, r) + return + } + last := (total + per - 1) / per v["tag"], v["posts"], v["pageNum"], v["lastPage"], v["base"] = tag, posts, n, last, "/tag/"+tag.Slug s.render(w, r, "blog/tag.html", v) } diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go index 3e9e600..5853436 100644 --- a/internal/web/handlers_design.go +++ b/internal/web/handlers_design.go @@ -4,63 +4,187 @@ import ( "net/http" "time" + "github.com/google/uuid" "github.com/gramanas/blogspace/internal/store" ) -func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) { +// The Design tab: one form for the whole look of the blog — colours, fonts, +// images, which areas are shown and how wide, the modules of each area and +// the menu — saved in one go. + +// recentImages is how many library images the pickers list up front; the +// rest are fetched by the script's library panel, page by page. +const recentImages = 48 + +// designView is the state the design page shows: what the database holds, or +// what a rejected form sent, so no edit is lost to a validation message. +type designView struct { + Theme Theme + Modules []store.Module + Menu []store.MenuItem +} + +// designSections are the cards of the page, in order; the form says which one +// was in view when it was sent so the flash can return there. +var designSections = []string{"colours", "header", "menu", "content", "columns", "footer", "logo"} + +// modRow and menuRow are what the template renders a module or menu entry +// from: the row's field index (unique across the form) and its position in +// its list, the value of the no-script position box. +type modRow struct { + I, Pos int + M store.Module +} + +type menuRow struct { + I, Pos int + Item store.MenuItem +} + +// modTemplate is a row the script clones when a module is added. +type modTemplate struct { + Area, Kind string + M store.Module +} + +func (s *Server) loadDesign(r *http.Request) (designView, error) { blog := currentBlog(r) - images, _, err := blogStore(r).ListFiles(r.Context(), "image", "", 0, 0) + mods, err := blogStore(r).ListModules(r.Context()) if err != nil { - s.serverError(w, err) - return + return designView{}, err + } + menu, err := blogStore(r).ListMenu(r.Context()) + if err != nil { + return designView{}, err + } + return designView{Theme: ParseTheme(blog.ThemeJSON), Modules: mods, Menu: menu}, nil +} + +// designData is everything design.html needs; "today" is the sample for the date formats. +func (s *Server) designData(r *http.Request, v designView) (map[string]any, error) { + bs := blogStore(r) + pages, err := bs.ListPages(r.Context()) + if err != nil { + return nil, err + } + images, imageCount, err := bs.ListFiles(r.Context(), "image", "", recentImages, 0) + if err != nil { + return nil, err + } + // The pickers list the recent images; a chosen one older than that still + // needs its name for the select. + names := map[string]string{} + for _, f := range images { + names[f.ID.String()] = f.Filename + } + for _, id := range []string{v.Theme.BgImage, v.Theme.HeaderImage, v.Theme.Logo, v.Theme.Favicon} { + if id == "" || names[id] != "" { + continue + } + if f, err := bs.FileMeta(r.Context(), uuid.MustParse(id)); err == nil { // a deleted file was cleared from the theme already + names[id] = f.Filename + } + } + rows := map[string][]modRow{} + for i, m := range v.Modules { + rows[m.Area] = append(rows[m.Area], modRow{I: i, Pos: len(rows[m.Area]), M: m}) + } + var menuRows []menuRow + inMenu := map[int64]bool{} + for i, it := range v.Menu { + menuRows = append(menuRows, menuRow{I: i, Pos: i, Item: it}) + if it.PageID != nil { + inMenu[*it.PageID] = true + } + } + var freePages []store.Page // pages not yet in the menu + for _, p := range pages { + if !inMenu[p.ID] { + freePages = append(freePages, p) + } } - s.render(w, r, "dashboard/design.html", designData(ParseTheme(blog.ThemeJSON), images)) + var templates []modTemplate + for _, area := range Areas { + for _, kind := range moduleKinds[area] { + templates = append(templates, modTemplate{Area: area, Kind: kind, M: newModule(area, kind, currentBlog(r).Language)}) + } + } + var pageID int64 + return map[string]any{ + "theme": v.Theme, "rows": rows, "menuRows": menuRows, "freePages": freePages, "nextIndex": max(len(v.Modules), len(v.Menu)), + "templates": templates, "pageItem": store.MenuItem{PageID: &pageID}, "linkItem": store.MenuItem{}, + "kinds": moduleKinds, "names": moduleNames, "areaNames": areaNames, "single": singleModuleAreas, + "counts": []int{3, 5, 8, 10, 15, 20}, "tagCounts": []int{5, 10, 20, 30, 50}, "perPage": postsPerPageChoices, + "images": images, "imageNames": names, "imageCount": imageCount, "presets": Presets(), "today": time.Now(), + }, nil } -// designData is what design.html shows; "today" is the sample for the date formats. -func designData(theme Theme, images []store.File) map[string]any { - return map[string]any{"theme": theme, "images": images, "presets": Presets(), "today": time.Now()} +func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) { + v, err := s.loadDesign(r) + if err != nil { + s.serverError(w, err) + return + } + d, err := s.designData(r, v) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/design.html", d) } func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) - theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form) - // Optional direct uploads from the design form. - for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage, "logo_file": &theme.Logo, "favicon_file": &theme.Favicon} { + lang := s.lang(r) + v := designView{Theme: ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form)} + // Without the script a scheme is a select, applied on top of the colour inputs. + if p, ok := PresetByKey(r.FormValue("preset")); ok { + v.Theme = v.Theme.WithPreset(p) + } + // The form is shown again with everything that was sent, plus the message. + fail := func(msg string) { + d, err := s.designData(r, v) + if err != nil { + s.serverError(w, err) + return + } + d["error"] = msg + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", d) + } + // Optional direct uploads from the pickers. + for field, dst := range map[string]*string{"bg_image_file": &v.Theme.BgImage, "header_image_file": &v.Theme.HeaderImage, "logo_file": &v.Theme.Logo, "favicon_file": &v.Theme.Favicon} { img, err := s.readUpload(r, field, true) if err != nil { - images, _, _ := blogStore(r).ListFiles(r.Context(), "image", "", 0, 0) - d := designData(theme, images) - d["error"] = err.Error() - s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", d) + fail(err.Error()) return } if img != nil { *dst = img.ID.String() } } - if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil { + var err error + if v.Modules, err = parseModules(r.Form, lang, blog.Language); err != nil { + fail(err.Error()) + return + } + pages, err := blogStore(r).ListPages(r.Context()) + if err != nil { s.serverError(w, err) return } - redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.tr(r, "Design saved. Refresh your blog to see it.")) -} - -// handleDesignPreset applies a colour scheme straight away; it is a separate -// form so it cannot be confused with the unsaved edits of the main one. -func (s *Server) handleDesignPreset(w http.ResponseWriter, r *http.Request) { - blog := currentBlog(r) - p, ok := PresetByKey(r.FormValue("preset")) - if !ok { - s.plainError(w, http.StatusBadRequest, s.tr(r, "Unknown colour scheme.")) + if v.Menu, err = parseMenu(r.Form, lang, pages); err != nil { + fail(err.Error()) return } - theme := ParseTheme(blog.ThemeJSON).WithPreset(p) - if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil { + if err := blogStore(r).SaveDesign(r.Context(), v.Theme.JSON(), v.Modules, v.Menu); err != nil { s.serverError(w, err) return } - redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.trf(r, "Applied the %s scheme. Refresh your blog to see it.", s.tr(r, p.Name))) + back := "/b/" + blog.Subdomain + "/design" + if at := r.FormValue("at"); at != "" && pick(at, designSections...) == at { + back += "#" + at + } + redirectOK(w, r, back, s.tr(r, "Design saved. Refresh your blog to see it.")) } // handleDesignReset restores the defaults, layout modules included; images @@ -77,3 +201,9 @@ func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) { } redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.tr(r, "Design and layout reset to the defaults.")) } + +// handleLayoutRedirect keeps old links to the Layout tab working: it is the +// columns section of the Design tab now. +func (s *Server) handleLayoutRedirect(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/b/"+currentBlog(r).Subdomain+"/design#columns", http.StatusSeeOther) +} diff --git a/internal/web/handlers_files.go b/internal/web/handlers_files.go index 2b68871..576f2ca 100644 --- a/internal/web/handlers_files.go +++ b/internal/web/handlers_files.go @@ -129,6 +129,14 @@ func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } + if wantsJSON(r) { // the design page's library panel: one page of files at a time + files := []map[string]string{} + for _, f := range d["files"].([]store.File) { + files = append(files, map[string]string{"id": f.ID.String(), "filename": f.Filename}) + } + writeJSON(w, http.StatusOK, map[string]any{"files": files, "page": d["pageNum"], "last": d["lastPage"]}) + return + } s.render(w, r, "dashboard/files.html", d) } diff --git a/internal/web/handlers_layout.go b/internal/web/handlers_layout.go deleted file mode 100644 index db4f47c..0000000 --- a/internal/web/handlers_layout.go +++ /dev/null @@ -1,315 +0,0 @@ -package web - -import ( - "errors" - "net/http" - "strconv" - "strings" - - "github.com/gramanas/blogspace/internal/i18n" - "github.com/gramanas/blogspace/internal/store" -) - -// The Layout tab: which areas of the blog are shown, how wide the columns -// are, what modules each area holds, and the menu. - -const ( - maxModuleBytes = 20_000 - maxRecentCount = 20 - maxMenuLabel = 60 -) - -func (s *Server) layoutURL(r *http.Request) string { - return "/b/" + currentBlog(r).Subdomain + "/layout" -} - -// layoutData is everything layout.html needs; it is rebuilt after a failed form too. -func (s *Server) layoutData(r *http.Request) (map[string]any, error) { - blog := currentBlog(r) - mods, err := blogStore(r).ListModules(r.Context()) - if err != nil { - return nil, err - } - menu, err := blogStore(r).ListMenu(r.Context()) - if err != nil { - return nil, err - } - pages, err := blogStore(r).ListPages(r.Context()) - if err != nil { - return nil, err - } - byArea := map[string][]store.Module{} - for _, m := range mods { - byArea[m.Area] = append(byArea[m.Area], m) - } - var freePages []store.Page // pages not yet in the menu - for _, p := range pages { - if !p.ShowInNav { - freePages = append(freePages, p) - } - } - return map[string]any{"theme": ParseTheme(blog.ThemeJSON), "areas": byArea, "menu": menu, "freePages": freePages, - "kinds": moduleKinds, "names": moduleNames, "areaNames": areaNames, "areaOrder": []string{"above", "below"}}, nil -} - -func (s *Server) handleLayout(w http.ResponseWriter, r *http.Request) { - data, err := s.layoutData(r) - if err != nil { - s.serverError(w, err) - return - } - s.render(w, r, "dashboard/layout.html", data) -} - -func (s *Server) handleLayoutWidths(w http.ResponseWriter, r *http.Request) { - blog := currentBlog(r) - theme := LayoutFromForm(ParseTheme(blog.ThemeJSON), r.Form) - if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil { - s.serverError(w, err) - return - } - redirectOK(w, r, s.layoutURL(r)+"#columns", s.tr(r, "Columns saved. Refresh your blog to see them.")) -} - -// handleAreaToggle shows or hides a whole area, keeping its modules. -func (s *Server) handleAreaToggle(w http.ResponseWriter, r *http.Request) { - blog := currentBlog(r) - area := r.PathValue("area") - if _, ok := areaNames[area]; !ok || singleModuleAreas[area] { - http.NotFound(w, r) - return - } - theme := ParseTheme(blog.ThemeJSON) - on := !theme.AreaOn(area) - theme.SetAreaOn(area, on) - if err := blogStore(r).UpdateTheme(r.Context(), theme.JSON()); err != nil { - s.serverError(w, err) - return - } - msg := s.trf(r, "%s hidden.", s.tr(r, areaNames[area])) - if on { - msg = s.trf(r, "%s shown again. Refresh your blog to see it.", s.tr(r, areaNames[area])) - } - redirectOK(w, r, s.layoutURL(r)+"#"+area, msg) -} - -func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) { - area, kind := r.PathValue("area"), r.FormValue("kind") - if !allowedKind(area, kind) { - s.plainError(w, http.StatusBadRequest, s.tr(r, "That module cannot go in that area.")) - return - } - if singleModuleAreas[area] { - mods, err := blogStore(r).ListModules(r.Context()) - if err != nil { - s.serverError(w, err) - return - } - for _, m := range mods { - if m.Area == area { - redirectOK(w, r, s.layoutURL(r)+"#main", s.tr(r, "There is already a module there; edit or remove it first.")) - return - } - } - } - m := &store.Module{Area: area, Kind: kind, Count: 5} - blogLang := currentBlog(r).Language // headings are content: the blog's language, not the viewer's - switch kind { // sensible headings so the module reads well before it is edited - case "archive": - m.Title = i18n.T(blogLang, "Archive") - case "recent": - m.Title = i18n.T(blogLang, "Recent posts") - case "sitemap": - m.Title = i18n.T(blogLang, "Pages") - case "tags": - m.Title = i18n.T(blogLang, "Tags") - m.Count = 10 - case "tagcloud": - m.Title = i18n.T(blogLang, "Tag cloud") - } - m, err := blogStore(r).CreateModule(r.Context(), m) - if err != nil { - s.serverError(w, err) - return - } - if kind == "html" || kind == "text" { - redirectOK(w, r, s.layoutURL(r)+"/modules/"+strconv.FormatInt(m.ID, 10)+"/edit", s.tr(r, "Added. Now write what it should show.")) - return - } - redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(area), s.trf(r, "%s added. Refresh your blog to see it.", s.tr(r, moduleNames[kind]))) -} - -// anchorFor is the layout.html card an area is edited in. -func anchorFor(area string) string { - if singleModuleAreas[area] { - return "main" - } - return area -} - -func (s *Server) loadModule(w http.ResponseWriter, r *http.Request) *store.Module { - id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) - m, err := blogStore(r).ModuleByID(r.Context(), id) - if err != nil { - if errors.Is(err, store.ErrNotFound) { - http.NotFound(w, r) - } else { - s.serverError(w, err) - } - return nil - } - return m -} - -func (s *Server) handleModuleForm(w http.ResponseWriter, r *http.Request) { - m := s.loadModule(w, r) - if m == nil { - return - } - if !moduleHasSettings(*m) { - http.Redirect(w, r, s.layoutURL(r), http.StatusSeeOther) - return - } - s.render(w, r, "dashboard/module_form.html", s.moduleFormData(r, m, "")) -} - -func (s *Server) moduleFormData(r *http.Request, m *store.Module, errMsg string) map[string]any { - d := map[string]any{"module": m, "name": s.tr(r, moduleNames[m.Kind]), "area": s.tr(r, areaNames[m.Area]), "counts": []int{3, 5, 8, 10, 15, 20}, "tagCounts": []int{5, 10, 20, 30, 50}} - if errMsg != "" { - d["error"] = errMsg - } - return d -} - -func (s *Server) handleModuleSave(w http.ResponseWriter, r *http.Request) { - m := s.loadModule(w, r) - if m == nil { - return - } - m.Title = strings.TrimSpace(r.FormValue("title")) - m.Body = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") - if n, err := strconv.Atoi(r.FormValue("count")); err == nil { - if m.Kind == "tags" { // 0 = all of them - m.Count = clampInt(n, 0, maxTagListCount) - } else { - m.Count = clampInt(n, 1, maxRecentCount) - } - } - fail := func(msg string) { - s.renderStatus(w, r, http.StatusBadRequest, "dashboard/module_form.html", s.moduleFormData(r, m, msg)) - } - switch { - case len(m.Title) > 120: - fail(s.tr(r, "Heading is too long (max 120 characters).")) - return - case len(m.Body) > maxModuleBytes: - fail(s.tr(r, "Content is too long (20 KB max).")) - return - } - if err := blogStore(r).UpdateModule(r.Context(), m); err != nil { - s.serverError(w, err) - return - } - redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), s.tr(r, "Saved. Refresh your blog to see it.")) -} - -func (s *Server) handleModuleMove(w http.ResponseWriter, r *http.Request) { - m := s.loadModule(w, r) - if m == nil { - return - } - dir := 1 - if r.FormValue("dir") == "up" { - dir = -1 - } - if err := blogStore(r).MoveModule(r.Context(), m.ID, dir); err != nil { - s.serverError(w, err) - return - } - http.Redirect(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), http.StatusSeeOther) -} - -// handleModuleDeleteConfirm guards modules with content the blogger typed in; -// the list removes the others directly. -func (s *Server) handleModuleDeleteConfirm(w http.ResponseWriter, r *http.Request) { - m := s.loadModule(w, r) - if m == nil { - return - } - if strings.TrimSpace(m.Body) == "" { - s.deleteModule(w, r, m) - return - } - s.render(w, r, "dashboard/confirm.html", map[string]any{"what": s.trf(r, "the “%s” module and its text", s.tr(r, moduleNames[m.Kind])), "action": r.URL.Path, "back": s.layoutURL(r)}) -} - -func (s *Server) handleModuleDelete(w http.ResponseWriter, r *http.Request) { - if m := s.loadModule(w, r); m != nil { - s.deleteModule(w, r, m) - } -} - -func (s *Server) deleteModule(w http.ResponseWriter, r *http.Request, m *store.Module) { - if err := blogStore(r).DeleteModule(r.Context(), m.ID); err != nil { - s.serverError(w, err) - return - } - redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), s.trf(r, "%s removed.", s.tr(r, moduleNames[m.Kind]))) -} - -// ---- menu -------------------------------------------------------------------- - -func (s *Server) handleMenuAddPage(w http.ResponseWriter, r *http.Request) { - pageID, _ := strconv.ParseInt(r.FormValue("page"), 10, 64) - if pageID == 0 { - redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Pick a page first.")) - return - } - if err := blogStore(r).AddMenuPage(r.Context(), pageID); err != nil { - s.serverError(w, err) - return - } - redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Page added to the menu. Refresh your blog to see it.")) -} - -func (s *Server) handleMenuAddLink(w http.ResponseWriter, r *http.Request) { - label, link := strings.TrimSpace(r.FormValue("label")), strings.TrimSpace(r.FormValue("url")) - if link != "" && !strings.HasPrefix(link, "/") && !strings.Contains(link, ":") { - link = "https://" + link // "example.org" is what people type - } - switch { - case label == "" || len(label) > maxMenuLabel: - redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Give the link a short text (max 60 characters).")) - return - case !validLinkURL(link): - redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "That address does not look right. Use a full web address like https://example.org or a path like /about.")) - return - } - if err := blogStore(r).AddMenuLink(r.Context(), label, link); err != nil { - s.serverError(w, err) - return - } - redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Link added to the menu. Refresh your blog to see it.")) -} - -func (s *Server) handleMenuMove(w http.ResponseWriter, r *http.Request) { - id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) - dir := 1 - if r.FormValue("dir") == "up" { - dir = -1 - } - if err := blogStore(r).MoveMenuItem(r.Context(), id, dir); err != nil { - s.serverError(w, err) - return - } - http.Redirect(w, r, s.layoutURL(r)+"#menu", http.StatusSeeOther) -} - -func (s *Server) handleMenuDelete(w http.ResponseWriter, r *http.Request) { - id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) - if err := blogStore(r).DeleteMenuItem(r.Context(), id); err != nil { - s.serverError(w, err) - return - } - redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Removed from the menu.")) -} diff --git a/internal/web/routes.go b/internal/web/routes.go index 563639c..d846160 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -47,22 +47,9 @@ func (s *Server) rootRoutes() http.Handler { m.HandleFunc("POST /b/{sub}/announcements/{id}/delete", s.withBlog(s.handleSectionDelete)) m.HandleFunc("POST /b/{sub}/announcements/{id}/move", s.withBlog(s.handleSectionMove)) m.HandleFunc("POST /b/{sub}/announcements/{id}/toggle", s.withBlog(s.handleSectionToggle)) - m.HandleFunc("GET /b/{sub}/layout", s.withBlog(s.handleLayout)) - m.HandleFunc("POST /b/{sub}/layout/widths", s.withBlog(s.handleLayoutWidths)) - m.HandleFunc("POST /b/{sub}/layout/areas/{area}/toggle", s.withBlog(s.handleAreaToggle)) - m.HandleFunc("POST /b/{sub}/layout/areas/{area}/add", s.withBlog(s.handleModuleAdd)) - m.HandleFunc("GET /b/{sub}/layout/modules/{id}/edit", s.withBlog(s.handleModuleForm)) - m.HandleFunc("POST /b/{sub}/layout/modules/{id}/edit", s.withBlog(s.handleModuleSave)) - m.HandleFunc("POST /b/{sub}/layout/modules/{id}/move", s.withBlog(s.handleModuleMove)) - m.HandleFunc("GET /b/{sub}/layout/modules/{id}/delete", s.withBlog(s.handleModuleDeleteConfirm)) - m.HandleFunc("POST /b/{sub}/layout/modules/{id}/delete", s.withBlog(s.handleModuleDelete)) - m.HandleFunc("POST /b/{sub}/layout/menu/add-page", s.withBlog(s.handleMenuAddPage)) - m.HandleFunc("POST /b/{sub}/layout/menu/add-link", s.withBlog(s.handleMenuAddLink)) - m.HandleFunc("POST /b/{sub}/layout/menu/{id}/move", s.withBlog(s.handleMenuMove)) - m.HandleFunc("POST /b/{sub}/layout/menu/{id}/delete", s.withBlog(s.handleMenuDelete)) + m.HandleFunc("GET /b/{sub}/layout", s.withBlog(s.handleLayoutRedirect)) // the old tab m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm)) m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign)) - m.HandleFunc("POST /b/{sub}/design/preset", s.withBlog(s.handleDesignPreset)) m.HandleFunc("POST /b/{sub}/design/reset", s.withBlog(s.handleDesignReset)) m.HandleFunc("GET /b/{sub}/files", s.withBlog(s.handleFiles)) m.HandleFunc("POST /b/{sub}/files/upload", s.withBlogFiles(maxUploadFiles, s.handleFileUpload)) diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index 3ef96c2..32561bf 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -195,28 +195,25 @@ table.files td.name { word-break: break-all; } table.files .copy { width: 100%; min-width: 11em; padding: 0.3em 0.4em; margin: 0; font-family: var(--mono); font-size: 0.75em; } .pager { margin: 0 0 1.4em; text-align: center; font-size: 0.92em; } .pager a, .pager span { margin: 0 0.8em; } -.imagepick { margin-top: 0.8em; padding-top: 0.6em; border-top: 1px solid var(--border); } -.imagepick > label { margin-bottom: 0.3em; } -.imagepick .picks { display: flex; flex-wrap: wrap; gap: 0.7em; margin: 0.4em 0 0; } -.imagepick .pick { width: 120px; margin: 0; padding: 0.4em; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); text-align: center; font-size: 0.75em; font-weight: normal; cursor: pointer; } -.imagepick .pick:hover { border-color: var(--border-strong); background: var(--surface-2); } -.imagepick .pick input { display: block; width: auto; margin: 0 auto 0.3em; } -.imagepick .pick img, .imagepick .pick .swatch { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); } -.imagepick .pick img { object-fit: contain; } -.imagepick .pick .swatch { line-height: 64px; font-size: 1.6em; color: var(--muted); } -.imagepick .pick .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.imagepick .pick input:checked ~ img, .imagepick .pick input:checked ~ .swatch { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); } - -/* ---- design page ---------------------------------------------------------- */ -.jumps { display: flex; flex-wrap: wrap; gap: 0.4em; margin: 0 0 1.2em; font-size: 0.85em; } -.jumps a { display: inline-block; padding: 0.2em 0.75em; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--text); text-decoration: none; } -.jumps a:hover { border-color: var(--accent); color: var(--accent); } +/* ---- design page: side index, one form, module rows, image pickers ---------- */ +.design { display: grid; grid-template-columns: 11em minmax(0, 1fr); gap: 0 1.4em; align-items: start; } +.sidenav { position: sticky; top: 1em; display: flex; flex-direction: column; gap: 0.15em; font-size: 0.9em; } +.sidenav a { padding: 0.35em 0.7em; border-radius: var(--radius-sm); color: var(--muted); text-decoration: none; font-weight: 500; } +.sidenav a:hover { background: var(--surface); color: var(--text); } +.sidenav a.active { background: var(--accent-soft); color: var(--accent-hover); } +.design .card h3 { margin-top: 1.4em; padding-top: 0.9em; border-top: 1px solid var(--border); } +.design .card h3:first-of-type { margin-top: 0.8em; } +.design .card h2 + h3 { margin-top: 0; padding-top: 0; border-top: 0; } +.design h4 { font-size: 0.9em; font-weight: 600; margin: 1em 0 0.2em; } +/* what only makes sense with, or without, the script */ +form.js .nojs { display: none !important; } +form:not(.js) .jsonly { display: none !important; } .presets { display: flex; flex-wrap: wrap; gap: 0.8em; } .presets .preset { width: 8.5em; margin: 0; padding: 0.55em 0.45em; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.8em; font-weight: 500; text-align: center; } .presets .preset:hover { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); } .presets .sw { display: inline-block; width: 22%; height: 2.2em; margin: 0 1%; border: 1px solid rgba(0,0,0,0.15); border-radius: 3px; vertical-align: middle; } .presets .pname { display: block; margin-top: 0.4em; } -/* layout page: the column diagram, area cards with their module tables */ +/* the column diagram */ .lay { margin: 0 0 1em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-2); font-size: 0.75em; font-weight: 500; text-align: center; overflow: hidden; } .lay-row { padding: 0.5em; background: var(--text); color: #fff; } .lay-cols { display: flex; padding: 0.5em; gap: 0.3em; } @@ -226,38 +223,85 @@ table.files .copy { width: 100%; min-width: 11em; padding: 0.3em 0.4em; margin: .lay-col.off { text-decoration: none; } .lay-col.empty { background: transparent; } .row input[type=number] { width: 6em; } -.cardhead { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 1em; } -.cardhead h2 { margin: 0 0 0.6em; } -.cardhead .switch { margin: 0 0 0.6em auto; } -.card.area-off { border-style: dashed; box-shadow: none; } -.card.area-off .modules, .card.area-off .addmod { opacity: 0.6; } -table.modules { margin: 0.5em 0 1em; } -table.modules td.order, table.modules td.actions { width: 1%; } -table.modules th { width: 10em; } -.addmod { border-top: 1px solid var(--border); padding-top: 0.6em; } -.addmod label, .add label { display: inline-block; margin: 0 0.8em 0.4em 0; min-width: 14em; } -.addmod select, .add select, .add input { width: auto; min-width: 14em; } -.addmod button, .add button { vertical-align: bottom; margin-bottom: 0.4em; } +/* module and menu rows */ +.arealist { margin: 0.6em 0 0; } +.arealist.area-off .modlist, .arealist.area-off .addmod { opacity: 0.55; } +.modlist { margin: 0.4em 0; } +.modlist:empty { display: none; } +.modrow { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4em 0.7em; padding: 0.45em 0.6em; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); margin: 0 0 0.4em; } +.modrow .order { display: inline-flex; gap: 0.2em; } +.modrow .order .pos { width: 3.6em; margin: 0; padding: 0.2em 0.3em; font-size: 0.85em; } +.modrow .modname { font-weight: 500; } +.modrow .tag { margin-left: 0; } +.modrow .modact { margin-left: auto; } +.modrow .modact label.check { display: inline; margin: 0; font-size: 0.85em; } +.modrow .modset { flex-basis: 100%; font-size: 0.95em; } +.modrow .modset summary { cursor: pointer; color: var(--muted); font-size: 0.9em; } +.modrow .modset summary .modsum { font-style: italic; } +.modrow .modset[open] summary { color: var(--text); } +.modrow .modset label { margin: 0.5em 0; } +.modrow .modset .row { gap: 0 1em; } +.modrow .modset .row label { flex: 0 1 14em; min-width: 10em; } +.modrow .modset p:last-child { margin-bottom: 0.3em; } +.modrow .linkfields { display: inline-flex; flex-wrap: wrap; gap: 0.4em; flex: 1 1 14em; } +.modrow .linkfields input { flex: 1 1 8em; width: auto; margin: 0; padding: 0.25em 0.45em; font-size: 0.9em; } +.addmod label { display: inline-block; margin: 0.3em 0 0.4em; min-width: 14em; } +.addmod select { width: auto; min-width: 14em; } .addrow { margin-top: 1em; border-top: 1px solid var(--border); padding-top: 0.6em; margin-bottom: 0; } +.addrow h3 { margin: 0.5em 0 0.2em; padding: 0; border: 0; } +.addrow select, .addrow input { width: auto; min-width: 14em; } +.addrow button { vertical-align: bottom; margin-bottom: 0.4em; } textarea.mono { font-size: 0.9em; } .hoverpick { margin: 0.8em 0; } .hoverpick > input { width: auto; margin: 0 0.4em 0 0; vertical-align: middle; } .hoverpick > label.check { display: inline; margin: 0; } .hoverpick .hover-color { margin: 0.4em 0 0 1.6em; } .hoverpick > input:not(:checked) ~ .hover-color { display: none; } -.savebar { position: sticky; bottom: 0; margin: 0; padding: 0.8em 0; background: var(--bg); border-top: 1px solid var(--border-strong); display: flex; gap: 0.8em; align-items: center; } +/* image fields: the chosen image, the select (or the library button) and an upload */ +.imagepick { margin: 0.9em 0 0; } +.imagepick > label { margin-bottom: 0.3em; } +.pickrow { display: flex; gap: 1em; align-items: flex-start; } +.pickrow .preview { flex: 0 0 auto; } +.pickrow .preview img, .pickrow .preview .swatch { display: block; width: 96px; height: 72px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); object-fit: contain; } +.pickrow .preview .swatch { line-height: 72px; text-align: center; font-size: 1.6em; color: var(--muted); } +.pickrow .pickctl { flex: 1 1 auto; min-width: 0; } +.pickrow .pickctl select { max-width: 24em; margin: 0 0 0.5em; } +.pickrow .pickctl .upload { margin: 0.5em 0 0; font-size: 0.85em; } +.pickrow .pickctl .upload input { max-width: 24em; } +.picker { position: fixed; inset: 0; z-index: 20; background: rgba(16, 24, 40, 0.45); padding: 3vh 1em; overflow: auto; } +.picker-box { max-width: 860px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 8px 30px rgba(16, 24, 40, 0.25); padding: 1em 1.2em; } +.picker-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6em; margin-bottom: 0.8em; } +.picker-head input { flex: 1 1 12em; width: auto; margin: 0; padding: 0.4em 0.6em; } +.picker .picks { display: flex; flex-wrap: wrap; gap: 0.7em; } +.picker .pick { width: 120px; margin: 0; padding: 0.4em; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); text-align: center; font-size: 0.75em; font-weight: normal; cursor: pointer; } +.picker .pick:hover { border-color: var(--accent); background: var(--surface-2); color: var(--text); } +.picker .pick img { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); object-fit: contain; } +.picker .pick .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.picker .pager { margin: 0.8em 0 0; } +.picker .pager button[disabled] { opacity: 0.4; cursor: default; } +.savebar { position: sticky; bottom: 0; z-index: 5; margin: 0; padding: 0.8em 0; background: var(--bg); border-top: 1px solid var(--border-strong); display: flex; flex-wrap: wrap; gap: 0.8em; align-items: center; } +.savebar .unsaved { font-size: 0.85em; font-weight: 600; color: #92400e; background: #fef3c7; border: 1px solid #fcd34d; border-radius: 999px; padding: 0.15em 0.7em; } +.savebar .viewlink { margin-left: auto; font-size: 0.9em; } details.reset { margin-top: 1.6em; } /* ---- login --------------------------------------------------------------- */ .login-brand { text-align: center; font-weight: 700; font-size: 1.2em; letter-spacing: -0.01em; margin: 2.5em 0 1em; } +@media (max-width: 900px) { + .design { display: block; } + .sidenav { position: static; flex-direction: row; flex-wrap: wrap; margin: 0 0 1em; gap: 0.3em; } + .sidenav a { border: 1px solid var(--border); background: var(--surface); } +} @media (max-width: 700px) { body { font-size: 15px; } .cols { grid-template-columns: 1fr; gap: 1.2em; } .row { gap: 0; } .row label { flex-basis: 100%; } - .imagepick .pick { width: 46%; } + .picker .pick { width: 46%; } .presets .preset { width: 46%; } + .sidenav { flex-wrap: nowrap; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; margin: 0 -1em 1em; padding: 0 1em; } + .pickrow { flex-wrap: wrap; } + .modrow .modact label.check { font-size: 0.8em; } .topbar .links { margin-left: 0; flex-basis: 100%; gap: 1em; } .blogbar .topbar-inner { display: block; } .blogbar .blogname { display: block; max-width: none; padding-bottom: 0.3em; } 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"}} ↗</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"}} ↗</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"}} ↗</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"}} ↗</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">← {{t "Newer"}}</button> <span class="pnum"></span> <button type="button" class="mini" data-pick="next">{{t "Older"}} →</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">×</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"}}">▲</button><button type="button" class="mini jsonly" data-act="down" title="{{t "Move down"}}">▼</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"}}">✕</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="<p>{{t "Hello!"}}</p>" 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"}}">▲</button><button type="button" class="mini jsonly" data-act="down" title="{{t "Move down"}}">▼</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"}}">✕</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"}} ↗</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"}}">▲</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"}}">▼</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"}}">▲</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"}}">▼</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="<p>{{t "Hello!"}}</p>">{{$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"}} ↗</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> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> · <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Arrange the layout"}}</a> · <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> · <a href="/b/{{.Blog.Subdomain}}/files">{{t "Upload files"}}</a></p> + <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> · <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> · <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">×</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">×</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}} diff --git a/internal/web/theme.go b/internal/web/theme.go index 2853667..acfe158 100644 --- a/internal/web/theme.go +++ b/internal/web/theme.go @@ -34,6 +34,7 @@ type Theme struct { HeaderAlign string `json:"header_align"` // left | center HeaderBg string `json:"header_bg"` HeaderText string `json:"header_text"` + TitleSize string `json:"title_size"` // normal | small | large (the site title in the header) NavAlign string `json:"nav_align"` // left | center | right (header menu) NavStyle string `json:"nav_style"` // normal | uppercase @@ -41,11 +42,13 @@ type Theme struct { NavText string `json:"nav_text"` NavHover string `json:"nav_hover"` // "" = same as NavText - ShowDates bool `json:"show_dates"` - DateFormat string `json:"date_format"` // long | short | iso (rendered by i18n.FormatDate) + ShowDates bool `json:"show_dates"` + DateFormat string `json:"date_format"` // long | short | iso (rendered by i18n.FormatDate) + PostsPerPage int `json:"posts_per_page"` // on listings and tag pages; one of postsPerPageChoices FooterBg string `json:"footer_bg"` FooterColor string `json:"footer_color"` + FooterAlign string `json:"footer_align"` // center | left Logo string `json:"logo"` // image uuid or "" LogoSize string `json:"logo_size"` // small | medium | large @@ -73,16 +76,19 @@ const ( maxColumnsSum = 70 // main keeps at least 30% ) +// postsPerPageChoices are the listing sizes the design form offers; the first is the default. +var postsPerPageChoices = []int{10, 5, 15, 20} + func DefaultTheme() Theme { return Theme{ BgColor: "#e9e6df", BgMode: "cover", ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", LinkUnderline: "always", ContentStyle: "card", ContentPadding: "normal", Font: "sans", HeadingFont: "same", FontSize: "normal", - HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", + HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", TitleSize: "normal", NavAlign: "left", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222", - ShowDates: true, DateFormat: "long", - FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", + ShowDates: true, DateFormat: "long", PostsPerPage: postsPerPageChoices[0], + FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterAlign: "center", LogoSize: "medium", HeaderOn: true, LeftOn: true, RightOn: true, FooterOn: true, LeftWidth: 25, RightWidth: 25, } @@ -109,6 +115,15 @@ func pick(v string, allowed ...string) string { return allowed[0] } +func pickInt(v int, allowed ...int) int { + for _, a := range allowed { + if v == a { + return v + } + } + return allowed[0] +} + func color(v, def string) string { v = strings.TrimSpace(v) if hexColor.MatchString(v) { @@ -148,14 +163,17 @@ func (t *Theme) normalize() { t.HeaderAlign = pick(t.HeaderAlign, "left", "center") t.HeaderBg = color(t.HeaderBg, d.HeaderBg) t.HeaderText = color(t.HeaderText, d.HeaderText) + t.TitleSize = pick(t.TitleSize, "normal", "small", "large") t.NavAlign = pick(t.NavAlign, "left", "center", "right") t.NavStyle = pick(t.NavStyle, "normal", "uppercase") t.NavBg = color(t.NavBg, d.NavBg) t.NavText = color(t.NavText, d.NavText) t.NavHover = optColor(t.NavHover) t.DateFormat = pick(t.DateFormat, "long", "short", "iso") + t.PostsPerPage = pickInt(t.PostsPerPage, postsPerPageChoices...) t.FooterBg = color(t.FooterBg, d.FooterBg) t.FooterColor = color(t.FooterColor, d.FooterColor) + t.FooterAlign = pick(t.FooterAlign, "center", "left") t.Logo = imageID(t.Logo) t.LogoSize = pick(t.LogoSize, "medium", "small", "large") t.Favicon = imageID(t.Favicon) @@ -176,11 +194,13 @@ func clampInt(v, lo, hi int) int { return v } -// ThemeFromForm reads the design form on top of the current theme. +// ThemeFromForm reads the design form on top of the current theme: colours, +// fonts, images and the layout switches alike. func ThemeFromForm(cur Theme, f url.Values) Theme { t := cur get := func(k string) string { return strings.TrimSpace(f.Get(k)) } on := func(k string) bool { return f.Get(k) == "on" } + num := func(k string) int { n, _ := strconv.Atoi(get(k)); return n } // Hover colours only apply when their checkbox is ticked; otherwise "inherit". hover := func(k string) string { if on(k + "_custom") { @@ -204,6 +224,7 @@ func ThemeFromForm(cur Theme, f url.Values) Theme { t.HeaderAlign = get("header_align") t.HeaderBg = get("header_bg") t.HeaderText = get("header_text") + t.TitleSize = get("title_size") t.NavAlign = get("nav_align") t.NavStyle = get("nav_style") t.NavBg = get("nav_bg") @@ -211,24 +232,18 @@ func ThemeFromForm(cur Theme, f url.Values) Theme { t.NavHover = hover("nav_hover") t.ShowDates = on("show_dates") t.DateFormat = get("date_format") + t.PostsPerPage = num("posts_per_page") t.FooterBg = get("footer_bg") t.FooterColor = get("footer_color") + t.FooterAlign = get("footer_align") t.LogoSize = get("logo_size") t.BgImage = pickImage(t.BgImage, get("bg_image")) t.HeaderImage = pickImage(t.HeaderImage, get("header_image")) t.Favicon = pickImage(t.Favicon, get("favicon")) t.Logo = pickImage(t.Logo, get("logo")) - t.normalize() - return t -} - -// LayoutFromForm reads the columns form of the Layout page; the area -// toggles have their own buttons (SetAreaOn). -func LayoutFromForm(cur Theme, f url.Values) Theme { - t := cur - t.LeftWidth, _ = strconv.Atoi(strings.TrimSpace(f.Get("left_width"))) - t.RightWidth, _ = strconv.Atoi(strings.TrimSpace(f.Get("right_width"))) - t.KeepColumns = f.Get("keep_columns") == "on" + t.HeaderOn, t.LeftOn, t.RightOn, t.FooterOn = on("header_on"), on("left_on"), on("right_on"), on("footer_on") + t.LeftWidth, t.RightWidth = num("left_width"), num("right_width") + t.KeepColumns = on("keep_columns") t.normalize() return t } @@ -248,20 +263,6 @@ func (t Theme) AreaOn(area string) bool { return true } -// SetAreaOn flips an area's switch; unknown areas are ignored. -func (t *Theme) SetAreaOn(area string, on bool) { - switch area { - case "header": - t.HeaderOn = on - case "left": - t.LeftOn = on - case "right": - t.RightOn = on - case "footer": - t.FooterOn = on - } -} - // MainWidth is what the main column gets when both side columns are shown. func (t Theme) MainWidth() int { return 100 - t.LeftWidth - t.RightWidth } @@ -309,6 +310,14 @@ var presets = []Preset{ func Presets() []Preset { return presets } +// Colors is the scheme as the design form's colour inputs, name → value, for +// the script that fills them in. +func (p Preset) Colors() string { + b, _ := json.Marshal(map[string]string{"bg_color": p.Bg, "content_bg": p.ContentBg, "text_color": p.Text, "link_color": p.Link, + "header_bg": p.HeaderBg, "header_text": p.HeaderText, "nav_bg": p.NavBg, "nav_text": p.NavText, "footer_bg": p.FooterBg, "footer_color": p.FooterText}) + return string(b) +} + func PresetByKey(key string) (Preset, bool) { for _, p := range presets { if p.Key == key { @@ -381,6 +390,18 @@ func (t Theme) LogoMaxHeight() string { return "80px" } +// TitleSizeCSS is the site title's font size on wide screens; "normal" is +// left to blog.css so its phone rule keeps working. +func (t Theme) TitleSizeCSS() string { + switch t.TitleSize { + case "small": + return "1.5em" + case "large": + return "2.6em" + } + return "" +} + func (t Theme) HeaderImageMaxHeight() string { switch t.HeaderImageHeight { case "short": @@ -422,11 +443,14 @@ a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderl body.has-left.has-right .col-main { width:{{.MainWidth}}%; } body.has-left:not(.has-right) .col-main { width:{{sub 100 .LeftWidth}}%; } body.has-right:not(.has-left) .col-main { width:{{sub 100 .RightWidth}}%; } +{{- with .TitleSizeCSS}} + .site-title { font-size:{{.}}; } +{{- end}} } {{- with .PaddingPhone}} @media (max-width: 700px) { .content { padding:{{.}}; } } {{- end}} -.site-footer { background:{{.FooterBg}}; color:{{.FooterColor}}; } +.site-footer { background:{{.FooterBg}}; color:{{.FooterColor}};{{if eq .FooterAlign "left"}} text-align:left;{{end}} } .site-footer a { color:{{.FooterColor}}; } `)) diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 48f380e..aa9b78e 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -96,6 +96,9 @@ func TestThemeNormalizeAndCSS(t *testing.T) { if !th.HeaderOn || !th.LeftOn || !th.RightOn || !th.FooterOn || th.LeftWidth != 15 { t.Errorf("old theme layout defaults: %+v", th) } + if th.PostsPerPage != 10 || th.TitleSize != "normal" || th.FooterAlign != "center" { + t.Errorf("old theme content defaults: %+v", th) + } f := url.Values{"bg_color": {"#ABCDEF"}, "bg_image": {"not-a-uuid"}, "logo_size": {"large"}, "link_hover": {"#ff0000"}, "nav_hover": {"#00ff00"}, "nav_hover_custom": {"on"}, "nav_style": {"uppercase"}, "nav_align": {"center"}, "link_underline": {"hover"}, "heading_font": {"serif"}, "content_style": {"flat"}, "content_padding": {"roomy"}, "date_format": {"iso"}} @@ -123,12 +126,20 @@ func TestThemeNormalizeAndCSS(t *testing.T) { } func TestLayoutTheme(t *testing.T) { - th := LayoutFromForm(DefaultTheme(), url.Values{"left_width": {"40"}, "right_width": {"40"}, "keep_columns": {"on"}}) + // The layout switches ride in the design form: the areas are checkboxes, so an unticked one is off. + th := ThemeFromForm(DefaultTheme(), url.Values{"left_width": {"40"}, "right_width": {"40"}, "keep_columns": {"on"}, "header_on": {"on"}, "left_on": {"on"}, "right_on": {"on"}, + "posts_per_page": {"20"}, "title_size": {"large"}, "footer_align": {"left"}}) if th.LeftWidth != 40 || th.RightWidth != 30 || th.MainWidth() != 30 || !th.KeepColumns { t.Errorf("the right column should give way: %+v", th) } - th = LayoutFromForm(th, url.Values{"left_width": {"junk"}, "right_width": {"20"}}) - if th.LeftWidth != 15 || th.RightWidth != 20 || th.KeepColumns { + if th.FooterOn || !th.HeaderOn || th.PostsPerPage != 20 || th.TitleSize != "large" || th.FooterAlign != "left" { + t.Errorf("switches and the content options: %+v", th) + } + if css := th.CSS(); !strings.Contains(css, ".site-title { font-size:2.6em") || !strings.Contains(css, "text-align:left") { + t.Errorf("css: %s", css) + } + th = ThemeFromForm(th, url.Values{"left_width": {"junk"}, "right_width": {"20"}, "posts_per_page": {"7"}}) + if th.LeftWidth != 15 || th.RightWidth != 20 || th.KeepColumns || th.PostsPerPage != 10 { t.Errorf("junk clamps to the minimum: %+v", th) } css := th.CSS() @@ -138,13 +149,74 @@ func TestLayoutTheme(t *testing.T) { t.Errorf("css missing %q:\n%s", want, css) } } - th.SetAreaOn("left", false) - th.SetAreaOn("bogus", false) - if th.AreaOn("left") || !th.AreaOn("right") || !th.AreaOn("above") { + th.LeftOn = false + if th.AreaOn("left") || !th.AreaOn("above") || !th.AreaOn("bogus") { t.Errorf("area switches: %+v", th) } } +// The design form sends every module as indexed fields; pos orders them and +// add.<area> appends. The index only tells rows apart. +func TestParseModulesForm(t *testing.T) { + f := url.Values{ + "mod.0.id": {"1"}, "mod.0.area": {"header"}, "mod.0.kind": {"title"}, "mod.0.pos": {"1"}, + "mod.1.id": {"2"}, "mod.1.area": {"header"}, "mod.1.kind": {"menu"}, "mod.1.pos": {"0"}, + "mod.2.id": {"3"}, "mod.2.area": {"left"}, "mod.2.kind": {"recent"}, "mod.2.pos": {"0"}, "mod.2.title": {" New "}, "mod.2.count": {"99"}, + "mod.3.id": {"4"}, "mod.3.area": {"footer"}, "mod.3.kind": {"rss"}, "mod.3.pos": {"0"}, "mod.3.del": {"on"}, + "mod.7.id": {"0"}, "mod.7.area": {"below"}, "mod.7.kind": {"html"}, "mod.7.body": {"<b>hi</b>\r\n"}, + "add.left": {"tags"}, "add.footer": {""}, + } + mods, err := parseModules(f, "en", "el") + if err != nil { + t.Fatal(err) + } + var got []string + for _, m := range mods { + got = append(got, m.Area+"/"+m.Kind) + } + if want := "header/menu header/title left/recent left/tags below/html"; strings.Join(got, " ") != want { + t.Errorf("order: %v, want %s", got, want) + } + if mods[2].Title != "New" || mods[2].Count != maxRecentCount || mods[2].ID != 3 || mods[3].ID != 0 || mods[3].Title != "Ετικέτες" || mods[3].Count != 10 || mods[4].Body != "<b>hi</b>\n" { + t.Errorf("fields: %+v", mods) + } + if mods, _ := parseModules(url.Values{"mod.0.area": {"header"}, "mod.0.kind": {"html"}, "add.header": {"rss"}}, "en", "en"); len(mods) != 0 { + t.Errorf("kinds an area does not take are dropped: %+v", mods) + } + // Errors still come with every row, so the form can be shown again as sent. + if mods, err := parseModules(url.Values{"mod.0.area": {"above"}, "mod.0.kind": {"html"}, "add.above": {"html"}}, "en", "en"); err == nil || len(mods) != 2 { + t.Errorf("two modules above the posts should be refused: %v %+v", err, mods) + } + if mods, err := parseModules(url.Values{"mod.0.area": {"footer"}, "mod.0.kind": {"text"}, "mod.0.body": {strings.Repeat("x", maxModuleBytes+1)}}, "en", "en"); err == nil || len(mods) != 1 { + t.Errorf("a long body should be refused: %v %+v", err, mods) + } +} + +func TestParseMenuForm(t *testing.T) { + pages := []store.Page{{ID: 1, Title: "Home", Slug: "home", IsHome: true}, {ID: 2, Title: "About", Slug: "about"}} + f := url.Values{ + "menu.0.id": {"10"}, "menu.0.page": {"2"}, "menu.0.pos": {"1"}, + "menu.1.id": {"11"}, "menu.1.label": {"Photos"}, "menu.1.url": {"example.org/pics"}, "menu.1.pos": {"0"}, + "menu.2.id": {"12"}, "menu.2.page": {"9"}, "menu.2.pos": {"2"}, // deleted meanwhile + "menu.3.id": {"13"}, "menu.3.page": {"2"}, "menu.3.pos": {"3"}, // listed twice + "menu_add_page": {"1"}, "menu_add_label": {"Mail"}, "menu_add_url": {"mailto:me@example.org"}, + } + menu, err := parseMenu(f, "en", pages) + if err != nil { + t.Fatal(err) + } + if len(menu) != 4 || menu[0].ID != 11 || menu[0].URL != "https://example.org/pics" || menu[1].ID != 10 || menu[1].PageTitle != "About" || + menu[2].ID != 0 || !menu[2].IsHome || menu[3].URL != "mailto:me@example.org" || menu[3].Label != "Mail" { + t.Errorf("menu: %+v", menu) + } + if menu, err := parseMenu(url.Values{"menu.0.label": {"x"}, "menu.0.url": {"not a url"}}, "en", pages); err == nil || len(menu) != 1 { + t.Errorf("a bad address should be refused, and kept for fixing: %v %+v", err, menu) + } + if menu, err := parseMenu(url.Values{"menu_add_label": {""}, "menu_add_url": {"https://example.org"}}, "en", pages); err == nil || len(menu) != 1 { + t.Errorf("a link without text should be refused: %v %+v", err, menu) + } +} + func TestBuildLayout(t *testing.T) { mods := []store.Module{{ID: 1, Area: "header", Kind: "title"}, {ID: 2, Area: "left", Kind: "recent", Count: 3}, {ID: 3, Area: "right", Kind: "recent", Count: 8}, {ID: 4, Area: "right", Kind: "archive"}, {ID: 5, Area: "footer", Kind: "rss"}, {ID: 6, Area: "above", Kind: "html"}, {ID: 7, Area: "right", Kind: "tagcloud"}} @@ -326,8 +398,8 @@ func TestMenuItemLinks(t *testing.T) { func TestRedirectOKKeepsAnchor(t *testing.T) { rec := httptest.NewRecorder() - redirectOK(rec, httptest.NewRequest("GET", "/", nil), "/b/a/layout#menu", "hi there") - if got := rec.Header().Get("Location"); got != "/b/a/layout?ok=hi+there#menu" { + redirectOK(rec, httptest.NewRequest("GET", "/", nil), "/b/a/design#menu", "hi there") + if got := rec.Header().Get("Location"); got != "/b/a/design?ok=hi+there#menu" { t.Errorf("location: %q", got) } } @@ -379,7 +451,7 @@ func TestAllTemplatesParse(t *testing.T) { tpl := newTemplates(false) for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/files.html", "dashboard/settings.html", - "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "dashboard/layout.html", "dashboard/module_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", + "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", "blog/page.html", "blog/post.html", "blog/tag.html", "blog/search.html", "blog/404.html"} { for _, l := range i18n.Languages() { if _, err := tpl.get(l.Code, name); err != nil { |
