From 6eaa533c6cabbcc8beebf950f2bbfceb13682af8 Mon Sep 17 00:00:00 2001 From: streambinder Date: Tue, 29 Sep 2026 12:14:14 +0200 Subject: [PATCH] feat(dashboard): add appearance settings section Lets each site restyle the envelope, stamp, invitation page and homepage from the dashboard. --- internal/database/database.go | 9 +- internal/database/settings.go | 17 ++- internal/database/settings_test.go | 51 +++++++++ internal/handlers/dashboard.go | 32 +++++- internal/i18n/i18n.go | 62 ++++++++--- internal/models/models.go | 31 +++++- internal/models/models_test.go | 23 ++++ static/css/dashboard.css | 29 +++++ static/css/styles.css | 166 ++++++++++++++++++++++++----- static/dashboard-spa.js | 115 +++++++++++++++----- templates/appearance_test.go | 45 ++++++++ templates/ceremony.templ | 6 +- templates/dashboard.templ | 89 +++++++++++++++- templates/home.templ | 23 +++- templates/invitation.templ | 36 +++++-- templates/layout.templ | 24 ++++- 16 files changed, 662 insertions(+), 96 deletions(-) create mode 100644 templates/appearance_test.go diff --git a/internal/database/database.go b/internal/database/database.go index d15f53d..36ef53d 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/streambinder/foedus/internal/models" _ "modernc.org/sqlite" ) @@ -195,7 +196,13 @@ func migrate() { bank_account_iban TEXT NOT NULL DEFAULT '', bank_account_holder TEXT NOT NULL DEFAULT '', spotify_playlist TEXT NOT NULL DEFAULT '', - share_preview_media_id INTEGER REFERENCES media(id) + share_preview_media_id INTEGER REFERENCES media(id), + envelope_color TEXT NOT NULL DEFAULT '` + models.DefaultEnvelopeColor + `', + page_bg_color TEXT NOT NULL DEFAULT '` + models.DefaultPageBgColor + `', + home_bg_color TEXT NOT NULL DEFAULT '` + models.DefaultHomeBgColor + `', + stamp_media_id INTEGER REFERENCES media(id), + flora_bl_media_id INTEGER REFERENCES media(id), + flora_tr_media_id INTEGER REFERENCES media(id) ) STRICT`, `CREATE TABLE IF NOT EXISTS registry_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/database/settings.go b/internal/database/settings.go index 9c15a20..ffa269f 100644 --- a/internal/database/settings.go +++ b/internal/database/settings.go @@ -10,7 +10,8 @@ import ( const settingsColumns = `groom_name, bride_name, ceremony_datetime, ceremony_address, ceremony_location, ceremony_city, ceremony_lat, ceremony_lng, ceremony_media_id, reception_datetime, reception_address, reception_location, reception_city, reception_lat, reception_lng, reception_media_id, - bank_account_iban, bank_account_holder, spotify_playlist, share_preview_media_id` + bank_account_iban, bank_account_holder, spotify_playlist, share_preview_media_id, + envelope_color, page_bg_color, home_bg_color, stamp_media_id, flora_bl_media_id, flora_tr_media_id` // seedSettings materialises the single settings row. Every read assumes it // exists, so this runs on every boot rather than only on a fresh database. @@ -24,7 +25,7 @@ func seedSettings() { func GetSettings() (models.Settings, error) { var settings models.Settings - var ceremonyMediaID, receptionMediaID, sharePreviewMediaID sql.NullInt64 + var ceremonyMediaID, receptionMediaID, sharePreviewMediaID, stampMediaID, floraBlMediaID, floraTrMediaID sql.NullInt64 err := DB.QueryRow(`SELECT `+settingsColumns+` FROM settings WHERE id = 1`).Scan( &settings.GroomName, &settings.BrideName, &settings.CeremonyDatetime, &settings.CeremonyAddress, &settings.CeremonyLocation, @@ -33,6 +34,8 @@ func GetSettings() (models.Settings, error) { &settings.ReceptionCity, &settings.ReceptionLat, &settings.ReceptionLng, &receptionMediaID, &settings.BankAccountIBAN, &settings.BankAccountHolder, &settings.SpotifyPlaylist, &sharePreviewMediaID, + &settings.EnvelopeColor, &settings.PageBgColor, &settings.HomeBgColor, + &stampMediaID, &floraBlMediaID, &floraTrMediaID, ) if err != nil { return models.Settings{}, err @@ -40,6 +43,9 @@ func GetSettings() (models.Settings, error) { settings.CeremonyMediaID = idOrZero(ceremonyMediaID) settings.ReceptionMediaID = idOrZero(receptionMediaID) settings.SharePreviewMediaID = idOrZero(sharePreviewMediaID) + settings.StampMediaID = idOrZero(stampMediaID) + settings.FloraBlMediaID = idOrZero(floraBlMediaID) + settings.FloraTrMediaID = idOrZero(floraTrMediaID) return settings, nil } @@ -51,7 +57,9 @@ func UpdateSettings(q Querier, settings models.Settings) error { ceremony_lat = ?, ceremony_lng = ?, ceremony_media_id = ?, reception_datetime = ?, reception_address = ?, reception_location = ?, reception_city = ?, reception_lat = ?, reception_lng = ?, reception_media_id = ?, - bank_account_iban = ?, bank_account_holder = ?, spotify_playlist = ?, share_preview_media_id = ? + bank_account_iban = ?, bank_account_holder = ?, spotify_playlist = ?, share_preview_media_id = ?, + envelope_color = ?, page_bg_color = ?, home_bg_color = ?, + stamp_media_id = ?, flora_bl_media_id = ?, flora_tr_media_id = ? WHERE id = 1`, settings.GroomName, settings.BrideName, settings.CeremonyDatetime, settings.CeremonyAddress, settings.CeremonyLocation, settings.CeremonyCity, @@ -60,6 +68,9 @@ func UpdateSettings(q Querier, settings models.Settings) error { settings.ReceptionLat, settings.ReceptionLng, nullableID(settings.ReceptionMediaID), settings.BankAccountIBAN, settings.BankAccountHolder, settings.SpotifyPlaylist, nullableID(settings.SharePreviewMediaID), + settings.EnvelopeColor, settings.PageBgColor, settings.HomeBgColor, + nullableID(settings.StampMediaID), + nullableID(settings.FloraBlMediaID), nullableID(settings.FloraTrMediaID), ) return err } diff --git a/internal/database/settings_test.go b/internal/database/settings_test.go index ab1941b..f6486e8 100644 --- a/internal/database/settings_test.go +++ b/internal/database/settings_test.go @@ -227,3 +227,54 @@ func TestHomepageLabelsRoundTripPerLanguage(t *testing.T) { t.Errorf("got %v for an unconfigured language, want empty", missing) } } + +// Appearance columns ride the same typed settings row as everything else: +// defaults seeded on a fresh row, values round-trip, absent flora overrides +// read back as NULL/0. +func TestSettingsAppearanceRoundTrip(t *testing.T) { + newTestDB(t) + + got, err := GetSettings() + if err != nil { + t.Fatal(err) + } + if got.EnvelopeColor != models.DefaultEnvelopeColor || got.PageBgColor != models.DefaultPageBgColor || + got.HomeBgColor != models.DefaultHomeBgColor { + t.Errorf("seeded colours %q/%q/%q, want defaults %q/%q/%q", + got.EnvelopeColor, got.PageBgColor, got.HomeBgColor, + models.DefaultEnvelopeColor, models.DefaultPageBgColor, models.DefaultHomeBgColor) + } + if got.StampMediaID != 0 || got.FloraBlMediaID != 0 || got.FloraTrMediaID != 0 { + t.Errorf("seeded image ids %d/%d/%d, want 0/0/0", got.StampMediaID, got.FloraBlMediaID, got.FloraTrMediaID) + } + + floraID, err := InsertMedia(DB, "image/webp", []byte("flora")) + if err != nil { + t.Fatal(err) + } + stampID, err := InsertMedia(DB, "image/png", []byte("stamp")) + if err != nil { + t.Fatal(err) + } + want := models.Settings{ + GroomName: "Davide", BrideName: "Agnese", + EnvelopeColor: "#123abc", PageBgColor: "#f0e6d8", HomeBgColor: "#e8f0e0", + StampMediaID: stampID, FloraBlMediaID: floraID, + } + if err := UpdateSettings(DB, want); err != nil { + t.Fatal(err) + } + + got, err = GetSettings() + if err != nil { + t.Fatal(err) + } + if got.EnvelopeColor != "#123abc" || got.PageBgColor != "#f0e6d8" || got.HomeBgColor != "#e8f0e0" { + t.Errorf("colours round-tripped as %q/%q/%q, want #123abc/#f0e6d8/#e8f0e0", + got.EnvelopeColor, got.PageBgColor, got.HomeBgColor) + } + if got.StampMediaID != stampID || got.FloraBlMediaID != floraID || got.FloraTrMediaID != 0 { + t.Errorf("image ids round-tripped as %d/%d/%d, want %d/%d/0", + got.StampMediaID, got.FloraBlMediaID, got.FloraTrMediaID, stampID, floraID) + } +} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 4e4c066..e808396 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -364,6 +364,9 @@ func collectExistingMediaIDs(settings models.Settings, content templates.Setting add(settings.CeremonyMediaID) add(settings.ReceptionMediaID) add(settings.SharePreviewMediaID) + add(settings.StampMediaID) + add(settings.FloraBlMediaID) + add(settings.FloraTrMediaID) for _, place := range slices.Concat(content.Places, content.Honeymoon) { add(place.MediaID) } @@ -495,7 +498,31 @@ func SaveSettings(c *fiber.Ctx) error { SpotifyPlaylist: strings.TrimSpace(c.FormValue("spotify_playlist")), } - // the three single-image fields resolve inside the tx: each may insert new + // appearance colours: the native picker always posts a value, but an + // empty field keeps the saved colour and anything else must be a strict + // #rrggbb, lowercased for a canonical stored form. + for _, colour := range []struct { + field string + existing string + target *string + }{ + {"envelope_color", settings.EnvelopeColor, &updated.EnvelopeColor}, + {"page_bg_color", settings.PageBgColor, &updated.PageBgColor}, + {"home_bg_color", settings.HomeBgColor, &updated.HomeBgColor}, + } { + raw := strings.ToLower(strings.TrimSpace(c.FormValue(colour.field))) + switch { + case raw == "": + *colour.target = colour.existing + case !models.IsHexColor(raw): + logger.Warn("settings save rejected", "field", colour.field, "value", raw) + return fiber.NewError(400, "invalid colour") + default: + *colour.target = raw + } + } + + // the five single-image fields resolve inside the tx: each may insert new // bytes or drop the row it replaces. for _, image := range []struct { field string @@ -505,6 +532,9 @@ func SaveSettings(c *fiber.Ctx) error { {"ceremony", settings.CeremonyMediaID, &updated.CeremonyMediaID}, {"reception", settings.ReceptionMediaID, &updated.ReceptionMediaID}, {"share_preview", settings.SharePreviewMediaID, &updated.SharePreviewMediaID}, + {"stamp", settings.StampMediaID, &updated.StampMediaID}, + {"flora_bl", settings.FloraBlMediaID, &updated.FloraBlMediaID}, + {"flora_tr", settings.FloraTrMediaID, &updated.FloraTrMediaID}, } { mediaID, err := resolveImageMediaID(tx, c.FormValue(image.field+"_image"), c.FormValue(image.field+"_media_id"), image.existing, true) if err != nil { diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index ae7297b..dde6d11 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -39,15 +39,30 @@ var translations = map[string]map[string]string{ "title.setup": "Setup Required", // dashboard - sections - "dashboard.title": "Wedding Dashboard", - "dashboard.details": "Settings", - "dashboard.guests": "Guests", - "dashboard.track_additions": "Track Additions", + "dashboard.title": "Wedding Dashboard", + "dashboard.details": "Settings", + "dashboard.guests": "Guests", + "dashboard.track_additions": "Track Additions", + "dashboard.appearance": "Appearance", + "dashboard.appearance_description": "Colours of the invitation envelope and page, plus the decorative corner graphics.", // dashboard - labels - "label.spouses": "Couple's Names", - "label.ceremony": "Ceremony", - "label.reception": "Celebration", + "label.spouses": "Couple's Names", + "label.ceremony": "Ceremony", + "label.reception": "Celebration", + "label.envelope_color": "Envelope colour", + "label.page_bg_color": "Invitation page background", + "label.home_bg_color": "Homepage background", + "label.stamp": "Envelope stamp", + "label.flora_bl": "Corner decoration · bottom-left", + "label.flora_tr": "Corner decoration · top-right", + + // appearance section - descriptions + "appearance.envelope_color_description": "Base colour of the animated envelope on the invitation page. Shadows and highlights derive from it automatically.", + "appearance.page_bg_color_description": "Background of the invitation page, behind the text and venues.", + "appearance.home_bg_color_description": "Background of the homepage, behind the scrolling sections.", + "appearance.stamp_description": "Replaces the wax seal on the invitation envelope. Remove restores the default.", + "appearance.flora_description": "Replaces the built-in eucalyptus corner graphic on the homepage and ceremony pages. Remove restores the default.", // dashboard - counters "counter.rsvp": "RSVP", @@ -112,6 +127,8 @@ var translations = map[string]map[string]string{ "btn.update_poll": "Update Poll", "btn.edit": "Edit", "btn.delete": "Delete", + "btn.remove": "Remove", + "btn.reset_default": "Reset to default", "btn.reset_viewed": "Reset viewed", "btn.move_up": "Move Up", "btn.move_down": "Move Down", @@ -344,15 +361,30 @@ var translations = map[string]map[string]string{ "title.setup": "Configurazione necessaria", // dashboard - sections - "dashboard.title": "Pannello", - "dashboard.details": "Impostazioni", - "dashboard.guests": "Invitati", - "dashboard.track_additions": "Brani aggiunti", + "dashboard.title": "Pannello", + "dashboard.details": "Impostazioni", + "dashboard.guests": "Invitati", + "dashboard.track_additions": "Brani aggiunti", + "dashboard.appearance": "Aspetto", + "dashboard.appearance_description": "Colori della busta e della pagina dell'invito, più le grafiche decorative angolari.", // dashboard - labels - "label.spouses": "Nomi degli sposi", - "label.ceremony": "Rito", - "label.reception": "Festa", + "label.spouses": "Nomi degli sposi", + "label.ceremony": "Rito", + "label.reception": "Festa", + "label.envelope_color": "Colore della busta", + "label.page_bg_color": "Sfondo della pagina dell'invito", + "label.home_bg_color": "Sfondo della homepage", + "label.stamp": "Timbro della busta", + "label.flora_bl": "Decorazione angolare · in basso a sinistra", + "label.flora_tr": "Decorazione angolare · in alto a destra", + + // appearance section - descriptions + "appearance.envelope_color_description": "Colore di base della busta animata nella pagina dell'invito. Ombre e riflessi derivano automaticamente.", + "appearance.page_bg_color_description": "Sfondo della pagina dell'invito, dietro testi e luoghi.", + "appearance.home_bg_color_description": "Sfondo della homepage, dietro le sezioni a scorrimento.", + "appearance.stamp_description": "Sostituisce il sigillo di ceralacca sulla busta dell'invito. Rimuovi per tornare al default.", + "appearance.flora_description": "Sostituisce la grafica d'eucalipto predefinita negli angoli di homepage e pagina del rito. Rimuovi per tornare al default.", // dashboard - counters "counter.rsvp": "RSVP", @@ -417,6 +449,8 @@ var translations = map[string]map[string]string{ "btn.update_poll": "Aggiorna Sondaggio", "btn.edit": "Modifica", "btn.delete": "Elimina", + "btn.remove": "Rimuovi", + "btn.reset_default": "Ripristina default", "btn.reset_viewed": "Reset visione", "btn.move_up": "Sposta Su", "btn.move_down": "Sposta Giù", diff --git a/internal/models/models.go b/internal/models/models.go index c4eead6..5863ef2 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,6 +1,26 @@ package models -import "time" +import ( + "regexp" + "time" +) + +// Default appearance colours: the sage envelope, white invitation page and +// ivory homepage the site shipped with before the dashboard pickers existed. +const ( + DefaultEnvelopeColor = "#6f7665" + DefaultPageBgColor = "#ffffff" + DefaultHomeBgColor = "#fcf8f3" +) + +var hexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +// IsHexColor reports whether s is a strict #rrggbb colour. Both the dashboard +// handler and the invitation template gate on this, so a hand-edited settings +// row can never inject into a style attribute. +func IsHexColor(s string) bool { + return hexColorPattern.MatchString(s) +} // place kinds — same entity, two roles on the homepage: story places render on // the timeline, honeymoon ones in their own section. @@ -32,6 +52,15 @@ type Settings struct { BankAccountHolder string SpotifyPlaylist string SharePreviewMediaID int + // Appearance section: envelope and page colours, the wax stamp override + // and overrides for the two decorative flora corners (media IDs of 0 + // mean the built-in defaults). + EnvelopeColor string + PageBgColor string + HomeBgColor string + StampMediaID int + FloraBlMediaID int + FloraTrMediaID int } func (s Settings) IsConfigured() bool { diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 8d04d78..1a43bb2 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -34,3 +34,26 @@ func TestEventPassedAt(t *testing.T) { }) } } + +func TestIsHexColor(t *testing.T) { + for _, tc := range []struct { + in string + want bool + }{ + {"#6f7665", true}, + {"#FFFFFF", true}, + {"#123abc", true}, + {"", false}, + {"6f7665", false}, + {"#fff", false}, + {"#gggggg", false}, + {"#1234567", false}, + {"red", false}, + {"#6f7665;", false}, + {"\"#6f7665\"", false}, + } { + if got := IsHexColor(tc.in); got != tc.want { + t.Errorf("IsHexColor(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} diff --git a/static/css/dashboard.css b/static/css/dashboard.css index 9982a70..c19b03f 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -1283,3 +1283,32 @@ code { align-items: center; } } + +/* Appearance section: native colour pickers with a hex readout. */ +.appearance-color-field { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.25rem; +} + +.appearance-color-field label { + margin-bottom: 0; + min-width: 12rem; +} + +.appearance-color-field input[type="color"] { + width: 3rem; + height: 2.25rem; + padding: 0.15rem; + border: 1px solid var(--dashboard-border, #d8d4cc); + border-radius: 0.375rem; + background: none; + cursor: pointer; +} + +.appearance-color-field .color-hex { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; + color: var(--dashboard-muted, #6b675f); +} diff --git a/static/css/styles.css b/static/css/styles.css index d49fec2..18248f3 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -100,7 +100,7 @@ html { body { font-family: Inter, sans-serif; font-size: var(--fs-base); - background: var(--ivory); + background: var(--home-page-bg, var(--ivory)); color: var(--charcoal); line-height: 1.6; -webkit-font-smoothing: antialiased; @@ -167,6 +167,24 @@ main.container:not(:has(.vp-section)) { padding-bottom: calc(var(--sp-4) + env(safe-area-inset-bottom, 0)); } +/* dashboard appearance section: colour pickers with per-field reset */ +.appearance-color-field { + display: flex; + align-items: center; + gap: var(--sp-3); + flex-wrap: wrap; +} + +.appearance-color-field label { + margin-bottom: 0; +} + +.appearance-color-reset { + margin-bottom: 0; + padding: 0.35rem 0.75rem; + font-size: 0.85rem; +} + main.container:has(#invitation-content) { max-width: 100%; padding: 0; @@ -249,7 +267,7 @@ html:has(.vp-section) body { .vp-hero { text-align: center; - background: var(--ivory); + background: var(--home-page-bg, var(--ivory)); overflow: hidden; isolation: isolate; } @@ -311,12 +329,12 @@ html:has(.vp-section) body { } .vp-venues { - background: var(--white); + background: var(--home-page-bg, var(--ivory)); overflow: hidden; } .vp-registry { - background: var(--ivory); + background: var(--home-page-bg, var(--ivory)); overflow: hidden; } @@ -2385,7 +2403,7 @@ body.invitation-opening { min-height: 100dvh; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.68' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='0.22'/%3E%3C/svg%3E"), - var(--white); + var(--invitation-page-bg, var(--white)); border: 0; border-radius: 0; box-shadow: none; @@ -2467,6 +2485,24 @@ body.invitation-opening { z-index: 220; padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0); + + /* Per-wedding envelope palette. The template overrides only --envelope-base + inline from the dashboard colour picker; every derived shade recomputes + from it via color-mix, so the whole envelope shifts as one coherent + palette. At the default base the mixes resolve to the original hexes. */ + + --envelope-base: #6f7665; + --envelope-dark: color-mix(in srgb, var(--envelope-base) 81%, black); + --envelope-light: color-mix(in srgb, var(--envelope-base) 87%, white); + --envelope-lighter: color-mix(in srgb, var(--envelope-base) 74%, white); + --envelope-lightest: color-mix(in srgb, var(--envelope-base) 60%, white); + --envelope-sheen: color-mix(in srgb, var(--envelope-base) 52%, white); + --envelope-glow: color-mix(in srgb, var(--envelope-base) 45%, white); + --envelope-hi: color-mix(in srgb, var(--envelope-base) 38%, white); + --envelope-vignette: color-mix(in srgb, var(--envelope-base) 22%, black); + --envelope-edge: color-mix(in srgb, var(--envelope-base) 45%, black); + --envelope-shadow: color-mix(in srgb, var(--envelope-base) 66%, black); + background: radial-gradient(circle at 82% 18%, rgb(54 69 79 / 8%), transparent 18%), radial-gradient(circle at top, rgb(232 230 218 / 85%), transparent 38%), @@ -2565,7 +2601,11 @@ body.invitation-opening { inset: auto 8% 3%; height: 12%; border-radius: 50%; - background: radial-gradient(circle, rgb(54 69 79 / 22%), rgb(54 69 79 / 0%)); + background: radial-gradient( + circle, + color-mix(in srgb, var(--envelope-edge) 22%, transparent), + rgb(54 69 79 / 0%) + ); z-index: 0; filter: blur(10px); } @@ -2581,13 +2621,23 @@ body.invitation-opening { url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.68' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), radial-gradient( ellipse at 22% 18%, - rgb(180 188 165 / 32%), + color-mix(in srgb, var(--envelope-sheen) 32%, transparent), transparent 30% ), - radial-gradient(ellipse at 74% 80%, rgb(25 32 22 / 14%), transparent 28%), - linear-gradient(160deg, #a8af9a 0%, #959c87 35%, #828a76 65%, #6f7665 100%); + radial-gradient( + ellipse at 74% 80%, + color-mix(in srgb, var(--envelope-vignette) 14%, transparent), + transparent 28% + ), + linear-gradient( + 160deg, + var(--envelope-lightest) 0%, + var(--envelope-lighter) 35%, + var(--envelope-light) 65%, + var(--envelope-base) 100% + ); box-shadow: - inset 0 0 0 1px rgb(54 69 79 / 22%), + inset 0 0 0 1px color-mix(in srgb, var(--envelope-edge) 22%, transparent), 0 24px 50px rgb(40 50 40 / 10%); overflow: hidden; z-index: 0; @@ -2600,8 +2650,12 @@ body.invitation-opening { backface-visibility: hidden; will-change: transform, opacity; transform-style: preserve-3d; - filter: drop-shadow(0 0 0.7px rgb(70 84 64 / 82%)) - drop-shadow(0 0 0.7px rgb(70 84 64 / 82%)) + filter: drop-shadow( + 0 0 0.7px color-mix(in srgb, var(--envelope-shadow) 82%, transparent) + ) + drop-shadow( + 0 0 0.7px color-mix(in srgb, var(--envelope-shadow) 82%, transparent) + ) drop-shadow(0 0 2px rgb(252 248 243 / 40%)); } @@ -2623,10 +2677,15 @@ body.invitation-opening { url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.68' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), radial-gradient( ellipse at 50% 86%, - rgb(190 196 175 / 20%), + color-mix(in srgb, var(--envelope-glow) 20%, transparent), transparent 30% ), - linear-gradient(180deg, #5a6151 0%, #6f7665 45%, #959c87 100%); + linear-gradient( + 180deg, + var(--envelope-dark) 0%, + var(--envelope-base) 45%, + var(--envelope-lighter) 100% + ); transform-origin: 50% 0%; z-index: 5; clip-path: shape( @@ -2659,8 +2718,17 @@ body.invitation-opening { transform: translateY(-50%); background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.68' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), - linear-gradient(90deg, rgb(200 206 188 / 16%), transparent 22%), - linear-gradient(90deg, #828a76 0%, #6f7665 55%, #5a6151 100%); + linear-gradient( + 90deg, + color-mix(in srgb, var(--envelope-hi) 16%, transparent), + transparent 22% + ), + linear-gradient( + 90deg, + var(--envelope-light) 0%, + var(--envelope-base) 55%, + var(--envelope-dark) 100% + ); transform-origin: 100% 50%; z-index: 3; clip-path: shape( @@ -2693,10 +2761,15 @@ body.invitation-opening { url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.68' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), radial-gradient( ellipse at 50% 14%, - rgb(190 196 175 / 18%), + color-mix(in srgb, var(--envelope-glow) 18%, transparent), transparent 28% ), - linear-gradient(180deg, #828a76 0%, #6f7665 55%, #5a6151 100%); + linear-gradient( + 180deg, + var(--envelope-light) 0%, + var(--envelope-base) 55%, + var(--envelope-dark) 100% + ); transform-origin: 50% 100%; z-index: 4; clip-path: shape( @@ -2728,8 +2801,17 @@ body.invitation-opening { transform: translateY(-50%); background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.68' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), - linear-gradient(90deg, transparent 78%, rgb(200 206 188 / 14%)), - linear-gradient(90deg, #5a6151 0%, #6f7665 45%, #828a76 100%); + linear-gradient( + 90deg, + transparent 78%, + color-mix(in srgb, var(--envelope-hi) 14%, transparent) + ), + linear-gradient( + 90deg, + var(--envelope-dark) 0%, + var(--envelope-base) 45%, + var(--envelope-light) 100% + ); transform-origin: 0 50%; z-index: 3; clip-path: shape( @@ -2858,8 +2940,17 @@ body.invitation-opening { transform: translateX(-50%); background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.68' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), - linear-gradient(0deg, rgb(200 206 188 / 16%), transparent 22%), - linear-gradient(180deg, #5a6151 0%, #6f7665 55%, #828a76 100%); + linear-gradient( + 0deg, + color-mix(in srgb, var(--envelope-hi) 16%, transparent), + transparent 22% + ), + linear-gradient( + 180deg, + var(--envelope-dark) 0%, + var(--envelope-base) 55%, + var(--envelope-light) 100% + ); transform-origin: 50% 0%; z-index: 3; clip-path: shape( @@ -2891,10 +2982,15 @@ body.invitation-opening { url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.68' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), radial-gradient( ellipse at 86% 50%, - rgb(190 196 175 / 18%), + color-mix(in srgb, var(--envelope-glow) 18%, transparent), transparent 28% ), - linear-gradient(90deg, #828a76 0%, #6f7665 55%, #5a6151 100%); + linear-gradient( + 90deg, + var(--envelope-light) 0%, + var(--envelope-base) 55%, + var(--envelope-dark) 100% + ); transform-origin: 100% 50%; z-index: 4; clip-path: shape( @@ -2926,8 +3022,17 @@ body.invitation-opening { transform: translateX(-50%); background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.68' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), - linear-gradient(180deg, rgb(200 206 188 / 14%), transparent 22%), - linear-gradient(180deg, #828a76 0%, #6f7665 45%, #5a6151 100%); + linear-gradient( + 180deg, + color-mix(in srgb, var(--envelope-hi) 14%, transparent), + transparent 22% + ), + linear-gradient( + 180deg, + var(--envelope-light) 0%, + var(--envelope-base) 45%, + var(--envelope-dark) 100% + ); transform-origin: 50% 100%; z-index: 3; clip-path: shape( @@ -2958,10 +3063,15 @@ body.invitation-opening { url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.68' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='256' height='256' filter='url(%23n)' opacity='0.25'/%3E%3C/svg%3E"), radial-gradient( ellipse at 14% 50%, - rgb(190 196 175 / 20%), + color-mix(in srgb, var(--envelope-glow) 20%, transparent), transparent 30% ), - linear-gradient(90deg, #5a6151 0%, #6f7665 45%, #959c87 100%); + linear-gradient( + 90deg, + var(--envelope-dark) 0%, + var(--envelope-base) 45%, + var(--envelope-lighter) 100% + ); transform-origin: 0 50%; z-index: 5; clip-path: shape( diff --git a/static/dashboard-spa.js b/static/dashboard-spa.js index 7d500c6..8753b92 100644 --- a/static/dashboard-spa.js +++ b/static/dashboard-spa.js @@ -568,7 +568,7 @@ null, null, null, - false, + null, null, null, false, @@ -579,7 +579,7 @@ "ceremony-preview", "image/webp", 0.9, - false, + null, "ceremony-image-token", 1600, true, @@ -590,7 +590,7 @@ "reception-preview", "image/webp", 0.9, - false, + null, "reception-image-token", 1600, true, @@ -601,12 +601,73 @@ "share-preview-preview", null, null, - true, + "share-preview-remove", "share-preview-image-token", null, true, ); + bindImageResize( + "stamp-file", + "stamp-image-data", + "stamp-preview", + null, + null, + "stamp-remove", + null, + null, + true, + ); + bindImageResize( + "flora-bl-file", + "flora-bl-image-data", + "flora-bl-preview", + null, + null, + "flora-bl-remove", + null, + null, + true, + ); + bindImageResize( + "flora-tr-file", + "flora-tr-image-data", + "flora-tr-preview", + null, + null, + "flora-tr-remove", + null, + null, + true, + ); bindManagedImageResizers(); + initAppearanceColorFields(); + } + + // Native colour pickers in the Appearance section: keep the hex readout next + // to each picker in sync while the user drags, and let the reset button + // restore the default colour for that field. + function initAppearanceColorFields() { + document + .querySelectorAll('input[type="color"][data-hex-target]') + .forEach((input) => { + if (input.dataset.colorBound === "true") return; + input.dataset.colorBound = "true"; + input.addEventListener("input", () => { + const target = document.getElementById(input.dataset.hexTarget); + if (target) target.textContent = input.value; + }); + }); + document.querySelectorAll("button[data-color-reset]").forEach((button) => { + if (button.dataset.resetBound === "true") return; + button.dataset.resetBound = "true"; + button.addEventListener("click", () => { + const input = document.getElementById(button.dataset.colorReset); + if (!input || !button.dataset.defaultColor) return; + input.value = button.dataset.defaultColor; + const target = document.getElementById(input.dataset.hexTarget); + if (target) target.textContent = input.value; + }); + }); } function bindImageResize( @@ -615,7 +676,7 @@ previewId, format, quality, - withRemove, + removeBtnId, tokenId, maxDim, passthrough, @@ -624,10 +685,27 @@ const dataInput = document.getElementById(dataId); const tokenInput = tokenId ? document.getElementById(tokenId) : null; const previewImg = previewId ? document.getElementById(previewId) : null; + const removeBtn = removeBtnId ? document.getElementById(removeBtnId) : null; if (!fileInput || !dataInput || fileInput.dataset.resizeBound === "true") return; fileInput.dataset.resizeBound = "true"; + const clearForRemove = () => { + dataInput.value = ""; + // dropping the media id too is what actually restores the default: the + // server deletes the stored media row when both fields arrive empty. + const mediaIdInput = document.getElementById( + fileId.replace(/-file$/, "-media-id"), + ); + if (mediaIdInput) mediaIdInput.value = ""; + if (tokenInput) tokenInput.value = ""; + if (previewImg) { + clearPreviewImage(previewImg); + } + if (removeBtn) removeBtn.style.display = "none"; + fileInput.value = ""; + }; + fileInput.addEventListener("change", () => { const file = fileInput.files?.[0]; if (!file) return; @@ -647,10 +725,7 @@ if (previewImg) { setPreviewImage(previewImg, reader.result); } - if (withRemove) { - const removeBtn = document.getElementById("share-preview-remove"); - if (removeBtn) removeBtn.style.display = ""; - } + if (removeBtn) removeBtn.style.display = ""; }; reader.readAsDataURL(file); return; @@ -675,28 +750,14 @@ if (previewImg) { setPreviewImage(previewImg, dataInput.value); } - if (withRemove) { - const removeBtn = document.getElementById("share-preview-remove"); - if (removeBtn) removeBtn.style.display = ""; - } + if (removeBtn) removeBtn.style.display = ""; }; img.src = URL.createObjectURL(file); }); - if (withRemove) { - const removeBtn = document.getElementById("share-preview-remove"); - if (removeBtn && removeBtn.dataset.bound !== "true") { - removeBtn.dataset.bound = "true"; - removeBtn.addEventListener("click", () => { - dataInput.value = ""; - if (tokenInput) tokenInput.value = ""; - if (previewImg) { - clearPreviewImage(previewImg); - } - removeBtn.style.display = "none"; - fileInput.value = ""; - }); - } + if (removeBtn && removeBtn.dataset.bound !== "true") { + removeBtn.dataset.bound = "true"; + removeBtn.addEventListener("click", clearForRemove); } } diff --git a/templates/appearance_test.go b/templates/appearance_test.go new file mode 100644 index 0000000..f42b0b9 --- /dev/null +++ b/templates/appearance_test.go @@ -0,0 +1,45 @@ +package templates + +import ( + "strings" + "testing" + + "github.com/streambinder/foedus/internal/models" +) + +// appearanceVars lands verbatim in a style attribute on the public invitation +// page, so this pins both the pass-through and the invalid-input fallback. +func TestAppearanceVars(t *testing.T) { + got := appearanceVars(models.Settings{EnvelopeColor: "#123abc", PageBgColor: "#f0e6d8"}) + if want := "--envelope-base:#123abc;--invitation-page-bg:#f0e6d8;"; got != want { + t.Errorf("appearanceVars() = %q, want %q", got, want) + } + + // a hand-edited row can never inject into the style attribute + got = appearanceVars(models.Settings{EnvelopeColor: `red";background:url(evil)`, PageBgColor: ""}) + if want := "--envelope-base:" + models.DefaultEnvelopeColor + ";--invitation-page-bg:" + models.DefaultPageBgColor + ";"; got != want { + t.Errorf("appearanceVars() with invalid input = %q, want %q", got, want) + } + if strings.ContainsAny(got, `"()`) { + t.Errorf("appearanceVars() output %q contains injectable characters", got) + } +} + +// homePageStyle lands verbatim in the style attribute of on the +// homepage, so this pins both the pass-through and the invalid-input +// fallback. +func TestHomePageStyle(t *testing.T) { + got := homePageStyle(models.Settings{HomeBgColor: "#e8f0e0"}) + if want := "--home-page-bg:#e8f0e0;"; got != want { + t.Errorf("homePageStyle() = %q, want %q", got, want) + } + + // a hand-edited row can never inject into the style attribute + got = homePageStyle(models.Settings{HomeBgColor: `red";background:url(evil)`}) + if want := "--home-page-bg:" + models.DefaultHomeBgColor + ";"; got != want { + t.Errorf("homePageStyle() with invalid input = %q, want %q", got, want) + } + if strings.ContainsAny(got, `"()`) { + t.Errorf("homePageStyle() output %q contains injectable characters", got) + } +} diff --git a/templates/ceremony.templ b/templates/ceremony.templ index 6011504..417c16c 100644 --- a/templates/ceremony.templ +++ b/templates/ceremony.templ @@ -6,10 +6,10 @@ import ( ) templ Ceremony(settings models.Settings, t i18n.T, lang string, ogMeta OGMeta) { - @Layout(pageTitle(t("ceremony.title"), settings.GroomName, settings.BrideName), lang, t, ogMeta, false, nil) { + @Layout(pageTitle(t("ceremony.title"), settings.GroomName, settings.BrideName), lang, t, ogMeta, false, nil, "") {
- - + @Flora(settings, "bl") + @Flora(settings, "tr")

{ t("home.getting_married") }

diff --git a/templates/dashboard.templ b/templates/dashboard.templ index e8c7229..1398340 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -171,6 +171,16 @@ func mediaIDValue(id int) string { return strconv.Itoa(id) } +// appearanceColorOrDefault keeps the native colour inputs on a valid value: +// a hand-edited settings row falls back to the default instead of rendering +// the input black. +func appearanceColorOrDefault(value, fallback string) string { + if models.IsHexColor(value) { + return value + } + return fallback +} + // donut chart inputs: each slice is (count, css-class, legend-label) type DonutSlice struct { Value int @@ -347,10 +357,10 @@ templ Dashboard(settings models.Settings, content SettingsContent, guests []mode if settings.SharePreviewMediaID > 0 { share preview - + } else { - + }
+
+

+ +

+
+
+
+ + + { appearanceColorOrDefault(settings.EnvelopeColor, models.DefaultEnvelopeColor) } + +
+

{ t("appearance.envelope_color_description") }

+ +

{ t("appearance.stamp_description") }

+
+ + + + if settings.StampMediaID > 0 { + envelope stamp + + } else { + + + } +
+
+ + + { appearanceColorOrDefault(settings.PageBgColor, models.DefaultPageBgColor) } + +
+

{ t("appearance.page_bg_color_description") }

+
+ + + { appearanceColorOrDefault(settings.HomeBgColor, models.DefaultHomeBgColor) } + +
+

{ t("appearance.home_bg_color_description") }

+ +

{ t("appearance.flora_description") }

+
+ + + + if settings.FloraBlMediaID > 0 { + flora bottom-left + + } else { + + + } +
+ +

{ t("appearance.flora_description") }

+
+ + + + if settings.FloraTrMediaID > 0 { + flora top-right + + } else { + + + } +
+
+
+

- - + @Flora(settings, "bl") + @Flora(settings, "tr") @@ -515,8 +528,8 @@ templ Home(settings models.Settings, content HomeContent, registryItems []models if bankConfigured {
- - + @Flora(settings, "bl") + @Flora(settings, "tr") diff --git a/templates/invitation.templ b/templates/invitation.templ index 78a9154..e3aa028 100644 --- a/templates/invitation.templ +++ b/templates/invitation.templ @@ -37,16 +37,32 @@ func InvitationAnswered(inv models.Invitation) bool { return true } -func invitationImagePreloads(alreadyViewed bool) []ImagePreload { - if alreadyViewed { +func invitationImagePreloads(settings models.Settings, alreadyViewed bool) []ImagePreload { + if alreadyViewed || settings.StampMediaID > 0 { return nil } return []ImagePreload{{Href: "/static/wax-seal.webp"}} } +// appearanceVars renders the per-wedding appearance settings as inline CSS +// custom properties. Colours are re-validated here (defence in depth: the +// dashboard already rejects anything that isn't #rrggbb), so a hand-edited +// settings row can never break out of the style attribute. +func appearanceVars(settings models.Settings) string { + envelope := models.DefaultEnvelopeColor + if models.IsHexColor(settings.EnvelopeColor) { + envelope = settings.EnvelopeColor + } + pageBg := models.DefaultPageBgColor + if models.IsHexColor(settings.PageBgColor) { + pageBg = settings.PageBgColor + } + return "--envelope-base:" + envelope + ";--invitation-page-bg:" + pageBg + ";" +} + templ Invitation(inv models.Invitation, settings models.Settings, polls []models.Poll, alreadyViewed bool, noRedirect bool, t i18n.T, lang string, ogMeta OGMeta, title string) { - @Layout(title, lang, t, ogMeta, false, invitationImagePreloads(alreadyViewed)) { -
+ @Layout(title, lang, t, ogMeta, false, invitationImagePreloads(settings, alreadyViewed), "") { +
@@ -295,10 +311,14 @@ templ Invitation(inv models.Invitation, settings models.Settings, polls []models
diff --git a/templates/layout.templ b/templates/layout.templ index ca705f4..b2b1e07 100644 --- a/templates/layout.templ +++ b/templates/layout.templ @@ -6,6 +6,7 @@ import ( "strings" "github.com/streambinder/foedus/internal/i18n" + "github.com/streambinder/foedus/internal/models" ) type OGMeta struct { @@ -57,7 +58,7 @@ func pageTitle(title, groom, bride string) string { } templ SetupGuard(lang string, t i18n.T) { - @Layout(t("title.setup"), lang, t, OGMeta{}, false, nil) { + @Layout(t("title.setup"), lang, t, OGMeta{}, false, nil, "") {

{ t("setup.not_ready") }

{ t("setup.go_to_dashboard") }

@@ -65,9 +66,9 @@ templ SetupGuard(lang string, t i18n.T) { } } -templ Layout(title string, lang string, t i18n.T, ogMeta OGMeta, includeLeaflet bool, imagePreloads []ImagePreload) { +templ Layout(title string, lang string, t i18n.T, ogMeta OGMeta, includeLeaflet bool, imagePreloads []ImagePreload, htmlStyle string) { - + @@ -177,3 +178,20 @@ templ DashboardLayout(title string, lang string) { } + +// Flora renders one of the two decorative corner graphics ("bl" or "tr"). +// Without an override it keeps the built-in eucalyptus background from CSS; +// with one it swaps in the uploaded image, keeping the class geometry. +templ Flora(settings models.Settings, corner string) { + {{ mediaID := 0 }} + if corner == "bl" { + {{ mediaID = settings.FloraBlMediaID }} + } else { + {{ mediaID = settings.FloraTrMediaID }} + } + if mediaID > 0 { + + } else { + + } +}