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
+6
View File
@@ -12,6 +12,12 @@ dist
dist-ssr
*.local
# Live admin data — persists on the server via the docker-compose bind mount.
# The seed copy kept in data/admin-content.json is committed separately as
# the image default and is preserved here for the Docker build context only.
# Uncomment the line below if you do NOT want to track live data in git.
# data/admin-content.json
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+8 -2
View File
@@ -18,9 +18,15 @@ RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY server.js ./server.js
COPY data ./data
# Copy seed data to a separate directory so the entrypoint can seed /app/data
# only when no live data exists yet — upgrades never overwrite existing data.
COPY data ./data-seed
RUN mkdir -p /app/data
COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
EXPOSE 4173
CMD ["node", "server.js"]
ENTRYPOINT ["./entrypoint.sh"]
+400
View File
@@ -0,0 +1,400 @@
{
"projects": [
{
"slug": "weather",
"title": "NeCloud Weather",
"domain": "weather.necloud.us",
"url": "https://weather.necloud.us",
"readmeUrl": "https://raw.githubusercontent.com/nmemmert/weather/main/README.md",
"category": "Weather",
"access": "Public",
"ownership": "built",
"status": "Live",
"summary": "Weather dashboard with hourly forecasts, radar, and practical daily details.",
"details": "The homepage highlights Open-Meteo and RainViewer data, current conditions, high/low, daylight windows, and quick bring-along suggestions.",
"features": [
"Hourly forecast",
"Radar view",
"Wind and atmosphere panel"
],
"scanSource": "scanned",
"featured": {
"headline": "Fast weather checks with useful details at a glance.",
"problem": "Most weather tools either feel too basic or too noisy when you only want quick, actionable updates.",
"solution": "NeCloud Weather combines clean current conditions, practical recommendation blocks, and rapid forecast scanning in one view.",
"stack": [
"React",
"TypeScript",
"Open-Meteo",
"RainViewer"
],
"highlights": [
"Location search",
"Hero weather card",
"24-hour timeline",
"Radar shortcut"
],
"nextSteps": [
"Add severe weather alerts",
"Save favorite locations",
"Add sunrise trend charts"
],
"updateNote": "Edit this content in src/data/projects.ts under the weather project."
}
},
{
"slug": "capsule",
"title": "Capsule",
"domain": "capsule.necloud.us",
"url": "https://capsule.necloud.us",
"readmeUrl": "https://raw.githubusercontent.com/nmemmert/capsule/main/README.md",
"category": "Finance",
"access": "Mixed",
"iconUrl": "https://capsule.necloud.us/images/capsule-logo.svg",
"ownership": "built",
"status": "Live",
"summary": "Smart envelope budgeting app focused on planning and income allocation.",
"details": "The site presents guided setup, envelope-based spending controls, and paycheck auto-allocation to keep budgets structured.",
"features": [
"Envelope budgeting",
"Income auto-allocation",
"Offline-ready data"
],
"scanSource": "scanned",
"featured": {
"headline": "A practical budgeting workflow built around envelope control.",
"problem": "Traditional budget tools can feel rigid or overwhelming for everyday spending decisions.",
"solution": "Capsule uses guided setup and smart allocation so users can organize spending with clear envelope categories.",
"stack": [
"React",
"TypeScript",
"Local-first storage"
],
"highlights": [
"Guided setup wizard",
"Envelope categories",
"Auto-allocation rules"
],
"nextSteps": [
"Add recurring bill templates",
"Import transaction feeds",
"Monthly budget snapshots"
],
"updateNote": "Edit this content in src/data/projects.ts under the capsule project."
}
},
{
"slug": "nexusbible",
"title": "Nexus Bible",
"domain": "nexusbible.necloud.us",
"url": "https://nexusbible.necloud.us",
"readmeUrl": "https://raw.githubusercontent.com/nmemmert/nexusbible/main/README.md",
"category": "Study",
"access": "Mixed",
"ownership": "built",
"status": "Live",
"summary": "Bible reader for reading, comparison, and study using Free Use Bible API data.",
"details": "The app exposes navigation, reader controls, bookmarks, and daily-focus tooling to support regular scripture study.",
"features": [
"Reader modes",
"Bookmarks",
"Daily focus workflow"
],
"scanSource": "scanned",
"featured": {
"headline": "Scripture reading designed for depth, rhythm, and clarity.",
"problem": "Switching between apps for reading, comparison, and planning breaks study flow.",
"solution": "Nexus Bible unifies reader tools, bookmarks, and daily focus controls for a single focused study experience.",
"stack": [
"React",
"TypeScript",
"Free Use Bible API"
],
"highlights": [
"Reader mode",
"Passage navigation",
"Bookmarks",
"Daily focus entry points"
],
"nextSteps": [
"Add multi-translation compare view",
"Personal note collections",
"Reading streak tracking"
],
"updateNote": "Edit this content in src/data/projects.ts under the nexusbible project."
}
},
{
"slug": "prayer",
"title": "Haver",
"domain": "prayer.necloud.us",
"url": "https://prayer.necloud.us",
"readmeUrl": "https://raw.githubusercontent.com/nmemmert/haver/main/README.md",
"category": "Faith",
"access": "Mixed",
"ownership": "built",
"status": "Live",
"summary": "Prayer journal app for daily entries, requests, and answered-prayer tracking.",
"details": "Landing content emphasizes a minimalist prayer workflow with dashboards, calendar history, and Google Calendar reminder sync.",
"features": [
"Prayer journal",
"Prayer dashboard",
"Calendar integration"
],
"scanSource": "scanned",
"featured": {
"headline": "A calm daily prayer workflow with meaningful tracking.",
"problem": "Prayer journaling often lives across scattered notes and reminders, making consistency difficult.",
"solution": "Haver centralizes requests, daily entries, answered prayers, and reminder sync into one simple interface.",
"stack": [
"React",
"Firebase",
"Google Calendar integration"
],
"highlights": [
"Prayer composer",
"Answered prayer tracking",
"Calendar view",
"Reminder sync"
],
"nextSteps": [
"Add shared prayer groups",
"Voice prayer entry",
"Weekly summary digest"
],
"updateNote": "Edit this content in src/data/projects.ts under the prayer project."
}
},
{
"slug": "skywatch",
"title": "Skywatch",
"domain": "skywatch.necloud.us",
"url": "https://skywatch.necloud.us",
"category": "Sky and Space",
"access": "Public",
"ownership": "built",
"status": "Live",
"summary": "Skywatch project in your NeCloud stack.",
"details": "Live page content could not be scanned automatically because the endpoint responded with 401; this entry is currently a manual placeholder.",
"features": [
"Manual metadata fallback",
"Live endpoint available via domain"
],
"scanSource": "manual",
"featured": {
"headline": "Skywatch project overview placeholder.",
"problem": "Automated scanning could not access this project because the endpoint returned 401.",
"solution": "This featured page is intentionally editable so you can replace placeholder copy with your exact app details.",
"stack": [
"Update manually"
],
"highlights": [
"Placeholder content",
"Manual metadata fallback"
],
"nextSteps": [
"Add full description",
"Add stack details",
"Add feature highlights"
],
"updateNote": "Edit this content in src/data/projects.ts under the skywatch project."
}
},
{
"slug": "spellinghub",
"title": "Spelling Hub",
"domain": "spellinghub.necloud.us",
"url": "https://spellinghub.necloud.us",
"readmeUrl": "https://raw.githubusercontent.com/nmemmert/spellinghub/main/README.md",
"category": "Education",
"access": "Mixed",
"ownership": "built",
"status": "Live",
"summary": "Modern spelling practice application with account-based learning flows.",
"details": "Homepage messaging presents a clean practice platform with login and registration for user progress.",
"features": [
"Practice workflows",
"User auth",
"Simple home dashboard"
],
"scanSource": "scanned",
"featured": {
"headline": "Focused spelling practice with lightweight account flow.",
"problem": "Many spelling tools are cluttered and do not support structured, repeatable daily practice.",
"solution": "Spelling Hub keeps onboarding simple and centers the product on practical, repeatable learning loops.",
"stack": [
"React",
"TypeScript",
"Auth-backed workflow"
],
"highlights": [
"Login/register flow",
"Practice-first design",
"Simple navigation"
],
"nextSteps": [
"Add custom word lists",
"Teacher/student modes",
"Progress analytics dashboard"
],
"updateNote": "Edit this content in src/data/projects.ts under the spellinghub project."
}
},
{
"slug": "mealie",
"title": "Mealie",
"domain": "mealie.necloud.us",
"url": "https://mealie.necloud.us",
"category": "Self-hosted Tools",
"access": "Mixed",
"ownership": "hosted",
"status": "Live",
"summary": "Self-hosted recipe manager with categories, tags, and kitchen planning tools.",
"details": "Scanned content confirms a full Mealie interface with home feed, recipe browsing, and account login flow.",
"features": [
"Recipe library",
"Tags and categories",
"User login"
],
"scanSource": "scanned",
"featured": {
"headline": "Recipe organization and planning through your self-hosted stack.",
"problem": "Recipe collections get scattered across browser bookmarks and notes without centralized organization.",
"solution": "Your Mealie instance provides structured categories, tags, and searchable recipes for day-to-day meal planning.",
"stack": [
"Mealie",
"Self-hosted deployment"
],
"highlights": [
"Recipe catalog",
"Category and tag views",
"Account login"
],
"nextSteps": [
"Add meal plan snapshots",
"Import bookmark recipes",
"Shopping list workflows"
],
"updateNote": "Edit this content in src/data/projects.ts under the mealie project."
}
},
{
"slug": "music",
"title": "Swing Music",
"domain": "music.necloud.us",
"url": "https://music.necloud.us",
"category": "Self-hosted Tools",
"access": "Mixed",
"ownership": "hosted",
"status": "Live",
"summary": "Self-hosted music streaming interface with folders, playlists, and favorites.",
"details": "Homepage scan identifies Swing Music with library navigation, playlists, favorites, and guest/standard access options.",
"features": [
"Library browsing",
"Playlists",
"Guest mode"
],
"scanSource": "scanned",
"featured": {
"headline": "Swing Music streaming from your own infrastructure.",
"problem": "Centralized music services can limit control over personal libraries and hosting preferences.",
"solution": "Your Swing Music instance provides library browsing, playlists, and favorites with local hosting control.",
"stack": [
"Swing Music",
"Self-hosted deployment"
],
"highlights": [
"Library navigation",
"Playlist support",
"Favorites",
"Guest mode"
],
"nextSteps": [
"Improve mobile now-playing UI",
"Add listening history",
"Playlist sharing links"
],
"updateNote": "Edit this content in src/data/projects.ts under the music project."
}
},
{
"slug": "seafile",
"title": "Seafile Instance",
"domain": "seafile.necloud.us",
"url": "https://seafile.necloud.us",
"category": "Self-hosted Tools",
"access": "Login required",
"iconUrl": "https://seafile.necloud.us/media/custom/mylogo.png",
"ownership": "hosted",
"status": "Live",
"summary": "Your self-hosted Seafile instance for secure sync, sharing, and account-based access.",
"details": "Scanned landing view exposes the Seafile login portal with account signup and password reset features.",
"features": [
"File sync platform",
"Account login",
"Password recovery"
],
"scanSource": "scanned",
"featured": {
"headline": "A private file sync and sharing system you host yourself.",
"problem": "Cloud storage options often reduce control over data location and account governance.",
"solution": "Your Seafile instance gives you direct ownership of file sync, sharing access, and user account management.",
"stack": [
"Seafile",
"Self-hosted deployment"
],
"highlights": [
"Secure login",
"Account management",
"Password reset flow"
],
"nextSteps": [
"Add team spaces",
"Automated backup checks",
"Storage usage dashboard"
],
"updateNote": "Edit this content in src/data/projects.ts under the seafile project."
}
}
],
"siteContent": {
"theme": "sand",
"homeEyebrow": "Siteforge",
"homeTitle": "Sites and Apps I Build",
"homeIntro": "Live links generated from your domain list. Use the Admin panel to update cards and About pages without editing code.",
"projectEyebrow": "Project Details",
"quickTipsTitle": "Quick edit tips",
"quickTipsBody": "Open /admin to update card and featured page content. Changes are saved in your project data file via the API server.",
"downloadsTitle": "Downloads",
"downloadsIntro": "Add shared files, installers, or documents here. Seafile share links work well for this section.",
"downloads": [
{
"title": "Shared File",
"url": "https://seafile.necloud.us",
"description": "Replace this with a direct Seafile share link or another download URL."
}
],
"showSidebar": true,
"showWeatherWidget": true,
"showMusicBar": true,
"homeSections": [
{
"id": "summary",
"placement": "main"
},
{
"id": "projects",
"placement": "main"
},
{
"id": "downloads",
"placement": "sidebar"
},
{
"id": "quickTips",
"placement": "sidebar"
}
]
},
"updatedAt": "2026-03-19T19:19:04.291Z"
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -e
DATA_FILE=/app/data/admin-content.json
SEED_FILE=/app/data-seed/admin-content.json
# Ensure the data directory exists (in case the volume was not mounted)
mkdir -p /app/data
# Only seed from image defaults when no live data file exists yet.
# This runs on first boot or a fresh volume, but never overwrites existing data.
if [ ! -f "$DATA_FILE" ] && [ -f "$SEED_FILE" ]; then
echo "[siteforge] No admin-content.json found. Seeding from image defaults..."
cp "$SEED_FILE" "$DATA_FILE"
fi
exec node server.js
+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} />
</>
)
}