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
+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} />
</>
)
}