diff --git a/.gitignore b/.gitignore index a547bf3..2517be7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile index 9f1676b..05bf735 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/data/admin-content.json b/data/admin-content.json new file mode 100644 index 0000000..8a0afc2 --- /dev/null +++ b/data/admin-content.json @@ -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" +} \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..22ac360 --- /dev/null +++ b/entrypoint.sh @@ -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 diff --git a/src/App.css b/src/App.css index b47ed3c..3f38ad9 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/App.tsx b/src/App.tsx index 9f26245..7da52ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = { + 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 { + 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 { + 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 { 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 ( +
+
+ +
+
+ +
+ +
+ {hasMusicPlayerSession ? ( +