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:
nmemmert
2026-03-19 16:05:16 -04:00
parent a06830281d
commit 8f165afcc9
6 changed files with 1310 additions and 39 deletions
+345
View File
@@ -7,6 +7,10 @@
gap: 1.5rem;
}
.shell-with-music-bar {
padding-bottom: calc(6.2rem + env(safe-area-inset-bottom));
}
.hero {
border: 1px solid var(--line);
border-radius: var(--radius-panel);
@@ -299,6 +303,313 @@ h1 {
color: var(--ink-muted);
}
.weather-widget {
overflow: hidden;
}
.weather-hero-card {
margin-top: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-note);
background: linear-gradient(150deg, rgba(10, 61, 92, 0.98) 0%, rgba(18, 30, 43, 0.97) 100%);
color: #f8fbff;
padding: 0.95rem;
}
.weather-hero-row {
display: flex;
gap: 0.65rem;
align-items: center;
}
.weather-hero-icon {
width: 2.2rem;
height: 2.2rem;
border-radius: 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.22);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
background: rgba(255, 255, 255, 0.12);
}
.weather-hero-card .weather-hero-location,
.weather-hero-card .weather-hero-desc,
.weather-hero-card .weather-hero-sub,
.weather-hero-card .weather-hero-updated {
margin: 0;
color: rgba(248, 251, 255, 0.96);
}
.weather-hero-card .weather-hero-location {
font-size: 0.9rem;
font-weight: 700;
}
.weather-hero-card .weather-hero-desc {
margin-top: 0.1rem;
font-size: 0.88rem;
}
.weather-hero-main-temp {
margin-top: 0.7rem;
font-family: var(--display);
font-size: 2.25rem;
line-height: 1;
}
.weather-hero-card .weather-hero-sub {
margin-top: 0.2rem;
font-size: 0.9rem;
}
.weather-hero-hilo {
margin-top: 0.65rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
}
.weather-hero-hilo > div {
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 10px;
padding: 0.45rem 0.5rem;
background: rgba(255, 255, 255, 0.08);
}
.weather-hero-label {
display: block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(248, 251, 255, 0.9);
}
.weather-hero-hilo strong {
font-size: 1.15rem;
}
.weather-hero-card .weather-hero-updated {
margin-top: 0.55rem;
font-size: 0.8rem;
}
.weather-widget-actions {
margin-top: 0.65rem;
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.weather-widget-actions a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.weather-widget-actions a:hover {
text-decoration: underline;
}
.music-dock {
position: fixed;
left: 50%;
width: min(560px, calc(100vw - 1rem));
bottom: 0;
z-index: 50;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
gap: 0.45rem;
padding: 0.48rem 0.8rem calc(0.48rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
border-left: 1px solid rgba(255, 255, 255, 0.06);
border-right: 1px solid rgba(255, 255, 255, 0.06);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
background: linear-gradient(95deg, rgba(16, 24, 34, 0.97) 0%, rgba(28, 36, 49, 0.96) 100%);
backdrop-filter: blur(8px);
transform: translateX(-50%) translateY(0);
opacity: 1;
transition: transform 180ms ease, opacity 180ms ease;
}
.music-dock.is-hidden {
transform: translateX(-50%) translateY(calc(100% + env(safe-area-inset-bottom)));
opacity: 0.95;
}
.music-dock-main {
min-width: 0;
display: flex;
align-items: center;
gap: 0.55rem;
}
.music-dock-content {
flex: 1;
min-width: 0;
border: 0;
padding: 0;
margin: 0;
text-align: left;
color: inherit;
background: transparent;
font: inherit;
cursor: pointer;
}
.music-dock-title-row,
.music-dock-now-playing,
.music-dock-status {
margin: 0;
color: #f4f8ff;
}
.music-dock-title-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
font-size: 0.88rem;
}
.music-dock-indicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #6de39d;
box-shadow: 0 0 0 4px rgba(109, 227, 157, 0.2);
}
.music-dock-now-playing {
font-size: 0.76rem;
color: rgba(244, 248, 255, 0.88);
}
.music-dock-status {
margin-top: 0.05rem;
font-size: 0.73rem;
color: #b9f0cf;
}
.music-dock.is-minimized .music-dock-now-playing,
.music-dock.is-minimized .music-dock-status {
display: none;
}
.music-dock.is-minimized {
padding-top: 0.4rem;
padding-bottom: calc(0.4rem + env(safe-area-inset-bottom));
}
.music-dock-player {
grid-column: 1 / -1;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 220ms ease, opacity 180ms ease;
}
.music-dock-player.is-visible {
max-height: min(72vh, 820px);
opacity: 1;
}
.music-player-frame {
width: 100%;
height: min(70vh, 720px);
border: 1px solid rgba(120, 131, 150, 0.45);
border-radius: 18px;
background: #101722;
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.35);
}
.music-dock-actions {
display: flex;
gap: 0.4rem;
flex-wrap: nowrap;
align-items: center;
align-self: center;
justify-content: flex-end;
}
.music-dock-actions a,
.music-dock-actions button,
.music-login-modal-actions a,
.music-login-modal-actions button {
border: 1px solid rgba(244, 248, 255, 0.3);
border-radius: 10px;
padding: 0.34rem 0.62rem;
background: rgba(255, 255, 255, 0.08);
color: #f4f8ff;
text-decoration: none;
font-weight: 700;
font-size: 0.8rem;
font-family: var(--body);
line-height: 1.2;
cursor: pointer;
white-space: nowrap;
}
.music-dock-actions a:hover,
.music-dock-actions button:hover,
.music-login-modal-actions a:hover,
.music-login-modal-actions button:hover {
background: rgba(255, 255, 255, 0.18);
}
.music-login-modal-backdrop {
position: fixed;
inset: 0;
z-index: 70;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.music-login-modal {
width: min(460px, 100%);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--surface);
padding: 1rem;
box-shadow: var(--shadow-elevated);
}
.music-login-modal h3 {
margin: 0;
font-family: var(--display);
}
.music-login-modal p {
margin-top: 0.45rem;
}
.music-login-modal-actions {
margin-top: 0.75rem;
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.music-login-modal-actions a,
.music-login-modal-actions button {
border-color: var(--line);
color: var(--ink);
background: var(--surface-soft);
}
.music-login-modal-actions a:hover,
.music-login-modal-actions button:hover {
background: var(--chip-bg);
}
.downloads-grid {
margin-top: 0.9rem;
display: grid;
@@ -737,6 +1048,10 @@ code {
padding-top: 1rem;
}
.shell-with-music-bar {
padding-bottom: calc(6.8rem + env(safe-area-inset-bottom));
}
.hero {
padding: 1.2rem;
}
@@ -757,6 +1072,36 @@ code {
grid-template-columns: 1fr;
}
.weather-hero-main-temp {
font-size: 2rem;
}
.music-dock {
left: 50%;
width: min(560px, calc(100vw - 0.6rem));
z-index: 60;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
padding: 0.55rem 0.65rem calc(0.55rem + env(safe-area-inset-bottom));
gap: 0.45rem;
}
.music-dock-main {
gap: 0.45rem;
}
.music-dock-title-row {
font-size: 0.83rem;
}
.music-player-frame {
height: min(64vh, 620px);
}
.music-dock-actions {
justify-content: flex-end;
}
.layout-row {
grid-template-columns: 1fr;
}
+534 -37
View File
@@ -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} />
</>
)
}