Add photo banner heroes to organize and actions
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
@@ -9,8 +9,19 @@ interface HeroAtmosphereProps {
|
||||
* `aTag`. The same seed always picks the same hue from
|
||||
* {@link HOPE_PALETTE}. Pass `null`/`undefined` when no campaign is
|
||||
* spotlit and the atmosphere will default to the first palette entry.
|
||||
*
|
||||
* Ignored when `hue` is provided. Optional only when `hue` is given;
|
||||
* the campaign hero still depends on seed-based selection.
|
||||
*/
|
||||
seed: string | undefined | null;
|
||||
seed?: string | undefined | null;
|
||||
/**
|
||||
* Explicit hue override. When set, the atmosphere skips seed-based
|
||||
* palette selection and crossfades whenever this hue changes. Use this
|
||||
* when the page already rotates hues itself (e.g. the Organize hero
|
||||
* cycles a cool palette every few seconds) or when the seed-derived
|
||||
* warm palette is the wrong vibe.
|
||||
*/
|
||||
hue?: HopeHue;
|
||||
/** Extra classes for the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
@@ -40,13 +51,13 @@ const FADE_MS = 1500;
|
||||
* the old one, matching the timing of the photo crossfade so the whole
|
||||
* hero blooms together.
|
||||
*/
|
||||
export function HeroAtmosphere({ seed, className }: HeroAtmosphereProps) {
|
||||
export function HeroAtmosphere({ seed, hue: hueOverride, className }: HeroAtmosphereProps) {
|
||||
const idRef = useRef(0);
|
||||
const [layers, setLayers] = useState<AtmosphereLayer[]>([]);
|
||||
const lastHueRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hue = hopeHueFor(seed ?? null);
|
||||
const hue = hueOverride ?? hopeHueFor(seed ?? null);
|
||||
if (hue.name === lastHueRef.current) return;
|
||||
lastHueRef.current = hue.name;
|
||||
|
||||
@@ -59,7 +70,7 @@ export function HeroAtmosphere({ seed, className }: HeroAtmosphereProps) {
|
||||
setLayers((prev) => prev.filter((l) => l.id === id));
|
||||
}, FADE_MS + 50);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [seed]);
|
||||
}, [seed, hueOverride]);
|
||||
|
||||
return (
|
||||
<div className={cn('absolute inset-0 pointer-events-none', className)} aria-hidden="true">
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Default rotation: photos from past World Liberty Congress events, used
|
||||
* by the Organize / Communities hero. Each image lives in
|
||||
* `/public/hero/wlc-N.webp` and is referenced by absolute path so the
|
||||
* browser caches them across navigations and `<link rel="preload">` can
|
||||
* pick them up if we ever add it.
|
||||
*
|
||||
* Other pages can pass their own list via the `images` prop (e.g. the
|
||||
* Actions hero rotates through the action cover gallery).
|
||||
*/
|
||||
const DEFAULT_BANNER_IMAGES: readonly string[] = [
|
||||
'/hero/wlc-1.webp',
|
||||
'/hero/wlc-2.webp',
|
||||
'/hero/wlc-3.webp',
|
||||
];
|
||||
|
||||
interface HeroBannerProps {
|
||||
/**
|
||||
* Ordered list of image URLs to rotate through. Defaults to the
|
||||
* Organize hero's WLC photos. Pass at least one URL; if the list has
|
||||
* a single entry the banner renders it as a still image.
|
||||
*/
|
||||
images?: readonly string[];
|
||||
/** Optional className for the outer wrapper. */
|
||||
className?: string;
|
||||
/**
|
||||
* Time between crossfades, in ms. Defaults to 7s — long enough for
|
||||
* faces to register, short enough that the page never feels static.
|
||||
*/
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
interface Layer {
|
||||
/** Stable key so React doesn't tear the layer down mid-transition. */
|
||||
id: number;
|
||||
/** URL of the image rendered on this layer. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
const FADE_MS = 1500;
|
||||
|
||||
/**
|
||||
* Full-bleed crossfading banner of event photos. Modelled after
|
||||
* {@link CampaignHeroBackground}: each new image gets its own stacked
|
||||
* layer and we toggle opacity to crossfade. The previous layer unmounts
|
||||
* after the fade completes so the DOM never accumulates more than two
|
||||
* `<img>` elements.
|
||||
*
|
||||
* The component is self-driving — it advances through `images` on a
|
||||
* fixed interval and stops the timer when `prefers-reduced-motion` is
|
||||
* set, leaving the first image as a static banner.
|
||||
*/
|
||||
export function HeroBanner({
|
||||
images = DEFAULT_BANNER_IMAGES,
|
||||
className,
|
||||
intervalMs = 7_000,
|
||||
}: HeroBannerProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const idRef = useRef(0);
|
||||
const [layers, setLayers] = useState<Layer[]>(() =>
|
||||
images.length > 0 ? [{ id: 0, url: images[0] }] : [],
|
||||
);
|
||||
|
||||
// Honor the user's reduced-motion preference. We freeze the rotation
|
||||
// and let the first image act as a still banner.
|
||||
const reducedMotion = useRef(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMotion.current = mq.matches;
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
reducedMotion.current = e.matches;
|
||||
};
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Advance the index on a fixed interval. We deliberately keep this
|
||||
// separate from the layer effect below so swapping the interval (e.g.
|
||||
// for tests) doesn't force a full crossfade restart.
|
||||
useEffect(() => {
|
||||
if (images.length <= 1) return;
|
||||
const id = window.setInterval(() => {
|
||||
if (reducedMotion.current) return;
|
||||
setIndex((i) => (i + 1) % images.length);
|
||||
}, intervalMs);
|
||||
return () => window.clearInterval(id);
|
||||
}, [images, intervalMs]);
|
||||
|
||||
// Whenever the active index changes, push a new layer on top. Old
|
||||
// layers are reaped after the crossfade completes.
|
||||
useEffect(() => {
|
||||
if (images.length === 0) return;
|
||||
const url = images[index % images.length];
|
||||
const id = ++idRef.current;
|
||||
setLayers((prev) => [...prev, { id, url }]);
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLayers((prev) => prev.filter((l) => l.id === id));
|
||||
}, FADE_MS + 50);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [index, images]);
|
||||
|
||||
// Preload the next image during idle time so the next crossfade
|
||||
// doesn't blink. Cheap — the browser will dedupe with the eventual
|
||||
// <img> request once the layer mounts.
|
||||
useEffect(() => {
|
||||
if (images.length <= 1) return;
|
||||
const next = images[(index + 1) % images.length];
|
||||
const img = new Image();
|
||||
img.decoding = 'async';
|
||||
img.src = next;
|
||||
}, [index, images]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('absolute inset-0 overflow-hidden', className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{layers.map((layer, i) => {
|
||||
const isTop = i === layers.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
opacity: isTop ? 1 : 0,
|
||||
transition: `opacity ${FADE_MS}ms ease-in-out`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={layer.url}
|
||||
alt=""
|
||||
// First image eager so the hero never starts empty; the
|
||||
// rest can wait until they're scheduled to come in.
|
||||
loading={layer.id === 0 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
// Subtle slow pan — same keyframe used by the campaigns
|
||||
// hero — so each photo feels alive on its turn instead of
|
||||
// sitting frozen for 7 seconds.
|
||||
className="absolute inset-0 w-full h-full object-cover hero-pan-left"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -111,3 +111,59 @@ export function hopeHueFor(seed: string | undefined | null): HopeHue {
|
||||
if (!seed) return HOPE_PALETTE[0];
|
||||
return HOPE_PALETTE[hashSeed(seed) % HOPE_PALETTE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cool-hued sibling palette used on the Organize / Communities hero.
|
||||
* Where {@link HOPE_PALETTE} reads as dawn / golden hour, this one reads
|
||||
* as water, leaves, and twilight — communal, calm, "we're in this
|
||||
* together" rather than "new day, new campaign".
|
||||
*
|
||||
* Same shape as {@link HopeHue} so callers can pass entries from either
|
||||
* palette interchangeably into {@link HeroAtmosphere} or
|
||||
* {@link HeroHands}.
|
||||
*/
|
||||
export const COOL_PALETTE: readonly HopeHue[] = [
|
||||
// Teal — equal parts blue and green, the canonical "hands holding hands"
|
||||
// color. Anchors the rotation.
|
||||
{
|
||||
name: 'teal',
|
||||
scrim: 'hsl(180 70% 45% / 0.32)',
|
||||
glow: 'hsl(178 85% 52% / 0.5)',
|
||||
rim: 'hsl(176 90% 65% / 0.55)',
|
||||
},
|
||||
// Ocean — deeper, more blue. Reads as depth / trust.
|
||||
{
|
||||
name: 'ocean',
|
||||
scrim: 'hsl(206 75% 48% / 0.32)',
|
||||
glow: 'hsl(204 90% 58% / 0.5)',
|
||||
rim: 'hsl(202 95% 70% / 0.55)',
|
||||
},
|
||||
// Forest — leans green. Growth / continuity / land.
|
||||
{
|
||||
name: 'forest',
|
||||
scrim: 'hsl(158 65% 42% / 0.32)',
|
||||
glow: 'hsl(156 80% 50% / 0.5)',
|
||||
rim: 'hsl(154 85% 62% / 0.55)',
|
||||
},
|
||||
// Lagoon — bright blue-green, joyful and warm-for-cool.
|
||||
{
|
||||
name: 'lagoon',
|
||||
scrim: 'hsl(186 75% 48% / 0.32)',
|
||||
glow: 'hsl(184 90% 56% / 0.5)',
|
||||
rim: 'hsl(182 95% 68% / 0.55)',
|
||||
},
|
||||
// Mint — softer green-leaning hue. Gentle, hopeful.
|
||||
{
|
||||
name: 'mint',
|
||||
scrim: 'hsl(164 60% 50% / 0.3)',
|
||||
glow: 'hsl(162 75% 58% / 0.48)',
|
||||
rim: 'hsl(160 85% 70% / 0.55)',
|
||||
},
|
||||
// Cobalt — pure cool blue, the cold end of the rotation.
|
||||
{
|
||||
name: 'cobalt',
|
||||
scrim: 'hsl(218 75% 52% / 0.32)',
|
||||
glow: 'hsl(216 90% 62% / 0.5)',
|
||||
rim: 'hsl(214 95% 72% / 0.55)',
|
||||
},
|
||||
];
|
||||
|
||||
+148
-6
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
@@ -14,12 +14,14 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { getAllCountries, getGeoDisplayName, countryCodeToFlag } from '@/lib/countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { getDisplayName } from '@/lib/genUserName';
|
||||
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { DEFAULT_ACTION_COVERS, DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CreateActionDialog } from '@/components/CreateActionDialog';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -301,8 +303,10 @@ export default function ActionsPage() {
|
||||
});
|
||||
|
||||
// Drive the global FAB from the canonical layout API so we get the same
|
||||
// circular Plus button every other page has.
|
||||
// circular Plus button every other page has. `noMaxWidth: true` lets
|
||||
// the hero banner span the full content column.
|
||||
useLayoutOptions({
|
||||
noMaxWidth: true,
|
||||
showFAB: !!user,
|
||||
onFabClick: () => setCreateOpen(true),
|
||||
});
|
||||
@@ -460,9 +464,13 @@ export default function ActionsPage() {
|
||||
|
||||
return (
|
||||
<main className="pb-16 sidebar:pb-0">
|
||||
<PageHeader title="Actions" icon={<Megaphone className="size-5" />} />
|
||||
<ActionsHero
|
||||
actionCount={actions?.length ?? 0}
|
||||
canCreate={!!user}
|
||||
onCreateAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="px-4 max-w-2xl mx-auto">
|
||||
<div className="px-4 max-w-2xl mx-auto pt-6">
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(4)].map((_, i) => <ActionSkeleton key={i} />)}
|
||||
@@ -566,6 +574,140 @@ export default function ActionsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Hero
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Banner rotation for the Actions hero. We reuse the same gallery the
|
||||
* action create form offers as a default cover, so the hero feels
|
||||
* thematically continuous with the cards below — readers see the
|
||||
* vocabulary of imagery they'll be picking from when they create their
|
||||
* own action. Filtered to a single source extension where multiple
|
||||
* exist isn't necessary; the browser handles `.png` / `.jpeg` mixed.
|
||||
*/
|
||||
const ACTIONS_HERO_IMAGES: readonly string[] = DEFAULT_ACTION_COVERS.map(
|
||||
(c) => c.url,
|
||||
);
|
||||
|
||||
interface ActionsHeroProps {
|
||||
/** Number of actions currently loaded — fuels the live stat pill. */
|
||||
actionCount: number;
|
||||
/** When true, the primary CTA opens the create-action dialog. */
|
||||
canCreate: boolean;
|
||||
/** Fires when the user clicks the primary CTA. */
|
||||
onCreateAction: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo-led hero for the Actions index. Same structural recipe as the
|
||||
* Organize hero (rotating banner + atmospheric tint + scrims + overlay
|
||||
* copy + glassy CTA), but tuned for action's "dawn / golden hour" vibe:
|
||||
* uses {@link HOPE_PALETTE} instead of the cool palette so the warm
|
||||
* hues land on top of the protest photography rather than competing
|
||||
* with it.
|
||||
*/
|
||||
function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProps) {
|
||||
// Cycle through warm hues on the same cadence as the banner so the
|
||||
// whole hero feels like one coordinated moment instead of two
|
||||
// unrelated rotations.
|
||||
const [hueIndex, setHueIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
|
||||
}, 9_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
const activeHue = HOPE_PALETTE[hueIndex];
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-border bg-secondary/30">
|
||||
{/* Rotating photo banner — uses the same gallery offered as default
|
||||
action covers, so the hero previews the visual vocabulary of
|
||||
the cards below. Crossfades every 7s and pans slowly between
|
||||
cuts. */}
|
||||
<HeroBanner images={ACTIONS_HERO_IMAGES} />
|
||||
|
||||
{/* Warm atmosphere — golden-hour scrim + radial glow + sunrise rim.
|
||||
Drives the hue cycle so the photo never feels static even when
|
||||
a single banner image is on screen. */}
|
||||
<HeroAtmosphere hue={activeHue} />
|
||||
|
||||
{/* Top scrim so the headline stays legible across every photo in
|
||||
the rotation. */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-64 sm:h-80 pointer-events-none bg-gradient-to-b from-black/70 via-black/40 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Bottom scrim so the stat pill + CTA stay legible. */}
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-56 sm:h-72 pointer-events-none bg-gradient-to-t from-black/70 via-black/35 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-10 sm:py-12 lg:py-14 min-h-[380px] sm:min-h-[420px] lg:min-h-[460px] flex flex-col items-center text-center">
|
||||
<div className="relative space-y-3 max-w-3xl">
|
||||
<p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/85 drop-shadow">
|
||||
Act
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
|
||||
Small acts,
|
||||
<br className="sm:hidden" /> real change.
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-white/85 max-w-2xl mx-auto drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]">
|
||||
Photograph protests, make art, gather information, organize on the ground.
|
||||
Get paid in Bitcoin when your work moves the needle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-[100px] sm:min-h-[120px]" aria-hidden="true" />
|
||||
|
||||
{/* Live stat pill. Mirrors the Communities hero's pattern but
|
||||
only carries a single fact — the current action count —
|
||||
so it stays calm and the headline does the heavy lifting. */}
|
||||
<div
|
||||
className="relative w-full max-w-md mx-auto rounded-full bg-background/55 backdrop-blur-xl backdrop-saturate-150 border border-white/20 dark:border-white/10 px-5 py-3 shadow-lg shadow-amber-500/10"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Megaphone className="size-5 text-primary shrink-0" aria-hidden />
|
||||
<span className="text-sm sm:text-base font-semibold tracking-tight">
|
||||
{actionCount.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground line-clamp-1">
|
||||
{actionCount === 1 ? 'action in motion right now' : 'actions in motion right now'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={onCreateAction}
|
||||
disabled={!canCreate}
|
||||
className={cn(
|
||||
'relative rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px]',
|
||||
'bg-gradient-to-br from-white/14 via-amber-100/10 to-rose-100/10 hover:from-white/20 hover:via-amber-100/14 hover:to-rose-100/14',
|
||||
'backdrop-blur-xl backdrop-saturate-150',
|
||||
'border border-white/25 hover:border-white/35',
|
||||
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),0_10px_28px_-12px_hsl(24_85%_45%/0.4)]',
|
||||
'hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),0_12px_32px_-10px_hsl(24_85%_45%/0.5)]',
|
||||
'motion-safe:transition-colors motion-safe:duration-200',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
)}
|
||||
aria-label={canCreate ? 'Create action' : 'Log in to create an action'}
|
||||
>
|
||||
<Plus className="mr-2" />
|
||||
Create action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionSection({
|
||||
items, total, visible, showAll, onToggle, isExpired,
|
||||
}: {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useInView } from 'react-intersection-observer';
|
||||
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroGlobe, type GlobeMarkerKind } from '@/components/HeroGlobe';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
@@ -22,7 +22,7 @@ import { HorizontalScroll } from '@/components/discovery/HorizontalScroll';
|
||||
import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
|
||||
import { SectionHeader } from '@/components/discovery/SectionHeader';
|
||||
import { COMMUNITY_DEFINITION_KIND, EMPTY_MODERATION } from '@/lib/communityUtils';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { COOL_PALETTE } from '@/lib/hopePalette';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
@@ -153,14 +153,6 @@ interface CommunitiesHeroProps {
|
||||
onCreateCommunity: () => void;
|
||||
}
|
||||
|
||||
interface GlobeMarker {
|
||||
key: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
kind: GlobeMarkerKind;
|
||||
}
|
||||
|
||||
interface TickerStat {
|
||||
id: string;
|
||||
value: string;
|
||||
@@ -172,36 +164,16 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
|
||||
const { data: communities } = useDiscoverCommunities({ limit: 24 });
|
||||
const { data: activityByCountry } = useGlobalActivity();
|
||||
const { data: donations, isLoading: donationsLoading } = useGlobalDonations();
|
||||
const [hueIndex, setHueIndex] = useState(1);
|
||||
const [hueIndex, setHueIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
|
||||
setHueIndex((i) => (i + 1) % COOL_PALETTE.length);
|
||||
}, 9_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const activeHue = HOPE_PALETTE[hueIndex];
|
||||
const markers = useMemo<GlobeMarker[]>(() => {
|
||||
const scatter: Array<{ lat: number; lng: number }> = [
|
||||
{ lat: 40.7, lng: -74.0 },
|
||||
{ lat: -23.5, lng: -46.6 },
|
||||
{ lat: 51.5, lng: -0.1 },
|
||||
{ lat: -1.3, lng: 36.8 },
|
||||
{ lat: 35.7, lng: 139.7 },
|
||||
{ lat: 28.6, lng: 77.2 },
|
||||
{ lat: -33.9, lng: 151.2 },
|
||||
{ lat: 19.4, lng: -99.1 },
|
||||
];
|
||||
|
||||
return (communities ?? []).slice(0, scatter.length).map((community, index) => ({
|
||||
key: community.aTag,
|
||||
lat: scatter[index].lat,
|
||||
lng: scatter[index].lng,
|
||||
label: community.name,
|
||||
kind: 'community',
|
||||
}));
|
||||
}, [communities]);
|
||||
const activeHue = COOL_PALETTE[hueIndex];
|
||||
|
||||
const stats = useMemo<TickerStat[]>(() => {
|
||||
const items: TickerStat[] = [];
|
||||
@@ -248,27 +220,33 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-border bg-secondary/30">
|
||||
<HeroAtmosphere seed={`communities-${activeHue.name}`} />
|
||||
{/* Rotating photo banner — World Liberty Congress events. Crossfades
|
||||
every 7s and pans slowly between cuts. Sits at the bottom of the
|
||||
stack so atmosphere, scrims, and content layer above it. */}
|
||||
<HeroBanner />
|
||||
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center">
|
||||
<div className="pointer-events-auto opacity-90">
|
||||
<HeroGlobe
|
||||
markers={markers}
|
||||
hue={activeHue}
|
||||
className="aspect-square max-w-none drop-shadow-2xl"
|
||||
style={{ width: 'clamp(440px, 62dvw, 720px)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Cool atmosphere — blue/green hues rotate independently of the
|
||||
banner cycle. The explicit `hue` prop overrides the warm
|
||||
seed-derived default HeroAtmosphere uses on campaign pages. The
|
||||
screen-blend gradients tint the photo without flattening it. */}
|
||||
<HeroAtmosphere hue={activeHue} />
|
||||
|
||||
{/* Top scrim so the headline stays legible regardless of which
|
||||
photo is currently on top. */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-72 sm:h-80 pointer-events-none bg-gradient-to-b from-black/55 via-black/25 to-transparent"
|
||||
className="absolute inset-x-0 top-0 h-64 sm:h-80 pointer-events-none bg-gradient-to-b from-black/70 via-black/40 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-12 sm:py-16 lg:py-20 min-h-[560px] sm:min-h-[640px] lg:min-h-[680px] flex flex-col items-center text-center">
|
||||
{/* Bottom scrim so the stat pill + CTA stay legible across photos. */}
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-56 sm:h-72 pointer-events-none bg-gradient-to-t from-black/70 via-black/35 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-10 sm:py-12 lg:py-14 min-h-[380px] sm:min-h-[420px] lg:min-h-[460px] flex flex-col items-center text-center">
|
||||
<div className="relative space-y-3 max-w-3xl">
|
||||
<p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/80 drop-shadow">
|
||||
<p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/85 drop-shadow">
|
||||
Organize
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
|
||||
@@ -280,10 +258,10 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-[180px] sm:min-h-[220px]" aria-hidden="true" />
|
||||
<div className="flex-1 min-h-[100px] sm:min-h-[120px]" aria-hidden="true" />
|
||||
|
||||
<div
|
||||
className="relative w-full max-w-md mx-auto rounded-full bg-background/55 backdrop-blur-xl backdrop-saturate-150 border border-white/20 dark:border-white/10 px-5 py-3 shadow-lg shadow-amber-500/10"
|
||||
className="relative w-full max-w-md mx-auto rounded-full bg-background/55 backdrop-blur-xl backdrop-saturate-150 border border-white/20 dark:border-white/10 px-5 py-3 shadow-lg shadow-teal-500/10"
|
||||
aria-live="polite"
|
||||
>
|
||||
{currentStat ? (
|
||||
@@ -316,18 +294,18 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={onCreateCommunity}
|
||||
className={cn(
|
||||
'relative rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px]',
|
||||
'bg-gradient-to-br from-white/14 via-amber-100/10 to-rose-100/10 hover:from-white/20 hover:via-amber-100/14 hover:to-rose-100/14',
|
||||
'bg-gradient-to-br from-white/14 via-cyan-100/10 to-emerald-100/10 hover:from-white/20 hover:via-cyan-100/14 hover:to-emerald-100/14',
|
||||
'backdrop-blur-xl backdrop-saturate-150',
|
||||
'border border-white/25 hover:border-white/35',
|
||||
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),0_10px_28px_-12px_hsl(24_85%_45%/0.4)]',
|
||||
'hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),0_12px_32px_-10px_hsl(24_85%_45%/0.5)]',
|
||||
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),0_10px_28px_-12px_hsl(186_75%_45%/0.45)]',
|
||||
'hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),0_12px_32px_-10px_hsl(186_75%_45%/0.55)]',
|
||||
'motion-safe:transition-colors motion-safe:duration-200',
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user