Add photo banner heroes to organize and actions

This commit is contained in:
Chad Curtis
2026-05-18 12:52:29 -05:00
parent 041979de07
commit 1ac62aac06
8 changed files with 401 additions and 63 deletions
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

+15 -4
View File
@@ -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">
+151
View File
@@ -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>
);
}
+56
View File
@@ -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
View File
@@ -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,
}: {
+31 -53
View File
@@ -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',
)}
>