From e96bbec551c0388e972894b349413fa6ef607352 Mon Sep 17 00:00:00 2001 From: grm Date: Thu, 17 Sep 2026 21:29:44 +0300 Subject: Add custom fonts: upload WOFF2/WOFF/TTF/OTF files and pick them on the Design tab Fonts become a file kind of their own (sniffed from the bytes like images, served inline from /media), and the theme gets custom_font and custom_heading_font: a chosen file is declared as @font-face and put first in the stack, the built-in Font / Heading font choice behind it as the fallback, so there is no "custom" enum value and no invalid state. The pickers are plain selects with an upload input, no script. Fonts uploaded before this are reclassified by the migration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- AGENTS.md | 26 +++++++--- README.md | 5 +- internal/db/migrations/blog/00010_font_kind.sql | 21 ++++++++ internal/i18n/el.go | 8 ++- internal/web/filetype.go | 19 ++++--- internal/web/filetype_test.go | 11 +++- internal/web/handlers_design.go | 28 ++++++---- internal/web/handlers_files.go | 23 +++++---- internal/web/handlers_pages.go | 4 +- internal/web/handlers_posts.go | 6 +-- internal/web/handlers_sections.go | 2 +- internal/web/static/dashboard.css | 5 ++ internal/web/templates/dashboard/design.html | 8 ++- internal/web/templates/partials/fontpick.html | 13 +++++ internal/web/theme.go | 68 +++++++++++++++++++------ internal/web/web_test.go | 35 ++++++++++++- 16 files changed, 219 insertions(+), 63 deletions(-) create mode 100644 internal/db/migrations/blog/00010_font_kind.sql create mode 100644 internal/web/templates/partials/fontpick.html diff --git a/AGENTS.md b/AGENTS.md index 84b6a7b..d4a9284 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,13 +185,23 @@ internal/web/ server.go (host router, middleware, render helpers) header text colour; hidden on phones so a long title can wrap. `NavStyle` `plain` is regular weight + underline for the menu links (blog.css makes them bold). + **Custom fonts** (`CustomFont`, `CustomHeadingFont`: font file ids or ""): + a chosen file is declared as `@font-face` (`"BlogFont"` / `"BlogHeadingFont"`, + `src:url(/media/)`, same-origin so no CORS) and put first in the stack, + the built-in `Font`/`HeadingFont` choice behind it as the fallback — so + there is no "custom" enum value and no invalid state. `HeadingFontFamily` + builds on the text's stack when `HeadingFont` is `same`, and the `h1…h6` + rule is emitted whenever `HeadingFontSet` (a heading choice or a heading + file). Picked with the `fontpick` partial: a plain `` 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 `_file` wins. + one's filename, fonts included), so the page never grows with the library; `none` clears, a + uuid selects, no value keeps (`pickFile`); an upload in `_file` wins. The `imagelib` partial (one per form; the design page and the post form both include it) is the panel behind "Choose from library…", with its own script: it marks every `.imagepick` with `.js` (hiding the select, showing @@ -245,15 +255,18 @@ internal/web/ server.go (host router, middleware, render helpers) - **Files** (`files` table, `store/files.go`, `handlers_files.go`, `filetype.go`, `/b/{sub}/files…`): the upload library, any type. A row is `id, filename, content_type, kind, size, data`; `kind` (image, document, - audio, video, archive, other) is decided at upload by `fileType`, which + audio, video, font, archive, other) is decided at upload by `fileType`, which trusts `http.DetectContentType` first and lets the extension refine only a generic sniff (text/plain, octet-stream) to a type on the allowlists in `filetype.go`; anything unknown is stored as `application/octet-stream`. + Fonts (`fontTypes`: WOFF2, WOFF, TTF, OTF) are known by their bytes alone, + like images; TTC and EOT stay `other`. `/media/{uuid}` is served on every host with immutable cache headers, from the host's blog database only (the root domain serves the root blog's files); dashboard previews for another blog on the root host use `GET /b/{sub}/media/{id}` (`withBlog`). **The root domain carries the - session cookie, so `servedAs` renders inline only images, PDF, plain text, + session cookie, so `servedAs` renders inline only images, fonts (a browser + parses one only inside `@font-face`), PDF, plain text, audio and video; everything else (HTML, SVG, XML, JS, archives, binaries) goes out as `application/octet-stream` + `Content-Disposition: attachment`.** `?download` forces attachment. SVG is therefore never an image (download @@ -293,7 +306,8 @@ internal/web/ server.go (host router, middleware, render helpers) `outro_file`) + `appendFile(body, f, format)` (the no-JS path: `fileMarkdown` or `fileHTML`, i.e. ``/``), and store `renderBody(format, src)` (`markdown.Render`, or the source untouched - for HTML) in the `*_html` column — the public templates only ever print + for HTML) in the `*_html` column (`readUpload`'s last argument is the kind + the field must be — `"image"`, `"font"` — or `""` for anything) — the public templates only ever print that column. **HTML mode** (`.editor.html`, toggled by the radios): the Markdown toolbar groups (`.ed-md`), link box, shortcuts, list continuation and cheat-sheet @@ -444,7 +458,7 @@ superadmin password to `admin`. Production refuses both. `-- +goose Up/Down` sections; they run automatically at startup. Never edit an applied migration. Blog chain so far: `00001_init`, `00002_language`, `00003_files`, `00004_tags`, `00005_search`, `00006_format`, `00007_post_image`, - `00008_page_outro`, `00009_gizmo_style`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after + `00008_page_outro`, `00009_gizmo_style`, `00010_font_kind`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after the move to per-blog databases; deployments from before it have `goose_db_version` rows 2–7 in the control DB that must be deleted once (README "Upgrading from a single database") or the next control migration diff --git a/README.md b/README.md index 295fe87..b6adffe 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones. keyboard shortcuts and a rendered preview — or, with the editor's Format switch, in **raw HTML** that goes on the blog exactly as written (embeds, scripts, inline styles; the blogger's own responsibility, like the custom HTML module). Files of any - kind — images, PDFs, archives, audio… — can be inserted straight from the editor (file + kind — images, PDFs, archives, audio, fonts… — can be inserted straight from the editor (file picker, paste or drag-and-drop; with JavaScript off the file is appended on save): images are shown, everything else becomes a download link. The **Files** tab lists them by kind with search, rename and delete. Uploads are 10 MB per file by default; @@ -23,7 +23,8 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones. with it. A post can have a **featured image**: a thumbnail beside it on listings, and the picture above or below the text on its own page. - **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, + (schemes, background colour/image, text and link colours, fonts — built-in + stacks or your own uploaded WOFF2/WOFF/TTF/OTF files), 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, diff --git a/internal/db/migrations/blog/00010_font_kind.sql b/internal/db/migrations/blog/00010_font_kind.sql new file mode 100644 index 0000000..cf741e4 --- /dev/null +++ b/internal/db/migrations/blog/00010_font_kind.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- Fonts become a file kind of their own so the design page can offer them. +ALTER TABLE files DROP CONSTRAINT files_kind_check; +ALTER TABLE files ADD CONSTRAINT files_kind_check + CHECK (kind IN ('image', 'document', 'audio', 'video', 'font', 'archive', 'other')); +-- Fonts uploaded before this were stored as "other"; the magic bytes tell them +-- apart (the same signatures the Go sniffer trusts), and substring() reads +-- only that slice. +UPDATE files SET kind = 'font', content_type = CASE substring(data from 1 for 4) + WHEN '\x774f4632'::bytea THEN 'font/woff2' + WHEN '\x774f4646'::bytea THEN 'font/woff' + WHEN '\x4f54544f'::bytea THEN 'font/otf' + ELSE 'font/ttf' END +WHERE kind = 'other' + AND substring(data from 1 for 4) IN ('\x774f4632'::bytea, '\x774f4646'::bytea, '\x4f54544f'::bytea, '\x00010000'::bytea); + +-- +goose Down +UPDATE files SET kind = 'other', content_type = 'application/octet-stream' WHERE kind = 'font'; +ALTER TABLE files DROP CONSTRAINT files_kind_check; +ALTER TABLE files ADD CONSTRAINT files_kind_check + CHECK (kind IN ('image', 'document', 'audio', 'video', 'archive', 'other')); diff --git a/internal/i18n/el.go b/internal/i18n/el.go index 911eaf0..852d9ab 100644 --- a/internal/i18n/el.go +++ b/internal/i18n/el.go @@ -194,6 +194,7 @@ var el = map[string]string{ "File is too large (max %s).": "Το αρχείο είναι πολύ μεγάλο (έως %s).", "File is empty.": "Το αρχείο είναι κενό.", "Only PNG, JPEG, GIF, WebP and ICO images can be used here.": "Εδώ μπορούν να χρησιμοποιηθούν μόνο εικόνες PNG, JPEG, GIF, WebP και ICO.", + "Only WOFF2, WOFF, TTF and OTF fonts can be used here.": "Εδώ μπορούν να χρησιμοποιηθούν μόνο γραμματοσειρές WOFF2, WOFF, TTF και OTF.", "Upload limit": "Όριο μεταφόρτωσης", "default": "προεπιλογή", "change": "αλλαγή", @@ -393,7 +394,7 @@ var el = map[string]string{ "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.", "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).": "Επαναφέρει κάθε χρώμα, γραμματοσειρά και επιλογή διάταξης όπως ξεκινά ένα νέο ιστολόγιο και ξεχνά τις επιλεγμένες εικόνες (μένουν στη συλλογή σας).", + "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images and fonts (they stay in your library).": "Επαναφέρει κάθε χρώμα, γραμματοσειρά και επιλογή διάταξης όπως ξεκινά ένα νέο ιστολόγιο και ξεχνά τις επιλεγμένες εικόνες και γραμματοσειρές (μένουν στη συλλογή σας).", "Reset everything": "Επαναφορά όλων", "None": "Κανένα", "…or upload a new one": "…ή μεταφορτώστε καινούργια", @@ -464,6 +465,11 @@ var el = map[string]string{ "Page background": "Φόντο σελίδας", "Text & links": "Κείμενο & σύνδεσμοι", "Fonts": "Γραμματοσειρές", + "Your own fonts": "Δικές σας γραμματοσειρές", + "A WOFF2, WOFF, TTF or OTF file from your library. It is used instead of the choice above, which readers see until it loads or if their browser cannot show it.": "Ένα αρχείο WOFF2, WOFF, TTF ή OTF από τη συλλογή σας. Χρησιμοποιείται αντί για την παραπάνω επιλογή, την οποία βλέπουν οι αναγνώστες μέχρι να φορτώσει ή αν ο φυλλομετρητής τους δεν μπορεί να τη δείξει.", + "Text font": "Γραμματοσειρά κειμένου", + "None: headings use the text font, or the heading font above.": "Κανένα: οι επικεφαλίδες χρησιμοποιούν τη γραμματοσειρά κειμένου ή την παραπάνω γραμματοσειρά επικεφαλίδων.", + "Current font": "Τρέχουσα γραμματοσειρά", "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": "Διατήρηση των παρακάτω χρωμάτων", diff --git a/internal/web/filetype.go b/internal/web/filetype.go index 9c0f590..60a220e 100644 --- a/internal/web/filetype.go +++ b/internal/web/filetype.go @@ -17,7 +17,8 @@ import ( // SVG or XML page must never render there. The rules below make that a // property of the stored content type: the sniffer is trusted first, a // filename extension may only refine a generic sniff to a type on an -// allowlist, and anything not on the inline list is a download. +// allowlist, and anything not on the inline list is a download. Fonts are +// on it: a browser only ever parses one inside @font-face, never as a page. // maxUploadFiles is how many files one Files-page request may carry; the body // cap of that route is this many upload limits. @@ -27,10 +28,10 @@ const maxUploadFiles = 10 // offsets, and the whole upload sits in memory while it is stored. const maxUploadMB = 1024 -var fileKinds = []string{"image", "document", "audio", "video", "archive", "other"} +var fileKinds = []string{"image", "document", "audio", "video", "font", "archive", "other"} // fileKindNames are the tab labels (translated where used, like moduleNames). -var fileKindNames = map[string]string{"image": "Images", "document": "Documents", "audio": "Audio", "video": "Video", "archive": "Archives", "other": "Other"} +var fileKindNames = map[string]string{"image": "Images", "document": "Documents", "audio": "Audio", "video": "Video", "font": "Fonts", "archive": "Archives", "other": "Other"} var imageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, "image/x-icon": true, "image/avif": true, "image/bmp": true} @@ -50,6 +51,10 @@ var archiveTypes = map[string]bool{ "application/x-tar": true, "application/x-bzip2": true, "application/x-xz": true, "application/x-rar-compressed": true, "application/vnd.rar": true, } +// fontTypes are what the design page's font pickers take. Like images they +// sniff exactly, so the name never refines into one; TTC and EOT stay "other". +var fontTypes = map[string]bool{"font/woff2": true, "font/woff": true, "font/ttf": true, "font/otf": true} + var mediaTypes = map[string]bool{ "audio/mpeg": true, "audio/mp4": true, "audio/ogg": true, "audio/flac": true, "audio/wav": true, "audio/wave": true, "audio/x-wav": true, "audio/webm": true, "audio/aac": true, "video/mp4": true, "video/ogg": true, "video/webm": true, "video/x-matroska": true, "video/quicktime": true, @@ -91,8 +96,8 @@ func fileType(head []byte, filename string) (contentType, kind string) { ct = "application/octet-stream" } } - if !imageTypes[ct] && !extAllowed(ct) && !strings.HasPrefix(ct, "audio/") && !strings.HasPrefix(ct, "video/") { - ct = "application/octet-stream" // sniffed HTML/XML, fonts, and everything else we do not name + if !imageTypes[ct] && !fontTypes[ct] && !extAllowed(ct) && !strings.HasPrefix(ct, "audio/") && !strings.HasPrefix(ct, "video/") { + ct = "application/octet-stream" // sniffed HTML/XML and everything else we do not name } return ct, kindOf(ct) } @@ -123,6 +128,8 @@ func kindOf(ct string) string { return "audio" case strings.HasPrefix(ct, "video/"): return "video" + case fontTypes[ct]: + return "font" case archiveTypes[ct]: return "archive" } @@ -131,7 +138,7 @@ func kindOf(ct string) string { // inlineOK says whether a browser may render the type in place. func inlineOK(ct string) bool { - return imageTypes[ct] || ct == "application/pdf" || ct == "text/plain" || strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "video/") + return imageTypes[ct] || fontTypes[ct] || ct == "application/pdf" || ct == "text/plain" || strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "video/") } // servedAs is the Content-Type and disposition /media answers with. diff --git a/internal/web/filetype_test.go b/internal/web/filetype_test.go index 1f1d89e..45c4166 100644 --- a/internal/web/filetype_test.go +++ b/internal/web/filetype_test.go @@ -35,8 +35,14 @@ func TestFileType(t *testing.T) { {"hello world", "notes.txt", "text/plain", "document"}, {"hello world", "server.log", "text/plain", "document"}, // text/* names only confirm the sniff {"hello world", "notes.md", "text/plain", "document"}, - {"hello world", "README", "text/plain", "document"}, // no extension: the bytes are all we have - {"\x00\x01\x02\x03\xff\xfe", "font.ttf", "application/octet-stream", "other"}, + {"hello world", "README", "text/plain", "document"}, // no extension: the bytes are all we have + {"\x00\x01\x02\x03\xff\xfe", "font.ttf", "application/octet-stream", "other"}, // a font is known by its bytes + {"wOF2\x00\x01\x00\x00", "body.woff2", "font/woff2", "font"}, + {"wOFF\x00\x01\x00\x00", "body.woff", "font/woff", "font"}, + {"\x00\x01\x00\x00\x00\x10", "body.ttf", "font/ttf", "font"}, + {"OTTO\x00\x10\x00\x80", "body.otf", "font/otf", "font"}, + {"OTTO\x00\x10\x00\x80", "evil.html", "font/otf", "font"}, + {"ttcf\x00\x01\x00\x00", "family.ttc", "application/octet-stream", "other"}, {"hello world", "data.csv", "text/csv", "document"}, {"hello world", "evil.html", "application/octet-stream", "other"}, {"", "evil.svg", "application/octet-stream", "other"}, @@ -69,6 +75,7 @@ func TestServedAs(t *testing.T) { {"text/plain", false, "text/plain; charset=utf-8", "inline"}, {"audio/mpeg", false, "audio/mpeg", "inline"}, {"video/mp4", false, "video/mp4", "inline"}, + {"font/woff2", false, "font/woff2", "inline"}, {"application/zip", false, "application/octet-stream", "attachment"}, {"text/html", false, "application/octet-stream", "attachment"}, {"image/svg+xml", false, "application/octet-stream", "attachment"}, diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go index acc7569..792dcdc 100644 --- a/internal/web/handlers_design.go +++ b/internal/web/handlers_design.go @@ -13,7 +13,8 @@ import ( // 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. +// rest are fetched by the script's library panel, page by page. The font +// pickers list as many, with no panel behind them: a blog has a few fonts. const recentImages = 48 // designView is the state the design page shows: what the database holds, or @@ -71,13 +72,17 @@ func (s *Server) designData(r *http.Request, v designView) (map[string]any, erro if err != nil { return nil, err } - // The pickers list the recent images; a chosen one older than that still + fonts, _, err := bs.ListFiles(r.Context(), "font", "", recentImages, 0) + if err != nil { + return nil, err + } + // The pickers list the recent files; a chosen one older than that still // needs its name for the select. names := map[string]string{} - for _, f := range images { + for _, f := range append(images, fonts...) { names[f.ID.String()] = f.Filename } - for _, id := range []string{v.Theme.BgImage, v.Theme.HeaderImage, v.Theme.Logo, v.Theme.Favicon} { + for _, id := range []string{v.Theme.BgImage, v.Theme.HeaderImage, v.Theme.Logo, v.Theme.Favicon, v.Theme.CustomFont, v.Theme.CustomHeadingFont} { if id == "" || names[id] != "" { continue } @@ -115,7 +120,7 @@ func (s *Server) designData(r *http.Request, v designView) (map[string]any, erro "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(), + "images": images, "fonts": fonts, "imageNames": names, "imageCount": imageCount, "presets": Presets(), "today": time.Now(), }, nil } @@ -164,16 +169,17 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) { // Optional direct uploads from the pickers. One that is stored stays // chosen in the form even when something else is rejected. for _, u := range []struct { - field string - dst *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, u.field, true) + field, kind string + dst *string + }{{"bg_image_file", "image", &v.Theme.BgImage}, {"header_image_file", "image", &v.Theme.HeaderImage}, {"logo_file", "image", &v.Theme.Logo}, {"favicon_file", "image", &v.Theme.Favicon}, + {"custom_font_file", "font", &v.Theme.CustomFont}, {"custom_heading_font_file", "font", &v.Theme.CustomHeadingFont}} { + f, err := s.readUpload(r, u.field, u.kind) if err != nil { note(err) continue } - if img != nil { - *u.dst = img.ID.String() + if f != nil { + *u.dst = f.ID.String() } } if problem != nil { diff --git a/internal/web/handlers_files.go b/internal/web/handlers_files.go index 576f2ca..fd6ab69 100644 --- a/internal/web/handlers_files.go +++ b/internal/web/handlers_files.go @@ -20,9 +20,9 @@ import ( const filesPerPage = 50 // readUpload stores the file from a multipart field, returning nil if the -// field is empty. imagesOnly is for the design page, whose fields become theme -// image ids. -func (s *Server) readUpload(r *http.Request, field string, imagesOnly bool) (*store.File, error) { +// field is empty. kind restricts what is accepted ("" = anything): the pickers' +// fields become theme image or font ids. +func (s *Server) readUpload(r *http.Request, field, kind string) (*store.File, error) { if r.MultipartForm == nil { return nil, nil } @@ -30,10 +30,10 @@ func (s *Server) readUpload(r *http.Request, field string, imagesOnly bool) (*st if len(fhs) == 0 { return nil, nil } - return s.storeUpload(r, fhs[0], imagesOnly) + return s.storeUpload(r, fhs[0], kind) } -func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, imagesOnly bool) (*store.File, error) { +func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, kind string) (*store.File, error) { limit := currentBlog(r).UploadLimit(s.cfg) tooBig := errors.New(s.trf(r, "File is too large (max %s).", humanSize(limit))) if fh.Size > limit { @@ -55,11 +55,14 @@ func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader, imagesOn return nil, errors.New(s.tr(r, "File is empty.")) } name := cleanFilename(fh.Filename) - ct, kind := fileType(buf.Bytes(), name) - if imagesOnly && kind != "image" { + ct, got := fileType(buf.Bytes(), name) + switch { + case kind == "image" && got != kind: return nil, errors.New(s.tr(r, "Only PNG, JPEG, GIF, WebP and ICO images can be used here.")) + case kind == "font" && got != kind: + return nil, errors.New(s.tr(r, "Only WOFF2, WOFF, TTF and OTF fonts can be used here.")) } - return blogStore(r).CreateFile(r.Context(), name, ct, kind, buf.Bytes()) + return blogStore(r).CreateFile(r.Context(), name, ct, got, buf.Bytes()) } // fileMarkdown is the line the editor inserts: an image for images, a link @@ -204,7 +207,7 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) { if msg != "" { break } - f, err := s.storeUpload(r, fh, false) + f, err := s.storeUpload(r, fh, "") if err != nil { msg = err.Error() break @@ -284,7 +287,7 @@ func (s *Server) handleFileDelete(w http.ResponseWriter, r *http.Request) { // Drop dangling references from the theme. theme := ParseTheme(blog.ThemeJSON) changed := false - for _, ref := range []*string{&theme.BgImage, &theme.HeaderImage, &theme.Favicon, &theme.Logo} { + for _, ref := range []*string{&theme.BgImage, &theme.HeaderImage, &theme.Favicon, &theme.Logo, &theme.CustomFont, &theme.CustomHeadingFont} { if *ref == id.String() { *ref, changed = "", true } diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index 40456f2..8dc0f5f 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -66,8 +66,8 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { if autoSlug { p.Slug = slug.Make(p.Title) } - img, err := s.readUpload(r, "intro_file", false) - outroImg, err2 := s.readUpload(r, "outro_file", false) + img, err := s.readUpload(r, "intro_file", "") + outroImg, err2 := s.readUpload(r, "outro_file", "") if err == nil { err = err2 } diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go index ec56138..ac0dd90 100644 --- a/internal/web/handlers_posts.go +++ b/internal/web/handlers_posts.go @@ -114,7 +114,7 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { p.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML) p.Published = r.FormValue("published") == "on" p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64) - p.Image = imageID(pickImage(p.Image, r.FormValue("image"))) + p.Image = fileID(pickFile(p.Image, r.FormValue("image"))) tags, tagsOK := parseTags(strings.Split(r.FormValue("tags"), ",")) p.Tags = tags autoSlug := p.Slug == "" @@ -138,13 +138,13 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { return } } - img, err := s.readUpload(r, "body_file", false) + img, err := s.readUpload(r, "body_file", "") if err != nil { fail(http.StatusBadRequest, s.tr(r, "File not added:")+" "+err.Error()) return } p.BodyMD = appendFile(p.BodyMD, img, p.Format) - if f, err := s.readUpload(r, "image_file", true); err != nil { // an upload beats the picker + if f, err := s.readUpload(r, "image_file", "image"); err != nil { // an upload beats the picker fail(http.StatusBadRequest, s.tr(r, "Image not added:")+" "+err.Error()) return } else if f != nil { diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go index 04ba764..6e802d5 100644 --- a/internal/web/handlers_sections.go +++ b/internal/web/handlers_sections.go @@ -73,7 +73,7 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) { fail := func(msg string) { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/section_form.html", map[string]any{"section": sec, "error": msg}) } - img, err := s.readUpload(r, "body_file", false) + img, err := s.readUpload(r, "body_file", "") switch { case err != nil: fail(s.tr(r, "File not added:") + " " + err.Error()) diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index f5e0fd4..4aa0213 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -269,6 +269,11 @@ textarea.mono { font-size: 0.9em; } .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; } +/* the design page's font pickers: a select over its upload input, two to a row */ +.fontpick { flex: 1 1 16em; min-width: 12em; } +.fontpick select { max-width: 24em; margin: 0 0 0.5em; } +.fontpick .upload { margin: 0 0 0.5em; font-size: 0.85em; } +.fontpick .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; } diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html index 5fd3fbe..5c79dbb 100644 --- a/internal/web/templates/dashboard/design.html +++ b/internal/web/templates/dashboard/design.html @@ -84,6 +84,12 @@ +

{{t "Your own fonts"}}

+

{{t "A WOFF2, WOFF, TTF or OTF file from your library. It is used instead of the choice above, which readers see until it loads or if their browser cannot show it."}}

+
+ {{template "fontpick" (dict "name" "custom_font" "current" $t.CustomFont "fonts" .Data.fonts "names" .Data.imageNames "label" (t "Text font"))}} + {{template "fontpick" (dict "name" "custom_heading_font" "current" $t.CustomHeadingFont "fonts" .Data.fonts "names" .Data.imageNames "label" (t "Heading font") "hint" (t "None: headings use the text font, or the heading font above."))}} +