diff --git a/frontend/src/pages/settingsPage.tsx b/frontend/src/pages/settingsPage.tsx index 451ba8e..ac68b36 100644 --- a/frontend/src/pages/settingsPage.tsx +++ b/frontend/src/pages/settingsPage.tsx @@ -1,4 +1,4 @@ -import { LogOut, RefreshCw } from "lucide-react" +import { ArrowDown, ArrowUp, LogOut, Plus, RefreshCw, Trash2 } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" import FooterMenuEditor from "../components/FooterMenuEditor" import { useApiFetch } from "../hooks/useApiFetch" @@ -14,6 +14,26 @@ type IndexStatus = { error?: string } +type CarouselSlide = { + enabled: boolean + title: string + link_url: string + image_url: string + background_color: string + text_color: string + desktop_only: boolean +} + +const emptyCarouselSlide = (): CarouselSlide => ({ + enabled: true, + title: "", + link_url: "", + image_url: "", + background_color: "", + text_color: "#ffffff", + desktop_only: false, +}) + function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -44,6 +64,10 @@ export default function SettingsPage() { const [breakingSaved, setBreakingSaved] = useState<{ enabled: boolean; text: string }>({ enabled: false, text: "" }) const [breakingSaving, setBreakingSaving] = useState(false) const [breakingMessage, setBreakingMessage] = useState(null) + const [carouselSlides, setCarouselSlides] = useState([]) + const [carouselSaved, setCarouselSaved] = useState([]) + const [carouselSaving, setCarouselSaving] = useState(false) + const [carouselMessage, setCarouselMessage] = useState(null) const [mediaIndexRunning, setMediaIndexRunning] = useState(false) const [mediaIndexMessage, setMediaIndexMessage] = useState(null) // Guards against two poll loops (mount-resume plus a click) racing each other. @@ -99,6 +123,30 @@ export default function SettingsPage() { } }, [apiFetch]) + useEffect(() => { + let cancelled = false + async function loadHomepageCarousel() { + try { + const res = await apiFetch("/v1/settings/homepage-carousel") + if (!res.ok) throw new Error(`Failed to load homepage carousel settings (${res.status})`) + const body = (await res.json()) as { slides?: CarouselSlide[] } + const slides = Array.isArray(body.slides) ? body.slides : [] + if (!cancelled) { + setCarouselSlides(slides) + setCarouselSaved(slides) + } + } catch (err) { + if (!cancelled) { + setCarouselMessage(err instanceof Error ? err.message : "Failed to load homepage carousel settings") + } + } + } + loadHomepageCarousel() + return () => { + cancelled = true + } + }, [apiFetch]) + async function saveBreakingNews() { const text = breakingText.trim() if (breakingEnabled && !text) { @@ -155,6 +203,61 @@ export default function SettingsPage() { } } + function updateCarouselSlide(index: number, patch: Partial) { + setCarouselSlides((current) => current.map((slide, idx) => (idx === index ? { ...slide, ...patch } : slide))) + } + + function moveCarouselSlide(index: number, direction: -1 | 1) { + setCarouselSlides((current) => { + const nextIndex = index + direction + if (nextIndex < 0 || nextIndex >= current.length) return current + const next = [...current] + const [slide] = next.splice(index, 1) + next.splice(nextIndex, 0, slide) + return next + }) + } + + function removeCarouselSlide(index: number) { + setCarouselSlides((current) => current.filter((_, idx) => idx !== index)) + } + + async function saveHomepageCarousel() { + const slides = carouselSlides.map((slide) => ({ + ...slide, + title: slide.title.trim(), + link_url: slide.link_url.trim(), + image_url: slide.image_url.trim(), + background_color: slide.background_color.trim(), + text_color: slide.text_color.trim() || "#ffffff", + })) + const invalidIndex = slides.findIndex((slide) => slide.enabled && (!slide.link_url || (!slide.title && !slide.image_url))) + if (invalidIndex >= 0) { + setCarouselMessage(`Slide ${String(invalidIndex + 1)} needs a link and either a title or image`) + return + } + + setCarouselSaving(true) + setCarouselMessage(null) + try { + const res = await apiFetch("/v1/settings/homepage-carousel", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slides }), + }) + if (!res.ok) throw new Error(`Failed to save homepage carousel settings (${res.status})`) + const body = (await res.json()) as { slides?: CarouselSlide[] } + const savedSlides = Array.isArray(body.slides) ? body.slides : [] + setCarouselSlides(savedSlides) + setCarouselSaved(savedSlides) + setCarouselMessage("Saved") + } catch (err) { + setCarouselMessage(err instanceof Error ? err.message : "Failed to save homepage carousel settings") + } finally { + setCarouselSaving(false) + } + } + async function rebuildTaxonomyCounts() { setTaxonomyRebuildRunning(true) setTaxonomyRebuildMessage(null) @@ -243,6 +346,8 @@ export default function SettingsPage() { } } + const carouselDirty = JSON.stringify(carouselSlides) !== JSON.stringify(carouselSaved) + return (
@@ -317,6 +422,150 @@ export default function SettingsPage() {
+
+
+
+

Homepage Carousel

+

+ Slides shown in Scalene's Splide carousel on the public homepage. +

+
+ +
+ +
+ {carouselSlides.length === 0 ? ( +
+ No carousel slides configured. +
+ ) : ( + carouselSlides.map((slide, index) => ( +
+
+ +
+ + + +
+
+ +
+
+ + updateCarouselSlide(index, { title: e.target.value })} + maxLength={160} + className="w-full px-3 py-2 rounded-lg border border-border bg-card text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" + placeholder="Slide title" + /> +
+
+ + updateCarouselSlide(index, { link_url: e.target.value })} + className="w-full px-3 py-2 rounded-lg border border-border bg-card text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" + placeholder="/classifieds or https://..." + /> +
+
+ + updateCarouselSlide(index, { image_url: e.target.value })} + className="w-full px-3 py-2 rounded-lg border border-border bg-card text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" + placeholder="/images/banner.webp" + /> +
+
+
+ + updateCarouselSlide(index, { background_color: e.target.value })} + className="w-full px-3 py-2 rounded-lg border border-border bg-card text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" + placeholder="#275997" + /> +
+
+ + updateCarouselSlide(index, { text_color: e.target.value })} + className="w-full px-3 py-2 rounded-lg border border-border bg-card text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" + placeholder="#ffffff" + /> +
+
+
+ + +
+ )) + )} +
+ +
+ + {carouselMessage && {carouselMessage}} +
+
+

Breaking News Banner

diff --git a/server/docs/docs.go b/server/docs/docs.go index 2f2822a..fd6aed6 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -3364,6 +3364,79 @@ const docTemplate = `{ } } }, + "/v1/settings/homepage-carousel": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Get homepage carousel settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.HomepageCarouselSettingsResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Update homepage carousel settings", + "parameters": [ + { + "description": "Homepage carousel settings", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.HomepageCarouselSettingsPatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.HomepageCarouselSettingsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/settings/seo": { "get": { "produces": [ @@ -4974,6 +5047,54 @@ const docTemplate = `{ } } }, + "models.HomepageCarouselSettingsPatchRequest": { + "type": "object", + "properties": { + "slides": { + "type": "array", + "items": { + "$ref": "#/definitions/models.HomepageCarouselSlide" + } + } + } + }, + "models.HomepageCarouselSettingsResponse": { + "type": "object", + "properties": { + "slides": { + "type": "array", + "items": { + "$ref": "#/definitions/models.HomepageCarouselSlide" + } + } + } + }, + "models.HomepageCarouselSlide": { + "type": "object", + "properties": { + "background_color": { + "type": "string" + }, + "desktop_only": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "image_url": { + "type": "string" + }, + "link_url": { + "type": "string" + }, + "text_color": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "models.HomepageDevelopingStory": { "type": "object", "properties": { @@ -5026,6 +5147,12 @@ const docTemplate = `{ "$ref": "#/definitions/models.ArticleListItem" } }, + "carousel": { + "type": "array", + "items": { + "$ref": "#/definitions/models.HomepageCarouselSlide" + } + }, "columns": { "type": "array", "items": { diff --git a/server/docs/swagger.json b/server/docs/swagger.json index 0435ced..b6df346 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -3361,6 +3361,79 @@ } } }, + "/v1/settings/homepage-carousel": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Get homepage carousel settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.HomepageCarouselSettingsResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Update homepage carousel settings", + "parameters": [ + { + "description": "Homepage carousel settings", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.HomepageCarouselSettingsPatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.HomepageCarouselSettingsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/v1/settings/seo": { "get": { "produces": [ @@ -4971,6 +5044,54 @@ } } }, + "models.HomepageCarouselSettingsPatchRequest": { + "type": "object", + "properties": { + "slides": { + "type": "array", + "items": { + "$ref": "#/definitions/models.HomepageCarouselSlide" + } + } + } + }, + "models.HomepageCarouselSettingsResponse": { + "type": "object", + "properties": { + "slides": { + "type": "array", + "items": { + "$ref": "#/definitions/models.HomepageCarouselSlide" + } + } + } + }, + "models.HomepageCarouselSlide": { + "type": "object", + "properties": { + "background_color": { + "type": "string" + }, + "desktop_only": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "image_url": { + "type": "string" + }, + "link_url": { + "type": "string" + }, + "text_color": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "models.HomepageDevelopingStory": { "type": "object", "properties": { @@ -5023,6 +5144,12 @@ "$ref": "#/definitions/models.ArticleListItem" } }, + "carousel": { + "type": "array", + "items": { + "$ref": "#/definitions/models.HomepageCarouselSlide" + } + }, "columns": { "type": "array", "items": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index 25a91d8..683ed35 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -579,6 +579,37 @@ definitions: status: type: string type: object + models.HomepageCarouselSettingsPatchRequest: + properties: + slides: + items: + $ref: '#/definitions/models.HomepageCarouselSlide' + type: array + type: object + models.HomepageCarouselSettingsResponse: + properties: + slides: + items: + $ref: '#/definitions/models.HomepageCarouselSlide' + type: array + type: object + models.HomepageCarouselSlide: + properties: + background_color: + type: string + desktop_only: + type: boolean + enabled: + type: boolean + image_url: + type: string + link_url: + type: string + text_color: + type: string + title: + type: string + type: object models.HomepageDevelopingStory: properties: excerpt: @@ -613,6 +644,10 @@ definitions: items: $ref: '#/definitions/models.ArticleListItem' type: array + carousel: + items: + $ref: '#/definitions/models.HomepageCarouselSlide' + type: array columns: items: $ref: '#/definitions/models.ArticleListItem' @@ -3212,6 +3247,52 @@ paths: summary: Update the public-site footer menu tags: - settings + /v1/settings/homepage-carousel: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.HomepageCarouselSettingsResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + summary: Get homepage carousel settings + tags: + - settings + patch: + consumes: + - application/json + parameters: + - description: Homepage carousel settings + in: body + name: body + required: true + schema: + $ref: '#/definitions/models.HomepageCarouselSettingsPatchRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.HomepageCarouselSettingsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: Update homepage carousel settings + tags: + - settings /v1/settings/seo: get: produces: diff --git a/server/internal/database/homepage_carousel_settings.go b/server/internal/database/homepage_carousel_settings.go new file mode 100644 index 0000000..e3e3450 --- /dev/null +++ b/server/internal/database/homepage_carousel_settings.go @@ -0,0 +1,126 @@ +package database + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + + "server/internal/models" +) + +const homepageCarouselSettingKey = "homepage_carousel" + +var defaultHomepageCarouselSlides = []models.HomepageCarouselSlide{ + { + Enabled: true, + Title: "100 Years of The Triangle", + LinkURL: "/one-hundred", + TextColor: "#ffffff", + DesktopOnly: true, + }, + { + Enabled: true, + Title: "Podcast", + LinkURL: "https://tr.ee/bqz8s-E4NK", + ImageURL: "/images/PodcastBanner.webp", + BackgroundColor: "#acd4f4", + TextColor: "#ffffff", + }, + { + Enabled: true, + Title: "Classifieds", + LinkURL: "/classifieds", + ImageURL: "/images/classifiedsBannerNew.webp", + BackgroundColor: "#E5E7EB", + TextColor: "#ffffff", + }, + { + Enabled: true, + Title: "Apply to The Triangle", + LinkURL: "https://docs.google.com/forms/d/e/1FAIpQLScra_6sUenvmpIuQ5FjmMyWO0a2sz9z36HkrqfnYQvJGH9BGQ/viewform", + ImageURL: "/images/applyBannerNew.webp", + BackgroundColor: "#275997", + TextColor: "#ffffff", + }, +} + +func DefaultHomepageCarouselSlides() []models.HomepageCarouselSlide { + return cloneHomepageCarouselSlides(defaultHomepageCarouselSlides) +} + +func GetHomepageCarousel(ctx context.Context, conn *sql.DB) ([]models.HomepageCarouselSlide, error) { + var raw string + err := conn.QueryRowContext(ctx, "SELECT value_text FROM cms_settings WHERE key_name = ? LIMIT 1", homepageCarouselSettingKey).Scan(&raw) + if err == sql.ErrNoRows { + return DefaultHomepageCarouselSlides(), nil + } + if err != nil { + return nil, err + } + if strings.TrimSpace(raw) == "" { + return DefaultHomepageCarouselSlides(), nil + } + + var parsed []models.HomepageCarouselSlide + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return DefaultHomepageCarouselSlides(), nil + } + + return NormalizeHomepageCarouselSlides(parsed), nil +} + +func SetHomepageCarousel(ctx context.Context, conn *sql.DB, slides []models.HomepageCarouselSlide) error { + normalized := NormalizeHomepageCarouselSlides(slides) + payload, err := json.Marshal(normalized) + if err != nil { + return err + } + + _, err = conn.ExecContext(ctx, ` + INSERT INTO cms_settings (key_name, value_text) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE value_text = VALUES(value_text) + `, homepageCarouselSettingKey, string(payload)) + return err +} + +func PublishedHomepageCarousel(slides []models.HomepageCarouselSlide) []models.HomepageCarouselSlide { + normalized := NormalizeHomepageCarouselSlides(slides) + published := make([]models.HomepageCarouselSlide, 0, len(normalized)) + for _, slide := range normalized { + if slide.Enabled { + published = append(published, slide) + } + } + return published +} + +func NormalizeHomepageCarouselSlides(slides []models.HomepageCarouselSlide) []models.HomepageCarouselSlide { + normalized := make([]models.HomepageCarouselSlide, 0, len(slides)) + for _, slide := range slides { + next := models.HomepageCarouselSlide{ + Enabled: slide.Enabled, + Title: strings.TrimSpace(slide.Title), + LinkURL: strings.TrimSpace(slide.LinkURL), + ImageURL: strings.TrimSpace(slide.ImageURL), + BackgroundColor: strings.TrimSpace(slide.BackgroundColor), + TextColor: strings.TrimSpace(slide.TextColor), + DesktopOnly: slide.DesktopOnly, + } + if next.LinkURL == "" && next.ImageURL == "" && next.Title == "" { + continue + } + if next.TextColor == "" { + next.TextColor = "#ffffff" + } + normalized = append(normalized, next) + } + return normalized +} + +func cloneHomepageCarouselSlides(slides []models.HomepageCarouselSlide) []models.HomepageCarouselSlide { + cloned := make([]models.HomepageCarouselSlide, len(slides)) + copy(cloned, slides) + return cloned +} diff --git a/server/internal/database/homepage_carousel_settings_test.go b/server/internal/database/homepage_carousel_settings_test.go new file mode 100644 index 0000000..d23e49d --- /dev/null +++ b/server/internal/database/homepage_carousel_settings_test.go @@ -0,0 +1,55 @@ +package database + +import ( + "testing" + + "server/internal/models" +) + +func TestNormalizeHomepageCarouselSlides(t *testing.T) { + slides := NormalizeHomepageCarouselSlides([]models.HomepageCarouselSlide{ + {Enabled: true, Title: " Podcast ", LinkURL: " /podcast ", ImageURL: " /images/podcast.webp ", TextColor: " "}, + {Enabled: true}, + {Enabled: false, Title: "Disabled draft"}, + }) + + if len(slides) != 2 { + t.Fatalf("slides = %d, want 2", len(slides)) + } + if slides[0].Title != "Podcast" { + t.Fatalf("title = %q, want %q", slides[0].Title, "Podcast") + } + if slides[0].LinkURL != "/podcast" { + t.Fatalf("link_url = %q, want %q", slides[0].LinkURL, "/podcast") + } + if slides[0].TextColor != "#ffffff" { + t.Fatalf("text_color = %q, want default #ffffff", slides[0].TextColor) + } + if slides[1].Title != "Disabled draft" { + t.Fatalf("disabled slide title = %q, want preserved draft", slides[1].Title) + } +} + +func TestPublishedHomepageCarousel(t *testing.T) { + slides := PublishedHomepageCarousel([]models.HomepageCarouselSlide{ + {Enabled: true, Title: "Live", LinkURL: "/live"}, + {Enabled: false, Title: "Draft", LinkURL: "/draft"}, + }) + + if len(slides) != 1 { + t.Fatalf("slides = %d, want 1", len(slides)) + } + if slides[0].Title != "Live" { + t.Fatalf("published title = %q, want Live", slides[0].Title) + } +} + +func TestDefaultHomepageCarouselSlidesReturnsCopy(t *testing.T) { + first := DefaultHomepageCarouselSlides() + first[0].Title = "Changed" + + second := DefaultHomepageCarouselSlides() + if second[0].Title == "Changed" { + t.Fatal("default slides share backing storage") + } +} diff --git a/server/internal/handlers/handlers.go b/server/internal/handlers/handlers.go index ff52149..9a4bcaa 100644 --- a/server/internal/handlers/handlers.go +++ b/server/internal/handlers/handlers.go @@ -2329,6 +2329,11 @@ func GetHomepage(conn *sql.DB) http.HandlerFunc { writeError(w, http.StatusInternalServerError, err.Error()) return } + carousel, err := db.GetHomepageCarousel(r.Context(), conn) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } storyTitles, err := db.GetDevelopingStories(r.Context(), conn) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) @@ -2352,6 +2357,7 @@ func GetHomepage(conn *sql.DB) http.HandlerFunc { sectionArticles := models.HomepageResponse{ BreakingNews: breakingNews, + Carousel: db.PublishedHomepageCarousel(carousel), DevelopingStories: developingStories, } diff --git a/server/internal/handlers/settings.go b/server/internal/handlers/settings.go index 27c5153..bce80c2 100644 --- a/server/internal/handlers/settings.go +++ b/server/internal/handlers/settings.go @@ -3,7 +3,10 @@ package handlers import ( "database/sql" "encoding/json" + "fmt" "net/http" + "net/url" + "strconv" "strings" "server/internal/activity" @@ -115,6 +118,56 @@ func PatchBreakingNews(conn *sql.DB) http.Handler { }) } +// @Summary Get homepage carousel settings +// @Tags settings +// @Produce json +// @Success 200 {object} models.HomepageCarouselSettingsResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /v1/settings/homepage-carousel [get] +func GetHomepageCarousel(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + slides, err := db.GetHomepageCarousel(r.Context(), conn) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to fetch homepage carousel settings") + return + } + writeJSON(w, http.StatusOK, models.HomepageCarouselSettingsResponse{Slides: slides}) + }) +} + +// @Summary Update homepage carousel settings +// @Tags settings +// @Accept json +// @Produce json +// @Param body body models.HomepageCarouselSettingsPatchRequest true "Homepage carousel settings" +// @Success 200 {object} models.HomepageCarouselSettingsResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Security BearerAuth +// @Router /v1/settings/homepage-carousel [patch] +func PatchHomepageCarousel(conn *sql.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body models.HomepageCarouselSettingsPatchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + slides := db.NormalizeHomepageCarouselSlides(body.Slides) + if err := validateHomepageCarouselSlides(slides); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := db.SetHomepageCarousel(r.Context(), conn, slides); err != nil { + writeError(w, http.StatusInternalServerError, "failed to update homepage carousel settings") + return + } + activity.LogRequest(r, "settings_changed", "Homepage carousel updated", "homepage_carousel", strconv.Itoa(len(slides))) + writeJSON(w, http.StatusOK, models.HomepageCarouselSettingsResponse{Slides: slides}) + }) +} + // @Summary Get the public-site footer menu // @Tags settings // @Produce json @@ -183,3 +236,48 @@ func PostRebuildTaxonomyCounts(conn *sql.DB) http.Handler { w.WriteHeader(http.StatusNoContent) }) } + +func validateHomepageCarouselSlides(slides []models.HomepageCarouselSlide) error { + if len(slides) > 20 { + return fmt.Errorf("homepage carousel cannot have more than 20 slides") + } + for idx, slide := range slides { + n := idx + 1 + if slide.Enabled && slide.LinkURL == "" { + return fmt.Errorf("slide %d link_url is required when enabled", n) + } + if slide.Enabled && slide.Title == "" && slide.ImageURL == "" { + return fmt.Errorf("slide %d needs a title or image_url when enabled", n) + } + if len(slide.Title) > 160 { + return fmt.Errorf("slide %d title cannot exceed 160 characters", n) + } + if len(slide.LinkURL) > 2048 { + return fmt.Errorf("slide %d link_url cannot exceed 2048 characters", n) + } + if len(slide.ImageURL) > 2048 { + return fmt.Errorf("slide %d image_url cannot exceed 2048 characters", n) + } + if len(slide.BackgroundColor) > 32 { + return fmt.Errorf("slide %d background_color cannot exceed 32 characters", n) + } + if len(slide.TextColor) > 32 { + return fmt.Errorf("slide %d text_color cannot exceed 32 characters", n) + } + if slide.LinkURL != "" && !validCarouselURL(slide.LinkURL) { + return fmt.Errorf("slide %d link_url must be a relative URL or absolute http(s) URL", n) + } + if slide.ImageURL != "" && !validCarouselURL(slide.ImageURL) { + return fmt.Errorf("slide %d image_url must be a relative URL or absolute http(s) URL", n) + } + } + return nil +} + +func validCarouselURL(raw string) bool { + if strings.HasPrefix(raw, "/") { + return !strings.HasPrefix(raw, "//") + } + parsed, err := url.Parse(raw) + return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https") +} diff --git a/server/internal/handlers/settings_test.go b/server/internal/handlers/settings_test.go new file mode 100644 index 0000000..b458e03 --- /dev/null +++ b/server/internal/handlers/settings_test.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "strings" + "testing" + + "server/internal/models" +) + +func TestValidateHomepageCarouselSlides(t *testing.T) { + valid := []models.HomepageCarouselSlide{ + {Enabled: true, Title: "Podcast", LinkURL: "https://example.com", ImageURL: "/images/podcast.webp"}, + {Enabled: false, Title: "Draft"}, + } + if err := validateHomepageCarouselSlides(valid); err != nil { + t.Fatalf("valid slides rejected: %v", err) + } + + invalid := []models.HomepageCarouselSlide{ + {Enabled: true, Title: "Broken", LinkURL: "ftp://example.com/file"}, + } + if err := validateHomepageCarouselSlides(invalid); err == nil || !strings.Contains(err.Error(), "link_url") { + t.Fatalf("invalid link_url error = %v, want link_url validation", err) + } +} diff --git a/server/internal/models/api_responses.go b/server/internal/models/api_responses.go index 88ddd67..4763118 100644 --- a/server/internal/models/api_responses.go +++ b/server/internal/models/api_responses.go @@ -188,6 +188,7 @@ type HomepageDevelopingStory struct { type HomepageResponse struct { BreakingNews BreakingNewsSettings `json:"breaking_news"` + Carousel []HomepageCarouselSlide `json:"carousel"` DevelopingStories []HomepageDevelopingStory `json:"developingstories"` News []ArticleListItem `json:"news"` Opinion []ArticleListItem `json:"opinion"` @@ -282,6 +283,24 @@ type BreakingNewsSettingsResponse = BreakingNewsSettings type BreakingNewsSettingsPatchRequest = BreakingNewsSettings +// HomepageCarouselSlide is one public Splide carousel item for Scalene's +// homepage. ImageURL may be empty for a text-only slide. +type HomepageCarouselSlide struct { + Enabled bool `json:"enabled"` + Title string `json:"title"` + LinkURL string `json:"link_url"` + ImageURL string `json:"image_url"` + BackgroundColor string `json:"background_color"` + TextColor string `json:"text_color"` + DesktopOnly bool `json:"desktop_only"` +} + +type HomepageCarouselSettingsResponse struct { + Slides []HomepageCarouselSlide `json:"slides"` +} + +type HomepageCarouselSettingsPatchRequest = HomepageCarouselSettingsResponse + // Footer entry kinds. A column is a flat ordered list rather than a heading // plus children because the live footer stacks two groups in one column // ("Columns" under "Opinion", "Special Editions" under "Comics & Puzzles"), diff --git a/server/internal/routes/routes.go b/server/internal/routes/routes.go index 3aaa1f7..a065023 100644 --- a/server/internal/routes/routes.go +++ b/server/internal/routes/routes.go @@ -93,6 +93,7 @@ func Register(mux *http.ServeMux, conn *sql.DB, verifier *oidc.IDTokenVerifier, mux.Handle("GET /v1/settings/site", handlers.GetSiteSettings(conn)) mux.Handle("GET /v1/settings/seo", handlers.GetSEOSettings(conn)) mux.Handle("GET /v1/settings/breaking-news", handlers.GetBreakingNews(conn)) + mux.Handle("GET /v1/settings/homepage-carousel", handlers.GetHomepageCarousel(conn)) mux.Handle("GET /v1/settings/footer", handlers.GetFooterSettings(conn)) mux.Handle("GET /v1/seo/audit", authMW(handlers.GetSEOAudit(conn))) mux.Handle("GET /v1/activity", authMW(adminOnly(handlers.GetActivity()))) @@ -128,6 +129,7 @@ func Register(mux *http.ServeMux, conn *sql.DB, verifier *oidc.IDTokenVerifier, mux.Handle("PATCH /v1/settings/site", authMW(adminOnly(handlers.PatchSiteSettings(conn)))) mux.Handle("PATCH /v1/settings/seo", authMW(adminOnly(handlers.PatchSEOSettings(conn)))) mux.Handle("PATCH /v1/settings/breaking-news", authMW(adminOnly(handlers.PatchBreakingNews(conn)))) + mux.Handle("PATCH /v1/settings/homepage-carousel", authMW(adminOnly(handlers.PatchHomepageCarousel(conn)))) mux.Handle("PATCH /v1/settings/footer", authMW(adminOnly(handlers.PatchFooterSettings(conn)))) mux.Handle("POST /v1/settings/taxonomy/rebuild", authMW(adminOnly(handlers.PostRebuildTaxonomyCounts(conn)))) mux.Handle("POST /v1/taxonomy", authMW(adminOnly(handlers.PostTaxonomy(conn)))) diff --git a/server/internal/routes/routes_test.go b/server/internal/routes/routes_test.go index 1664a9a..7302f6e 100644 --- a/server/internal/routes/routes_test.go +++ b/server/internal/routes/routes_test.go @@ -38,6 +38,7 @@ func TestRegister_ReadEndpointsPublicWithVerifier(t *testing.T) { "/v1/search", "/v1/authors", "/v1/authors/some-author", + "/v1/settings/homepage-carousel", "/v1/taxonomy", } for _, path := range public { @@ -70,6 +71,39 @@ func TestRegister_ReadEndpointsPublicWithVerifier(t *testing.T) { } } +func TestRegister_SettingsWriteEndpointsGated(t *testing.T) { + verifier := oidc.NewVerifier("https://issuer.example", nil, &oidc.Config{ + ClientID: "test", + SkipClientIDCheck: true, + }) + + mux := http.NewServeMux() + Register(mux, nil, verifier, auth.OIDCConfig{}, nil) + + tests := []struct { + method string + path string + }{ + {http.MethodPatch, "/v1/settings/site"}, + {http.MethodPatch, "/v1/settings/seo"}, + {http.MethodPatch, "/v1/settings/breaking-news"}, + {http.MethodPatch, "/v1/settings/homepage-carousel"}, + } + + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, nil) + rec := httptest.NewRecorder() + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected %d, got %d", http.StatusUnauthorized, rec.Code) + } + }) + } +} + func TestRegister_PublicRoute(t *testing.T) { mux := http.NewServeMux() Register(mux, nil, nil, auth.OIDCConfig{}, nil)