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:
@@ -12,6 +12,12 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.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
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
+8
-2
@@ -18,9 +18,15 @@ RUN npm ci --omit=dev
|
|||||||
|
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
COPY server.js ./server.js
|
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
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
COPY entrypoint.sh ./entrypoint.sh
|
||||||
|
RUN chmod +x ./entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 4173
|
EXPOSE 4173
|
||||||
CMD ["node", "server.js"]
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
@@ -7,6 +7,10 @@
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shell-with-music-bar {
|
||||||
|
padding-bottom: calc(6.2rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-panel);
|
border-radius: var(--radius-panel);
|
||||||
@@ -299,6 +303,313 @@ h1 {
|
|||||||
color: var(--ink-muted);
|
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 {
|
.downloads-grid {
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -737,6 +1048,10 @@ code {
|
|||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shell-with-music-bar {
|
||||||
|
padding-bottom: calc(6.8rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
padding: 1.2rem;
|
padding: 1.2rem;
|
||||||
}
|
}
|
||||||
@@ -757,6 +1072,36 @@ code {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.layout-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
+504
-7
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ChangeEvent } from 'react'
|
import type { ChangeEvent } from 'react'
|
||||||
import { Link, Navigate, Route, Routes, useParams } from 'react-router-dom'
|
import { Link, Navigate, Route, Routes, useParams } from 'react-router-dom'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
@@ -9,6 +9,7 @@ import type { Project } from './data/projects'
|
|||||||
|
|
||||||
const THEME_STORAGE_KEY = 'portfolio-theme-v1'
|
const THEME_STORAGE_KEY = 'portfolio-theme-v1'
|
||||||
const ADMIN_CONTENT_API = '/api/admin-content'
|
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'
|
type ThemeName = 'sand' | 'ocean' | 'midnight' | 'forest' | 'sunset' | 'rose' | 'slate' | 'aurora' | 'amethyst'
|
||||||
|
|
||||||
@@ -39,6 +40,8 @@ type SiteContent = {
|
|||||||
downloadsIntro: string
|
downloadsIntro: string
|
||||||
downloads: DownloadItem[]
|
downloads: DownloadItem[]
|
||||||
showSidebar: boolean
|
showSidebar: boolean
|
||||||
|
showWeatherWidget: boolean
|
||||||
|
showMusicBar: boolean
|
||||||
homeSections: HomeSectionConfig[]
|
homeSections: HomeSectionConfig[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +80,8 @@ const defaultSiteContent: SiteContent = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
showSidebar: false,
|
showSidebar: false,
|
||||||
|
showWeatherWidget: true,
|
||||||
|
showMusicBar: false,
|
||||||
homeSections: DEFAULT_HOME_SECTIONS,
|
homeSections: DEFAULT_HOME_SECTIONS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +98,168 @@ type ScanResult = {
|
|||||||
source: string
|
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[] {
|
function splitLines(value: string): string[] {
|
||||||
return value
|
return value
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -322,6 +489,14 @@ async function fetchAdminContent(): Promise<
|
|||||||
typeof payload.siteContent.showSidebar === 'boolean'
|
typeof payload.siteContent.showSidebar === 'boolean'
|
||||||
? payload.siteContent.showSidebar
|
? payload.siteContent.showSidebar
|
||||||
: defaultSiteContent.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),
|
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.')
|
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({
|
function HomePage({
|
||||||
projectList,
|
projectList,
|
||||||
siteContent,
|
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) => {
|
const renderHomeSection = (sectionId: HomeSectionId) => {
|
||||||
if (sectionId === 'summary') {
|
if (sectionId === 'summary') {
|
||||||
return (
|
return (
|
||||||
@@ -668,7 +1083,7 @@ function HomePage({
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className={`shell${siteContent.showMusicBar ? ' shell-with-music-bar' : ''}`}>
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<p className="eyebrow">{siteContent.homeEyebrow}</p>
|
<p className="eyebrow">{siteContent.homeEyebrow}</p>
|
||||||
<h1>{siteContent.homeTitle}</h1>
|
<h1>{siteContent.homeTitle}</h1>
|
||||||
@@ -700,16 +1115,65 @@ function HomePage({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{siteContent.showSidebar && sidebarSections.length > 0 ? (
|
{siteContent.showSidebar ? (
|
||||||
<div className="home-layout">
|
<div className="home-layout">
|
||||||
<div className="home-main">{mainSections.map((section) => renderHomeSection(section.id))}</div>
|
<div className="home-main">{mainSections.map((section) => renderHomeSection(section.id))}</div>
|
||||||
<aside className="home-sidebar">
|
<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))}
|
{sidebarSections.map((section) => renderHomeSection(section.id))}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
orderedSections.map((section) => renderHomeSection(section.id))
|
orderedSections.map((section) => renderHomeSection(section.id))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -770,7 +1234,7 @@ function ProjectPage({
|
|||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className={`shell${siteContent.showMusicBar ? ' shell-with-music-bar' : ''}`}>
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<p className="eyebrow">Project</p>
|
<p className="eyebrow">Project</p>
|
||||||
<h1>Project not found</h1>
|
<h1>Project not found</h1>
|
||||||
@@ -784,7 +1248,7 @@ function ProjectPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className={`shell${siteContent.showMusicBar ? ' shell-with-music-bar' : ''}`}>
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<p className="eyebrow">{siteContent.projectEyebrow}</p>
|
<p className="eyebrow">{siteContent.projectEyebrow}</p>
|
||||||
<h1>{project.title}</h1>
|
<h1>{project.title}</h1>
|
||||||
@@ -1033,7 +1497,7 @@ function AdminPage({
|
|||||||
|
|
||||||
if (!draft) {
|
if (!draft) {
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className={`shell${siteContent.showMusicBar ? ' shell-with-music-bar' : ''}`}>
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<p className="eyebrow">Admin</p>
|
<p className="eyebrow">Admin</p>
|
||||||
<h1>No projects available</h1>
|
<h1>No projects available</h1>
|
||||||
@@ -1054,7 +1518,7 @@ function AdminPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className={`shell${siteContent.showMusicBar ? ' shell-with-music-bar' : ''}`}>
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<p className="eyebrow">Admin</p>
|
<p className="eyebrow">Admin</p>
|
||||||
<h1>Edit Cards and About Pages</h1>
|
<h1>Edit Cards and About Pages</h1>
|
||||||
@@ -1289,6 +1753,36 @@ function AdminPage({
|
|||||||
/>
|
/>
|
||||||
<span>Enable sidebar on the home page</span>
|
<span>Enable sidebar on the home page</span>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div className="layout-manager">
|
<div className="layout-manager">
|
||||||
@@ -2090,6 +2584,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
@@ -2120,6 +2615,8 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<SiteWideMusicDock projectList={managedProjects} showMusicBar={siteContent.showMusicBar} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user