From 4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88 Mon Sep 17 00:00:00 2001 From: grm Date: Wed, 16 Sep 2026 19:10:13 +0300 Subject: 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 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/i18n/el.go | 163 ++++---- internal/store/design.go | 69 ++++ internal/store/menu.go | 42 -- internal/store/modules.go | 55 --- internal/web/design_form.go | 228 +++++++++++ internal/web/handlers_blog.go | 24 +- internal/web/handlers_design.go | 188 +++++++-- internal/web/handlers_files.go | 8 + internal/web/handlers_layout.go | 315 --------------- internal/web/routes.go | 15 +- internal/web/static/dashboard.css | 106 +++-- internal/web/templates/dashboard/design.html | 435 ++++++++++++++++++--- internal/web/templates/dashboard/layout.html | 113 ------ internal/web/templates/dashboard/module_form.html | 41 -- internal/web/templates/dashboard/overview.html | 2 +- internal/web/templates/dashboard/pages.html | 2 +- internal/web/templates/dashboard/section_form.html | 2 +- internal/web/templates/partials/dashnav.html | 1 - internal/web/templates/partials/imagepick.html | 21 +- internal/web/theme.go | 88 +++-- internal/web/web_test.go | 90 ++++- 21 files changed, 1156 insertions(+), 852 deletions(-) create mode 100644 internal/store/design.go create mode 100644 internal/web/design_form.go delete mode 100644 internal/web/handlers_layout.go delete mode 100644 internal/web/templates/dashboard/layout.html delete mode 100644 internal/web/templates/dashboard/module_form.html (limited to 'internal') 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..{id,area,kind,title,body,count,pos,del} and every menu +// entry menu..{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., 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..), 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. 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}}

{{t "Design"}}

-

{{t "Changes are live as soon as you save; refresh"}} {{t "your blog"}} ↗ {{t "to see them."}}

+

{{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"}} {{t "your blog"}} ↗ {{t "to see them."}}

-

{{t "Schemes"}}{{t "Background"}}{{t "Text"}}{{t "Header"}}{{t "Menu"}}{{t "Posts"}}{{t "Footer"}}{{t "Logo & icon"}}

-

{{t "What appears where — header, columns, footer and their modules — is arranged on the"}} {{t "Layout"}} {{t "tab."}}

-
-

{{t "Colour schemes"}}

-

{{t "A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept."}}

-
- - {{range .Data.presets}} {{end}} -
-
- -
- + + -
-

{{t "Background"}}

+

{{t "Page background"}}

- {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" .Blog.Subdomain "images" .Data.images "label" (t "Background image"))}} -
+ {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" $sub "images" .Data.images "names" .Data.imageNames "label" (t "Background image"))}} -
-

{{t "Text & content area"}}

+

{{t "Text & links"}}

- +
+ +

{{t "Fonts"}}

-
-
- - -
-

{{t "The content spans the whole page; how much of it the posts get is set by the column widths on the"}} {{t "Layout"}} {{t "tab."}}

-
-

{{t "Posts & dates"}}

+
+

{{t "Content"}}

+

{{t "The box your posts and pages are shown in. It spans whatever the side columns leave."}}

+
+ + + +
+

{{t "Posts"}}

+
+

{{t "Around the posts"}}

+

{{t "Your own HTML before and after the posts — an intro box, a newsletter form, a badge…"}}

+ {{template "arealist" (dict "area" "above" "root" .)}} + {{template "arealist" (dict "area" "below" "root" .)}} +
+ +
+

{{t "Side columns"}}

+
+
{{t "Header"}}
+
+
{{t "Left"}} {{$t.LeftWidth}}%
+
{{t "Main content"}} {{$t.MainWidth}}%
+
{{t "Right"}} {{$t.RightWidth}}%
+
+
{{t "Footer"}}
+
+
+ + +
+

{{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."}}

+ +

{{t "Left column"}}

+ + {{template "arealist" (dict "area" "left" "root" . "hint" (t "A narrow column beside your posts. Good for the menu, an archive or recent posts."))}} +

{{t "Right column"}}

+ + {{template "arealist" (dict "area" "right" "root" . "hint" (t "A narrow column on the other side of your posts. Same choices as the left one."))}}
- - {{t "View blog"}} ↗ + + {{t "Discard changes"}} + + {{t "View blog"}} ↗
+ +{{/* Row templates the script clones: one per area and kind, and the two menu entries. */}} +{{range .Data.templates}} +{{end}} +
{{t "Reset to defaults"}}

{{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)."}}

-
+
+
+ +{{/* The library panel: filled by the script from /files?kind=image as JSON, one page at a time. */}} + + + {{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}} +
+ {{if $single}}

{{t (index $r.Data.areaNames $area)}}

{{end}} + {{with .hint}}

{{.}}

{{end}} +
    + {{range $rows}}{{template "modrow" (dict "i" .I "m" .M "pos" .Pos "root" $r)}}{{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}}

+
+ +
+
{{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}} +
  • + + + {{t (index $r.Data.names $m.Kind)}} + {{if and (eq $m.Kind "logo") (not $r.Data.theme.Logo)}}{{t "no logo chosen"}} {{t "pick one"}}{{end}} + + {{if modsettings $m}}
    + {{t "Settings"}} {{modsummary $m}} + {{if or (ne $m.Kind "html") (and (ne $m.Area "above") (ne $m.Area "below"))}}{{if ne $m.Kind "text"}} + + {{end}}{{end}} + {{if eq $m.Kind "recent"}} +
    +

    {{t "The newest published posts from every page of your blog, newest first."}}

    + {{else if eq $m.Kind "archive"}} +

    {{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}

    + {{else if eq $m.Kind "sitemap"}} +

    {{t "Links to every page of your blog."}}

    + {{else if eq $m.Kind "tags"}} +
    +

    {{t "Your tags with the number of posts in each, most used first. Only published posts count."}}

    + {{else if eq $m.Kind "tagcloud"}} +

    {{t "Every tag in alphabetical order, bigger the more posts use it."}}

    + {{else if eq $m.Kind "search"}} +

    {{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."}}

    + {{else if eq $m.Kind "html"}} + +

    {{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."}}

    + {{else if eq $m.Kind "text"}} + +

    {{t "Plain text; each line becomes its own paragraph."}}

    + {{end}} +
    {{end}} +
  • {{end}} + +{{/* One menu entry: a page (fixed) or a link (text and address editable). */}} +{{define "menurow"}}{{$i := .i}}{{$it := .item}} +{{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"}} -
    -

    {{t "Layout"}}

    -

    {{t "Decide what goes where on your blog. Every change here is saved straight away; refresh"}} {{t "your blog"}} ↗ {{t "to see it."}} {{t "Colours and fonts are on the"}} {{t "Design"}} {{t "tab."}}

    -
    -

    {{t "Columns"}}{{t "Header"}}{{t "Left column"}}{{t "Main content"}}{{t "Right column"}}{{t "Footer"}}{{t "Menu"}}

    - -
    -

    {{t "Columns"}}

    -
    -
    {{t "Header"}}{{if not $t.HeaderOn}} ({{t "hidden"}}){{end}}
    -
    -
    {{t "Left"}} {{$t.LeftWidth}}%
    -
    {{t "Main content"}} {{$t.MainWidth}}%
    -
    {{t "Right"}} {{$t.RightWidth}}%
    -
    -
    {{t "Footer"}}{{if not $t.FooterOn}} ({{t "hidden"}}){{end}}
    -
    -
    - -
    - - -
    -

    {{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."}}

    - -

    -
    -
    - -{{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."))}} - -
    -

    {{t "Main content"}}

    -

    {{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…"}}

    - - {{range $area := .Data.areaOrder}}{{$mods := index $.Data.areas $area}} - - {{if $mods}}{{$m := index $mods 0}} - - - {{else}} - - - {{end}} - {{end}} -
    {{t (index $.Data.areaNames $area)}}{{t (index $.Data.names $m.Kind)}}{{with modsummary $m}} — {{.}}{{end}}{{t "edit"}} {{t "remove"}}{{t "Nothing extra."}}
    -
    - -{{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."))}} - - -{{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}} -
    -
    -

    {{t (index $r.Data.areaNames $area)}}{{if not $on}} {{t "hidden"}}{{end}}

    -
    -
    -

    {{.hint}}

    - {{if $mods}} - {{range $mods}} - - - - {{end}} -
    -
    -
    -
    {{t (index $r.Data.names .Kind)}}{{with modsummary .}} — {{.}}{{end}} - {{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}{{t "no logo chosen"}} {{t "pick one on the"}} {{t "Design"}} {{t "tab."}}{{end}}{{if modsettings .}}{{t "edit"}} {{end}}{{t "remove"}}
    {{else}}

    {{if ne $area "header"}}{{t "Nothing here yet — this area is not shown until you add a module."}}{{else}}{{t "Nothing here yet."}}{{end}}

    {{end}} -
    - - - -
    -
    {{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}} -
    -

    {{.Data.name}} {{tf "in the %s" (lower .Data.area)}}

    -
    - - {{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")))}} - - {{end}} - {{if eq $m.Kind "recent"}} -
    -

    {{t "The newest published posts from every page of your blog, newest first."}}

    - {{else if eq $m.Kind "archive"}} -

    {{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}

    - {{else if eq $m.Kind "sitemap"}} -

    {{t "Links to every page of your blog."}}

    - {{else if eq $m.Kind "tags"}} -
    -

    {{t "Your tags with the number of posts in each, most used first. Only published posts count."}}

    - {{else if eq $m.Kind "tagcloud"}} -

    {{t "Every tag in alphabetical order, bigger the more posts use it."}}

    - {{else if eq $m.Kind "search"}} -

    {{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."}}

    - {{else if eq $m.Kind "html"}} - - -

    {{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."}}

    - {{else if eq $m.Kind "text"}} - - -

    {{t "Plain text; each line becomes its own paragraph."}}

    - {{end}} -

    - - {{t "View blog"}} ↗ - {{t "Back to layout"}} -

    -
    -
    -{{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 @@

    {{t "Pages"}}

    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 @@

    {{t "Pages"}}

    -

    {{t "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the"}} {{t "Layout"}} {{t "tab."}}

    +

    {{t "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the"}} {{t "Design"}} {{t "tab."}}

    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 @@ -

    {{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} {{t "Layout"}}) {{t "the announcement moves to the main content instead."}}

    +

    {{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} {{t "Design"}}) {{t "the announcement moves to the main content instead."}}

    {{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)}} 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 @@ {{t "Posts"}}{{t "Pages"}}{{t "Announcements"}} - {{t "Layout"}}{{t "Design"}}{{t "Files"}}{{t "Settings"}} 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"}}
    +{{/* 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}}
    {{if .hint}}

    {{.hint}}

    {{end}} -
    - - {{range .images}}{{end}} +
    + {{if $cur}}{{else}}×{{end}} + + + + +
    -
    {{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. 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": {"hi\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 != "hi\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 { -- cgit v1.2.3