From 1531c7a9ee172bee4c84d1a08035ba5dc20a7a83 Mon Sep 17 00:00:00 2001 From: Emeraldglow Date: Sat, 17 Oct 2026 20:50:29 +0100 Subject: [PATCH 1/4] feat: add favicon, web manifest, OG image and social metadata --- src/app/icon.svg | 9 ++++ src/app/layout.tsx | 16 ++++++- src/app/manifest.ts | 32 ++++++++++++++ src/app/opengraph-image.tsx | 85 +++++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 src/app/icon.svg create mode 100644 src/app/manifest.ts create mode 100644 src/app/opengraph-image.tsx diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000..d0656bb --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 484eb56..59378d7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -19,6 +19,10 @@ const geistMono = Geist_Mono({ const DESCRIPTION = 'Recurring, streaming donations for verified NGOs on Stellar.'; export const metadata: Metadata = { + // metadataBase is required for Next.js to resolve relative URLs used in + // openGraph.images and twitter.images into fully-qualified URLs for crawlers. + // Falls back to the local dev origin; set NEXT_PUBLIC_APP_URL in production. + metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3001'), title: { default: 'StreamGive', // Child routes set just their own segment (e.g. "Explore NGOs") and @@ -27,15 +31,25 @@ export const metadata: Metadata = { template: '%s — StreamGive', }, description: DESCRIPTION, + // Browser toolbar / PWA chrome colour (matches the manifest and the logo). + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#0d9488' }, + { media: '(prefers-color-scheme: dark)', color: '#0f172a' }, + ], openGraph: { title: 'StreamGive', description: DESCRIPTION, type: 'website', + siteName: 'StreamGive', + // /opengraph-image is generated by src/app/opengraph-image.tsx. + images: [{ url: '/opengraph-image', width: 1200, height: 630, alt: 'StreamGive' }], }, twitter: { - card: 'summary', + // summary_large_image renders the OG image as a large card, not a thumbnail. + card: 'summary_large_image', title: 'StreamGive', description: DESCRIPTION, + images: ['/opengraph-image'], }, }; diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..300b193 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,32 @@ +import type { MetadataRoute } from 'next'; + +/** + * Web app manifest — served at /manifest.webmanifest. + * Enables "Add to Home Screen" on mobile and provides browser-level theming. + * Next.js wires up the automatically when this file exists. + */ +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'StreamGive', + short_name: 'StreamGive', + description: 'Recurring, streaming donations for verified NGOs on Stellar.', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#0d9488', + icons: [ + { + src: '/icon.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any', + }, + { + src: '/icon.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'maskable', + }, + ], + }; +} diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000..52862a1 --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -0,0 +1,85 @@ +import { ImageResponse } from 'next/og'; + +export const runtime = 'edge'; +export const alt = 'StreamGive — Recurring streaming donations for verified NGOs on Stellar'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +/** + * Default Open Graph / Twitter social-share image. + * Generated at request time via the Edge runtime — no static PNG to commit. + * Next.js serves it at /opengraph-image and wires it into automatically + * when `metadataBase` is set in the root layout. + */ +export default function OgImage() { + return new ImageResponse( + ( +
+ {/* Logo mark */} +
+ + + + +
+ + {/* Wordmark */} +
+ StreamGive +
+ + {/* Tagline */} +
+ Recurring, streaming donations for verified NGOs on Stellar. +
+
+ ), + { ...size }, + ); +} From 9134ef0edc20f5ed24ce29eac91ada21e6afebc7 Mon Sep 17 00:00:00 2001 From: Emeraldglow Date: Sat, 17 Oct 2026 20:52:22 +0100 Subject: [PATCH 2/4] feat: add system option to theme toggle with OS listener and tab sync --- src/components/layout/ThemeToggle.tsx | 137 +++++++++++++++++++++++--- src/lib/theme.ts | 51 ++++++++-- 2 files changed, 164 insertions(+), 24 deletions(-) diff --git a/src/components/layout/ThemeToggle.tsx b/src/components/layout/ThemeToggle.tsx index d2729d8..5d84272 100644 --- a/src/components/layout/ThemeToggle.tsx +++ b/src/components/layout/ThemeToggle.tsx @@ -2,7 +2,17 @@ import { useEffect, useState } from 'react'; -import { applyTheme, getAppliedTheme, type Theme } from '@/lib/theme'; +import { + applyThemePreference, + getStoredPreference, + getSystemTheme, + type Theme, + type ThemePreference, +} from '@/lib/theme'; + +// --------------------------------------------------------------------------- +// Icons +// --------------------------------------------------------------------------- function SunIcon() { return ( @@ -39,40 +49,135 @@ function MoonIcon() { ); } +/** Monitor icon — represents "follow system preference". */ +function SystemIcon() { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CYCLE: ThemePreference[] = ['light', 'dark', 'system']; + +function nextPreference(current: ThemePreference): ThemePreference { + return CYCLE[(CYCLE.indexOf(current) + 1) % CYCLE.length]; +} + +const LABELS: Record = { + light: 'Switch to dark mode', + dark: 'Switch to system mode', + system: 'Switch to light mode', +}; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + /** - * Overrides the system color scheme. The class it toggles is applied to - * before hydration by THEME_INIT_SCRIPT (see src/lib/theme.ts and - * the root layout), so `theme` only needs to read that already-applied - * class rather than re-deriving it here. + * Three-way theme toggle: light → dark → system (follow OS) → light … * - * The icon renders only once mounted: rendering it immediately from - * `getAppliedTheme()` would read a class the server-rendered markup - * doesn't have, which is a hydration mismatch. Server and first client - * render both render nothing here; the icon fills in right after. + * Behaviour: + * - Cycles through the three preferences on click. + * - In 'system' mode, a matchMedia listener reacts immediately when the OS + * theme changes at sunset/sunrise without requiring a page reload. + * - A 'storage' event listener keeps the toggle in sync across browser tabs: + * opening a new tab and changing the preference there will update every + * other open tab automatically. + * - The icon is suppressed until mounted to avoid a server/client hydration + * mismatch (the server doesn't know the user's stored preference). */ export function ThemeToggle() { - const [theme, setTheme] = useState('light'); + const [preference, setPreference] = useState('system'); const [mounted, setMounted] = useState(false); + // On mount: read the stored preference and start listening for cross-tab + // changes and OS theme changes. useEffect(() => { - setTheme(getAppliedTheme()); + setPreference(getStoredPreference()); setMounted(true); + + // Cross-tab sync: another tab wrote a new value (or cleared it) in + // localStorage — mirror it here without reloading the page. + function handleStorage(event: StorageEvent): void { + if (event.key !== 'theme') return; + const incoming: ThemePreference = + event.newValue === 'dark' || event.newValue === 'light' ? event.newValue : 'system'; + // Apply visually (the other tab already wrote to storage, so we only + // need to update the DOM and local state). + applyThemePreference(incoming); + setPreference(incoming); + } + + window.addEventListener('storage', handleStorage); + return () => window.removeEventListener('storage', handleStorage); }, []); + // OS theme listener — only active while the user is in 'system' mode. + // When the OS flips (e.g. macOS auto dark mode at sunset) we re-apply so + // the page updates without waiting for a reload. + useEffect(() => { + if (!mounted) return; + if (preference !== 'system') return; + + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + + function handleChange(): void { + // Re-resolve system theme against the new OS setting. + applyThemePreference('system'); + // No setPreference needed — the preference is still 'system'. + } + + mq.addEventListener('change', handleChange); + return () => mq.removeEventListener('change', handleChange); + }, [mounted, preference]); + function toggle(): void { - const next: Theme = theme === 'dark' ? 'light' : 'dark'; - applyTheme(next); - setTheme(next); + const next = nextPreference(preference); + applyThemePreference(next); + setPreference(next); } + // Derive the visual theme for rendering (system resolves to the current OS + // setting, used only to decide which icon to show when in system mode). + const resolvedTheme: Theme = mounted + ? preference === 'system' + ? getSystemTheme() + : preference + : 'light'; + + const icon = + preference === 'system' ? ( + + ) : resolvedTheme === 'dark' ? ( + + ) : ( + + ); + return ( ); } diff --git a/src/lib/theme.ts b/src/lib/theme.ts index bb84640..11d5e42 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,3 +1,7 @@ +/** 'system' means "follow prefers-color-scheme" — no stored override. */ +export type ThemePreference = 'light' | 'dark' | 'system'; + +/** The two visual outcomes (what actually gets applied to ). */ export type Theme = 'light' | 'dark'; export const THEME_STORAGE_KEY = 'theme'; @@ -10,16 +14,25 @@ export const THEME_STORAGE_KEY = 'theme'; * * Kept as a plain string (not a shared function) because it has to run as * a standalone