feat: site-wide Swing Music player, persistent storage, mobile UX polish
- SiteWideMusicDock component at app level; persists across all routes - Dock + player merged into single expandable panel - Remove non-functional Hide button; one Minimize/Open control - Swing Music label and action button on same row - Mobile: dock z-index above player, larger touch target on iPad - entrypoint.sh: seeds admin-content.json only on first boot/fresh volume - Dockerfile: seed in /app/data-seed; live data in /app/data (volume) - shell-with-music-bar padding on project and admin pages
This commit is contained in:
+534
-37
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { Link, Navigate, Route, Routes, useParams } from 'react-router-dom'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
@@ -9,6 +9,7 @@ import type { Project } from './data/projects'
|
||||
|
||||
const THEME_STORAGE_KEY = 'portfolio-theme-v1'
|
||||
const ADMIN_CONTENT_API = '/api/admin-content'
|
||||
const WEATHER_REPO_URL = 'https://github.com/nmemmert/weather'
|
||||
|
||||
type ThemeName = 'sand' | 'ocean' | 'midnight' | 'forest' | 'sunset' | 'rose' | 'slate' | 'aurora' | 'amethyst'
|
||||
|
||||
@@ -39,6 +40,8 @@ type SiteContent = {
|
||||
downloadsIntro: string
|
||||
downloads: DownloadItem[]
|
||||
showSidebar: boolean
|
||||
showWeatherWidget: boolean
|
||||
showMusicBar: boolean
|
||||
homeSections: HomeSectionConfig[]
|
||||
}
|
||||
|
||||
@@ -77,6 +80,8 @@ const defaultSiteContent: SiteContent = {
|
||||
},
|
||||
],
|
||||
showSidebar: false,
|
||||
showWeatherWidget: true,
|
||||
showMusicBar: false,
|
||||
homeSections: DEFAULT_HOME_SECTIONS,
|
||||
}
|
||||
|
||||
@@ -93,6 +98,168 @@ type ScanResult = {
|
||||
source: string
|
||||
}
|
||||
|
||||
type WeatherWidgetState = 'loading' | 'ready' | 'error'
|
||||
|
||||
type WeatherHeroCard = {
|
||||
location: string
|
||||
icon: string
|
||||
description: string
|
||||
precipitation: string
|
||||
temperature: string
|
||||
feelsLike: string
|
||||
high: string
|
||||
low: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type WeatherApiPayload = {
|
||||
current?: {
|
||||
weather_code?: number
|
||||
temperature_2m?: number
|
||||
apparent_temperature?: number
|
||||
precipitation?: number
|
||||
}
|
||||
daily?: {
|
||||
temperature_2m_max?: number[]
|
||||
temperature_2m_min?: number[]
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_WIDGET_COORDS = {
|
||||
lat: 36.1627,
|
||||
lon: -86.7816,
|
||||
}
|
||||
|
||||
const WMO_SUMMARY: Record<number, { icon: string; description: string }> = {
|
||||
0: { icon: 'SUN', description: 'Clear sky' },
|
||||
1: { icon: 'SUN', description: 'Mainly clear' },
|
||||
2: { icon: 'PART', description: 'Partly cloudy' },
|
||||
3: { icon: 'CLOUD', description: 'Overcast' },
|
||||
45: { icon: 'FOG', description: 'Foggy' },
|
||||
48: { icon: 'FOG', description: 'Icy fog' },
|
||||
51: { icon: 'RAIN', description: 'Light drizzle' },
|
||||
53: { icon: 'RAIN', description: 'Drizzle' },
|
||||
55: { icon: 'RAIN', description: 'Heavy drizzle' },
|
||||
56: { icon: 'SNOW', description: 'Freezing drizzle' },
|
||||
57: { icon: 'SNOW', description: 'Heavy freezing drizzle' },
|
||||
61: { icon: 'RAIN', description: 'Light rain' },
|
||||
63: { icon: 'RAIN', description: 'Rain' },
|
||||
65: { icon: 'RAIN', description: 'Heavy rain' },
|
||||
66: { icon: 'SNOW', description: 'Light freezing rain' },
|
||||
67: { icon: 'SNOW', description: 'Heavy freezing rain' },
|
||||
71: { icon: 'SNOW', description: 'Light snow' },
|
||||
73: { icon: 'SNOW', description: 'Snow' },
|
||||
75: { icon: 'SNOW', description: 'Heavy snow' },
|
||||
77: { icon: 'SNOW', description: 'Snow grains' },
|
||||
80: { icon: 'RAIN', description: 'Rain showers' },
|
||||
81: { icon: 'RAIN', description: 'Rain showers' },
|
||||
82: { icon: 'RAIN', description: 'Violent rain showers' },
|
||||
85: { icon: 'SNOW', description: 'Snow showers' },
|
||||
86: { icon: 'SNOW', description: 'Heavy snow showers' },
|
||||
95: { icon: 'STORM', description: 'Thunderstorm' },
|
||||
96: { icon: 'STORM', description: 'Thunderstorm with hail' },
|
||||
99: { icon: 'STORM', description: 'Severe thunderstorm with hail' },
|
||||
}
|
||||
|
||||
function getWmoSummary(code: number | null | undefined): { icon: string; description: string } {
|
||||
if (typeof code !== 'number') {
|
||||
return { icon: 'WX', description: 'Current conditions' }
|
||||
}
|
||||
|
||||
return WMO_SUMMARY[code] || { icon: 'WX', description: 'Current conditions' }
|
||||
}
|
||||
|
||||
function formatFahrenheit(value: number | null | undefined): string {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return `${Math.round(value)}°F`
|
||||
}
|
||||
|
||||
function formatPrecip(value: number | null | undefined): string {
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) {
|
||||
return 'No precipitation'
|
||||
}
|
||||
|
||||
return `${value.toFixed(2)} in precip`
|
||||
}
|
||||
|
||||
function getBrowserCoords(timeoutMs: number): Promise<{ lat: number; lon: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation not supported'))
|
||||
return
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
resolve({
|
||||
lat: position.coords.latitude,
|
||||
lon: position.coords.longitude,
|
||||
})
|
||||
},
|
||||
() => reject(new Error('Location access denied')),
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function hasHeroWeatherData(payload: WeatherApiPayload): boolean {
|
||||
return Boolean(
|
||||
payload.current &&
|
||||
payload.daily?.temperature_2m_max?.length &&
|
||||
payload.daily.temperature_2m_min?.length,
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchWidgetWeatherFromApp(
|
||||
baseUrl: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
): Promise<WeatherApiPayload> {
|
||||
const weatherResponse = await fetch(
|
||||
`${baseUrl}/api/weather?lat=${lat}&lon=${lon}&tz=auto&units=us`,
|
||||
)
|
||||
|
||||
if (!weatherResponse.ok) {
|
||||
throw new Error('Weather app API request failed')
|
||||
}
|
||||
|
||||
return (await weatherResponse.json()) as WeatherApiPayload
|
||||
}
|
||||
|
||||
async function fetchWidgetWeatherFromOpenMeteo(
|
||||
lat: number,
|
||||
lon: number,
|
||||
): Promise<WeatherApiPayload> {
|
||||
const params = new URLSearchParams({
|
||||
latitude: String(lat),
|
||||
longitude: String(lon),
|
||||
timezone: 'auto',
|
||||
current: [
|
||||
'temperature_2m',
|
||||
'apparent_temperature',
|
||||
'precipitation',
|
||||
'weather_code',
|
||||
].join(','),
|
||||
daily: ['temperature_2m_max', 'temperature_2m_min'].join(','),
|
||||
temperature_unit: 'fahrenheit',
|
||||
precipitation_unit: 'inch',
|
||||
forecast_days: '1',
|
||||
})
|
||||
|
||||
const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Open-Meteo fallback request failed')
|
||||
}
|
||||
|
||||
return (await response.json()) as WeatherApiPayload
|
||||
}
|
||||
|
||||
function splitLines(value: string): string[] {
|
||||
return value
|
||||
.split('\n')
|
||||
@@ -322,6 +489,14 @@ async function fetchAdminContent(): Promise<
|
||||
typeof payload.siteContent.showSidebar === 'boolean'
|
||||
? payload.siteContent.showSidebar
|
||||
: defaultSiteContent.showSidebar,
|
||||
showWeatherWidget:
|
||||
typeof payload.siteContent.showWeatherWidget === 'boolean'
|
||||
? payload.siteContent.showWeatherWidget
|
||||
: defaultSiteContent.showWeatherWidget,
|
||||
showMusicBar:
|
||||
typeof payload.siteContent.showMusicBar === 'boolean'
|
||||
? payload.siteContent.showMusicBar
|
||||
: defaultSiteContent.showMusicBar,
|
||||
homeSections: normalizeHomeSections(payload.siteContent.homeSections),
|
||||
},
|
||||
}
|
||||
@@ -522,6 +697,151 @@ async function scanMetadataFromUrl(input: string): Promise<ScanResult> {
|
||||
throw new Error('Unable to scan this URL automatically.')
|
||||
}
|
||||
|
||||
function SiteWideMusicDock({
|
||||
projectList,
|
||||
showMusicBar,
|
||||
}: {
|
||||
projectList: Project[]
|
||||
showMusicBar: boolean
|
||||
}) {
|
||||
const musicProject = projectList.find(
|
||||
(project) => project.slug === 'music' || project.domain === 'music.necloud.us',
|
||||
)
|
||||
const musicWidgetUrl = musicProject?.url || 'https://music.necloud.us'
|
||||
const musicWidgetTitle = musicProject?.title || 'Swing Music'
|
||||
const [isMusicPlayerOpen, setIsMusicPlayerOpen] = useState(false)
|
||||
const [hasMusicPlayerSession, setHasMusicPlayerSession] = useState(false)
|
||||
const [isMusicDockVisible, setIsMusicDockVisible] = useState(true)
|
||||
const [isMusicDockMinimized, setIsMusicDockMinimized] = useState(true)
|
||||
const lastScrollYRef = useRef(0)
|
||||
|
||||
const toggleMusicPlayer = () => {
|
||||
setIsMusicDockVisible(true)
|
||||
|
||||
if (!hasMusicPlayerSession) {
|
||||
setHasMusicPlayerSession(true)
|
||||
setIsMusicPlayerOpen(true)
|
||||
setIsMusicDockMinimized(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsMusicPlayerOpen((current) => {
|
||||
const next = !current
|
||||
setIsMusicDockMinimized(!next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMusicBar) {
|
||||
setIsMusicPlayerOpen(false)
|
||||
setHasMusicPlayerSession(false)
|
||||
setIsMusicDockVisible(true)
|
||||
setIsMusicDockMinimized(true)
|
||||
}
|
||||
}, [showMusicBar])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMusicBar) {
|
||||
return
|
||||
}
|
||||
|
||||
lastScrollYRef.current = window.scrollY
|
||||
|
||||
const handleScroll = () => {
|
||||
const currentY = window.scrollY
|
||||
const delta = currentY - lastScrollYRef.current
|
||||
|
||||
if (isMusicPlayerOpen) {
|
||||
setIsMusicDockVisible(true)
|
||||
lastScrollYRef.current = currentY
|
||||
return
|
||||
}
|
||||
|
||||
if (currentY <= 24 || delta < -8) {
|
||||
setIsMusicDockVisible(true)
|
||||
} else if (delta > 10 && currentY > 120) {
|
||||
setIsMusicDockVisible(false)
|
||||
}
|
||||
|
||||
lastScrollYRef.current = currentY
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [showMusicBar, isMusicPlayerOpen])
|
||||
|
||||
if (!showMusicBar) {
|
||||
return null
|
||||
}
|
||||
|
||||
const musicDockStatusMessage =
|
||||
hasMusicPlayerSession && !isMusicPlayerOpen
|
||||
? 'Player hidden. Site-wide audio can keep playing.'
|
||||
: isMusicPlayerOpen
|
||||
? 'Site-wide player active.'
|
||||
: 'Site-wide player ready.'
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`music-dock${isMusicDockVisible ? ' is-visible' : ' is-hidden'}${
|
||||
isMusicDockMinimized ? ' is-minimized' : ''
|
||||
}`}
|
||||
aria-label="Swing Music dock"
|
||||
>
|
||||
<div className="music-dock-main">
|
||||
<button
|
||||
type="button"
|
||||
className="music-dock-content"
|
||||
onClick={() => {
|
||||
if (isMusicPlayerOpen) {
|
||||
return
|
||||
}
|
||||
setIsMusicDockVisible(true)
|
||||
setIsMusicPlayerOpen(true)
|
||||
setIsMusicDockMinimized(false)
|
||||
if (!hasMusicPlayerSession) {
|
||||
setHasMusicPlayerSession(true)
|
||||
}
|
||||
}}
|
||||
aria-label={isMusicPlayerOpen ? `${musicWidgetTitle} status` : `Open ${musicWidgetTitle} player`}
|
||||
>
|
||||
<p className="music-dock-title-row">
|
||||
<span className="music-dock-indicator" aria-hidden="true" />
|
||||
{musicWidgetTitle} (Site-wide)
|
||||
</p>
|
||||
<p className="music-dock-now-playing">
|
||||
{hasMusicPlayerSession ? 'Playback session active' : 'Not playing yet'}
|
||||
</p>
|
||||
<p className="music-dock-status">{musicDockStatusMessage}</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="music-dock-actions">
|
||||
<button type="button" onClick={toggleMusicPlayer}>
|
||||
{isMusicPlayerOpen ? 'Minimize' : hasMusicPlayerSession ? 'Open player' : 'Player'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`music-dock-player${isMusicPlayerOpen ? ' is-visible' : ''}`}
|
||||
aria-hidden={isMusicPlayerOpen ? 'false' : 'true'}
|
||||
>
|
||||
{hasMusicPlayerSession ? (
|
||||
<iframe
|
||||
title="Swing Music player"
|
||||
src={musicWidgetUrl}
|
||||
className="music-player-frame"
|
||||
loading="lazy"
|
||||
allow="autoplay; encrypted-media"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HomePage({
|
||||
projectList,
|
||||
siteContent,
|
||||
@@ -553,6 +873,101 @@ function HomePage({
|
||||
}
|
||||
}
|
||||
|
||||
const weatherProject = projectList.find(
|
||||
(project) => project.slug === 'weather' || project.category.toLowerCase() === 'weather',
|
||||
)
|
||||
const weatherWidgetUrl = weatherProject?.url || 'https://weather.necloud.us'
|
||||
const weatherWidgetTitle = weatherProject?.title || 'Weather'
|
||||
const [weatherWidgetState, setWeatherWidgetState] = useState<WeatherWidgetState>('loading')
|
||||
const [weatherHeroCard, setWeatherHeroCard] = useState<WeatherHeroCard | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false
|
||||
|
||||
const loadWeatherHeroCard = async () => {
|
||||
setWeatherWidgetState('loading')
|
||||
|
||||
const baseUrl = weatherWidgetUrl.replace(/\/+$/, '')
|
||||
let lat = DEFAULT_WIDGET_COORDS.lat
|
||||
let lon = DEFAULT_WIDGET_COORDS.lon
|
||||
|
||||
try {
|
||||
const coords = await getBrowserCoords(4500)
|
||||
lat = coords.lat
|
||||
lon = coords.lon
|
||||
} catch {
|
||||
// Keep the hero card resilient with a fixed fallback location.
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: WeatherApiPayload
|
||||
try {
|
||||
payload = await fetchWidgetWeatherFromApp(baseUrl, lat, lon)
|
||||
} catch {
|
||||
payload = await fetchWidgetWeatherFromOpenMeteo(lat, lon)
|
||||
}
|
||||
|
||||
if (!hasHeroWeatherData(payload)) {
|
||||
throw new Error('Incomplete weather payload')
|
||||
}
|
||||
|
||||
const current = payload.current
|
||||
const daily = payload.daily
|
||||
|
||||
let locationLabel = `Lat ${lat.toFixed(2)}, Lon ${lon.toFixed(2)}`
|
||||
try {
|
||||
const reverseResponse = await fetch(`${baseUrl}/api/reverse-geocode?lat=${lat}&lon=${lon}`)
|
||||
if (reverseResponse.ok) {
|
||||
const reversePayload = (await reverseResponse.json()) as {
|
||||
name?: string
|
||||
admin1?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
const composed = [reversePayload.name, reversePayload.admin1, reversePayload.country]
|
||||
.filter((value) => typeof value === 'string' && value.trim())
|
||||
.join(', ')
|
||||
|
||||
if (composed) {
|
||||
locationLabel = composed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Use default location label when reverse geocoding is unavailable.
|
||||
}
|
||||
|
||||
const summary = getWmoSummary(current?.weather_code)
|
||||
const nextCard: WeatherHeroCard = {
|
||||
location: locationLabel,
|
||||
icon: summary.icon,
|
||||
description: summary.description,
|
||||
precipitation: formatPrecip(current?.precipitation),
|
||||
temperature: formatFahrenheit(current?.temperature_2m),
|
||||
feelsLike: formatFahrenheit(current?.apparent_temperature),
|
||||
high: formatFahrenheit(daily?.temperature_2m_max?.[0]),
|
||||
low: formatFahrenheit(daily?.temperature_2m_min?.[0]),
|
||||
updatedAt: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
|
||||
if (!canceled) {
|
||||
setWeatherHeroCard(nextCard)
|
||||
setWeatherWidgetState('ready')
|
||||
}
|
||||
} catch {
|
||||
if (!canceled) {
|
||||
setWeatherHeroCard(null)
|
||||
setWeatherWidgetState('error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadWeatherHeroCard()
|
||||
|
||||
return () => {
|
||||
canceled = true
|
||||
}
|
||||
}, [weatherWidgetUrl])
|
||||
|
||||
const renderHomeSection = (sectionId: HomeSectionId) => {
|
||||
if (sectionId === 'summary') {
|
||||
return (
|
||||
@@ -668,7 +1083,7 @@ function HomePage({
|
||||
: []
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<main className={`shell${siteContent.showMusicBar ? ' shell-with-music-bar' : ''}`}>
|
||||
<section className="hero">
|
||||
<p className="eyebrow">{siteContent.homeEyebrow}</p>
|
||||
<h1>{siteContent.homeTitle}</h1>
|
||||
@@ -700,16 +1115,65 @@ function HomePage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{siteContent.showSidebar && sidebarSections.length > 0 ? (
|
||||
{siteContent.showSidebar ? (
|
||||
<div className="home-layout">
|
||||
<div className="home-main">{mainSections.map((section) => renderHomeSection(section.id))}</div>
|
||||
<aside className="home-sidebar">
|
||||
{siteContent.showWeatherWidget ? (
|
||||
<section className="notes weather-widget" aria-label="Weather widget">
|
||||
<h3>{weatherWidgetTitle} hero widget</h3>
|
||||
{weatherWidgetState === 'loading' ? <p>Loading hero conditions...</p> : null}
|
||||
{weatherWidgetState === 'error' ? (
|
||||
<p>Could not load hero conditions right now. Open your weather app for full details.</p>
|
||||
) : null}
|
||||
{weatherWidgetState === 'ready' && weatherHeroCard ? (
|
||||
<article className="weather-hero-card" aria-label="Weather hero card">
|
||||
<div className="weather-hero-row">
|
||||
<span className="weather-hero-icon" aria-hidden="true">
|
||||
{weatherHeroCard.icon}
|
||||
</span>
|
||||
<div>
|
||||
<p className="weather-hero-location">{weatherHeroCard.location}</p>
|
||||
<p className="weather-hero-desc">{weatherHeroCard.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="weather-hero-main-temp">{weatherHeroCard.temperature}</div>
|
||||
<p className="weather-hero-sub">Feels like {weatherHeroCard.feelsLike}</p>
|
||||
<p className="weather-hero-sub">{weatherHeroCard.precipitation}</p>
|
||||
|
||||
<div className="weather-hero-hilo" aria-label="High and low">
|
||||
<div>
|
||||
<span className="weather-hero-label">High</span>
|
||||
<strong>{weatherHeroCard.high}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="weather-hero-label">Low</span>
|
||||
<strong>{weatherHeroCard.low}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="weather-hero-updated">Updated {weatherHeroCard.updatedAt}</p>
|
||||
</article>
|
||||
) : null}
|
||||
<div className="weather-widget-actions">
|
||||
<a href={weatherWidgetUrl} target="_blank" rel="noreferrer">
|
||||
Open weather app
|
||||
</a>
|
||||
<a href={WEATHER_REPO_URL} target="_blank" rel="noreferrer">
|
||||
View source
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{sidebarSections.map((section) => renderHomeSection(section.id))}
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
orderedSections.map((section) => renderHomeSection(section.id))
|
||||
)}
|
||||
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -770,7 +1234,7 @@ function ProjectPage({
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<main className={`shell${siteContent.showMusicBar ? ' shell-with-music-bar' : ''}`}>
|
||||
<section className="hero">
|
||||
<p className="eyebrow">Project</p>
|
||||
<h1>Project not found</h1>
|
||||
@@ -784,7 +1248,7 @@ function ProjectPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<main className={`shell${siteContent.showMusicBar ? ' shell-with-music-bar' : ''}`}>
|
||||
<section className="hero">
|
||||
<p className="eyebrow">{siteContent.projectEyebrow}</p>
|
||||
<h1>{project.title}</h1>
|
||||
@@ -1033,7 +1497,7 @@ function AdminPage({
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<main className={`shell${siteContent.showMusicBar ? ' shell-with-music-bar' : ''}`}>
|
||||
<section className="hero">
|
||||
<p className="eyebrow">Admin</p>
|
||||
<h1>No projects available</h1>
|
||||
@@ -1054,7 +1518,7 @@ function AdminPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<main className={`shell${siteContent.showMusicBar ? ' shell-with-music-bar' : ''}`}>
|
||||
<section className="hero">
|
||||
<p className="eyebrow">Admin</p>
|
||||
<h1>Edit Cards and About Pages</h1>
|
||||
@@ -1289,6 +1753,36 @@ function AdminPage({
|
||||
/>
|
||||
<span>Enable sidebar on the home page</span>
|
||||
</label>
|
||||
|
||||
<label className="admin-label" htmlFor="showWeatherWidget">
|
||||
Weather widget
|
||||
</label>
|
||||
<label className="admin-checkbox-row" htmlFor="showWeatherWidget">
|
||||
<input
|
||||
id="showWeatherWidget"
|
||||
type="checkbox"
|
||||
checked={siteDraft.showWeatherWidget}
|
||||
onChange={(event) =>
|
||||
setSiteDraft({ ...siteDraft, showWeatherWidget: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>Show weather hero widget in the sidebar</span>
|
||||
</label>
|
||||
|
||||
<label className="admin-label" htmlFor="showMusicBar">
|
||||
Swing Music site-wide bar
|
||||
</label>
|
||||
<label className="admin-checkbox-row" htmlFor="showMusicBar">
|
||||
<input
|
||||
id="showMusicBar"
|
||||
type="checkbox"
|
||||
checked={siteDraft.showMusicBar}
|
||||
onChange={(event) =>
|
||||
setSiteDraft({ ...siteDraft, showMusicBar: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>Keep a Swing Music player available site-wide while scrolling</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="layout-manager">
|
||||
@@ -2090,36 +2584,39 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<HomePage projectList={managedProjects} siteContent={siteContent} />}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:slug"
|
||||
element={<ProjectPage projectList={managedProjects} siteContent={siteContent} />}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminPage
|
||||
projectList={managedProjects}
|
||||
onSave={handleSaveProject}
|
||||
onReorderProjects={handleReorderProjects}
|
||||
onResetProject={handleResetProject}
|
||||
onResetAll={handleResetAll}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onCreateProject={handleCreateProject}
|
||||
theme={theme}
|
||||
onThemeChange={handleThemeChange}
|
||||
siteContent={siteContent}
|
||||
onSaveSiteContent={handleSaveSiteContent}
|
||||
onResetSiteContent={handleResetSiteContent}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<HomePage projectList={managedProjects} siteContent={siteContent} />}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:slug"
|
||||
element={<ProjectPage projectList={managedProjects} siteContent={siteContent} />}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminPage
|
||||
projectList={managedProjects}
|
||||
onSave={handleSaveProject}
|
||||
onReorderProjects={handleReorderProjects}
|
||||
onResetProject={handleResetProject}
|
||||
onResetAll={handleResetAll}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onCreateProject={handleCreateProject}
|
||||
theme={theme}
|
||||
onThemeChange={handleThemeChange}
|
||||
siteContent={siteContent}
|
||||
onSaveSiteContent={handleSaveSiteContent}
|
||||
onResetSiteContent={handleResetSiteContent}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<SiteWideMusicDock projectList={managedProjects} showMusicBar={siteContent.showMusicBar} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user