diff --git a/frontend/src/pages/settingsPage.tsx b/frontend/src/pages/settingsPage.tsx index ac68b36..6760208 100644 --- a/frontend/src/pages/settingsPage.tsx +++ b/frontend/src/pages/settingsPage.tsx @@ -1,6 +1,7 @@ -import { ArrowDown, ArrowUp, LogOut, Plus, RefreshCw, Trash2 } from "lucide-react" +import { ArrowDown, ArrowUp, ImageOff, ImagePlus, LogOut, Plus, RefreshCw, Trash2 } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" import FooterMenuEditor from "../components/FooterMenuEditor" +import MediaPicker, { type MediaPickerItem } from "../components/MediaPicker" import { useApiFetch } from "../hooks/useApiFetch" import { useCurrentUserRole } from "../hooks/useCurrentUserRole" import { useNavigate } from "react-router-dom" @@ -68,6 +69,8 @@ export default function SettingsPage() { const [carouselSaved, setCarouselSaved] = useState([]) const [carouselSaving, setCarouselSaving] = useState(false) const [carouselMessage, setCarouselMessage] = useState(null) + // Index of the slide whose image is being picked, or null when the modal is closed. + const [carouselPickerIndex, setCarouselPickerIndex] = 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. @@ -222,6 +225,11 @@ export default function SettingsPage() { setCarouselSlides((current) => current.filter((_, idx) => idx !== index)) } + function selectCarouselImage(item: MediaPickerItem) { + if (carouselPickerIndex !== null) updateCarouselSlide(carouselPickerIndex, { image_url: item.url }) + setCarouselPickerIndex(null) + } + async function saveHomepageCarousel() { const slides = carouselSlides.map((slide) => ({ ...slide, @@ -509,13 +517,47 @@ export default function SettingsPage() { />
- - 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" - /> + +
+ {slide.image_url ? ( + + ) : ( +
+ +
+ )} +
+ 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="Choose from the media library, or paste a URL" + /> +
+ + {slide.image_url && ( + + )} +
+
+
@@ -647,6 +689,14 @@ export default function SettingsPage() {
)} + + {carouselPickerIndex !== null && ( + setCarouselPickerIndex(null)} + onSelect={selectCarouselImage} + /> + )} ) } diff --git a/server/internal/database/homepage_carousel_settings.go b/server/internal/database/homepage_carousel_settings.go index e3e3450..30d9809 100644 --- a/server/internal/database/homepage_carousel_settings.go +++ b/server/internal/database/homepage_carousel_settings.go @@ -11,6 +11,22 @@ import ( const homepageCarouselSettingKey = "homepage_carousel" +// Default slide images live on the media filesystem under a `scalene/` prefix +// rather than in Scalene's bundled public/images. Bundled paths could not be +// changed from the CMS -- editing the Podcast banner meant a Scalene commit and +// redeploy -- which defeats the point of a CMS-editable carousel, and a rename +// on the Scalene side silently broke the slide. +// +// The prefix sits under wp-content/uploads because that is the only tree Nginx +// serves (see deploy/nginx/triangle-cms.conf); it is outside the YYYY/MM layout +// so the media reindex and the legacy WP corpus stay visibly separate. +// +// That location is served with 30-day immutable caching, which assumes a +// filename pins its bytes. To swap one of these banners, upload under a NEW +// name and repoint the slide -- overwriting in place leaves the old image at +// the edge for up to a month. +const defaultCarouselImageBase = "https://delta.thetriangle.org/wp-content/uploads/scalene" + var defaultHomepageCarouselSlides = []models.HomepageCarouselSlide{ { Enabled: true, @@ -23,7 +39,7 @@ var defaultHomepageCarouselSlides = []models.HomepageCarouselSlide{ Enabled: true, Title: "Podcast", LinkURL: "https://tr.ee/bqz8s-E4NK", - ImageURL: "/images/PodcastBanner.webp", + ImageURL: defaultCarouselImageBase + "/PodcastBanner.webp", BackgroundColor: "#acd4f4", TextColor: "#ffffff", }, @@ -31,7 +47,7 @@ var defaultHomepageCarouselSlides = []models.HomepageCarouselSlide{ Enabled: true, Title: "Classifieds", LinkURL: "/classifieds", - ImageURL: "/images/classifiedsBannerNew.webp", + ImageURL: defaultCarouselImageBase + "/classifiedsBannerNew.webp", BackgroundColor: "#E5E7EB", TextColor: "#ffffff", }, @@ -39,7 +55,7 @@ var defaultHomepageCarouselSlides = []models.HomepageCarouselSlide{ Enabled: true, Title: "Apply to The Triangle", LinkURL: "https://docs.google.com/forms/d/e/1FAIpQLScra_6sUenvmpIuQ5FjmMyWO0a2sz9z36HkrqfnYQvJGH9BGQ/viewform", - ImageURL: "/images/applyBannerNew.webp", + ImageURL: defaultCarouselImageBase + "/applyBannerNew.webp", BackgroundColor: "#275997", TextColor: "#ffffff", },