diff --git a/public/challenge-covers/cover1.png b/public/challenge-covers/cover1.png new file mode 100644 index 00000000..d1a57005 Binary files /dev/null and b/public/challenge-covers/cover1.png differ diff --git a/public/challenge-covers/cover10.png b/public/challenge-covers/cover10.png new file mode 100644 index 00000000..565a2552 Binary files /dev/null and b/public/challenge-covers/cover10.png differ diff --git a/public/challenge-covers/cover11.png b/public/challenge-covers/cover11.png new file mode 100644 index 00000000..c62b8d4c Binary files /dev/null and b/public/challenge-covers/cover11.png differ diff --git a/public/challenge-covers/cover2.png b/public/challenge-covers/cover2.png new file mode 100644 index 00000000..1ef81b80 Binary files /dev/null and b/public/challenge-covers/cover2.png differ diff --git a/public/challenge-covers/cover3.png b/public/challenge-covers/cover3.png new file mode 100644 index 00000000..f7204a59 Binary files /dev/null and b/public/challenge-covers/cover3.png differ diff --git a/public/challenge-covers/cover4.jpeg b/public/challenge-covers/cover4.jpeg new file mode 100644 index 00000000..9cc0636c Binary files /dev/null and b/public/challenge-covers/cover4.jpeg differ diff --git a/public/challenge-covers/cover5.jpeg b/public/challenge-covers/cover5.jpeg new file mode 100644 index 00000000..cd6f565c Binary files /dev/null and b/public/challenge-covers/cover5.jpeg differ diff --git a/public/challenge-covers/cover6.png b/public/challenge-covers/cover6.png new file mode 100644 index 00000000..592695cc Binary files /dev/null and b/public/challenge-covers/cover6.png differ diff --git a/public/challenge-covers/cover7.png b/public/challenge-covers/cover7.png new file mode 100644 index 00000000..71b70cd1 Binary files /dev/null and b/public/challenge-covers/cover7.png differ diff --git a/public/challenge-covers/cover8.png b/public/challenge-covers/cover8.png new file mode 100644 index 00000000..096b05a5 Binary files /dev/null and b/public/challenge-covers/cover8.png differ diff --git a/public/challenge-covers/cover9.png b/public/challenge-covers/cover9.png new file mode 100644 index 00000000..f9cecc92 Binary files /dev/null and b/public/challenge-covers/cover9.png differ diff --git a/src/components/TimezoneSwitcher.tsx b/src/components/TimezoneSwitcher.tsx new file mode 100644 index 00000000..bca1197f --- /dev/null +++ b/src/components/TimezoneSwitcher.tsx @@ -0,0 +1,178 @@ +import { useState } from 'react'; +import { Clock, Check, ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +/** + * Curated list of timezones grouped by region. Offsets are standard-time + * (without DST); the OS applies DST when interpreting the IANA zone. + */ +const TIMEZONES = [ + // Americas + { value: 'America/New_York', label: 'Eastern Time (US)', region: 'Americas', offset: 'UTC-5' }, + { value: 'America/Chicago', label: 'Central Time (US)', region: 'Americas', offset: 'UTC-6' }, + { value: 'America/Denver', label: 'Mountain Time (US)', region: 'Americas', offset: 'UTC-7' }, + { value: 'America/Los_Angeles', label: 'Pacific Time (US)', region: 'Americas', offset: 'UTC-8' }, + { value: 'America/Anchorage', label: 'Alaska Time', region: 'Americas', offset: 'UTC-9' }, + { value: 'Pacific/Honolulu', label: 'Hawaii Time', region: 'Americas', offset: 'UTC-10' }, + { value: 'America/Toronto', label: 'Toronto', region: 'Americas', offset: 'UTC-5' }, + { value: 'America/Vancouver', label: 'Vancouver', region: 'Americas', offset: 'UTC-8' }, + { value: 'America/Mexico_City', label: 'Mexico City', region: 'Americas', offset: 'UTC-6' }, + { value: 'America/Sao_Paulo', label: 'São Paulo', region: 'Americas', offset: 'UTC-3' }, + { value: 'America/Buenos_Aires', label: 'Buenos Aires', region: 'Americas', offset: 'UTC-3' }, + { value: 'America/Lima', label: 'Lima', region: 'Americas', offset: 'UTC-5' }, + { value: 'America/Bogota', label: 'Bogotá', region: 'Americas', offset: 'UTC-5' }, + { value: 'America/Caracas', label: 'Caracas', region: 'Americas', offset: 'UTC-4' }, + { value: 'America/Santiago', label: 'Santiago', region: 'Americas', offset: 'UTC-3' }, + + // Europe + { value: 'Europe/London', label: 'London (GMT)', region: 'Europe', offset: 'UTC+0' }, + { value: 'Europe/Paris', label: 'Paris (CET)', region: 'Europe', offset: 'UTC+1' }, + { value: 'Europe/Berlin', label: 'Berlin (CET)', region: 'Europe', offset: 'UTC+1' }, + { value: 'Europe/Rome', label: 'Rome (CET)', region: 'Europe', offset: 'UTC+1' }, + { value: 'Europe/Madrid', label: 'Madrid (CET)', region: 'Europe', offset: 'UTC+1' }, + { value: 'Europe/Amsterdam', label: 'Amsterdam (CET)', region: 'Europe', offset: 'UTC+1' }, + { value: 'Europe/Brussels', label: 'Brussels (CET)', region: 'Europe', offset: 'UTC+1' }, + { value: 'Europe/Vienna', label: 'Vienna (CET)', region: 'Europe', offset: 'UTC+1' }, + { value: 'Europe/Warsaw', label: 'Warsaw (CET)', region: 'Europe', offset: 'UTC+1' }, + { value: 'Europe/Athens', label: 'Athens (EET)', region: 'Europe', offset: 'UTC+2' }, + { value: 'Europe/Helsinki', label: 'Helsinki (EET)', region: 'Europe', offset: 'UTC+2' }, + { value: 'Europe/Istanbul', label: 'Istanbul (TRT)', region: 'Europe', offset: 'UTC+3' }, + { value: 'Europe/Moscow', label: 'Moscow (MSK)', region: 'Europe', offset: 'UTC+3' }, + { value: 'Europe/Lisbon', label: 'Lisbon (WET)', region: 'Europe', offset: 'UTC+0' }, + { value: 'Europe/Dublin', label: 'Dublin (GMT)', region: 'Europe', offset: 'UTC+0' }, + + // Asia + { value: 'Asia/Dubai', label: 'Dubai', region: 'Asia', offset: 'UTC+4' }, + { value: 'Asia/Kolkata', label: 'India (IST)', region: 'Asia', offset: 'UTC+5:30' }, + { value: 'Asia/Bangkok', label: 'Bangkok', region: 'Asia', offset: 'UTC+7' }, + { value: 'Asia/Singapore', label: 'Singapore', region: 'Asia', offset: 'UTC+8' }, + { value: 'Asia/Hong_Kong', label: 'Hong Kong', region: 'Asia', offset: 'UTC+8' }, + { value: 'Asia/Shanghai', label: 'Shanghai', region: 'Asia', offset: 'UTC+8' }, + { value: 'Asia/Tokyo', label: 'Tokyo', region: 'Asia', offset: 'UTC+9' }, + { value: 'Asia/Seoul', label: 'Seoul', region: 'Asia', offset: 'UTC+9' }, + { value: 'Asia/Jakarta', label: 'Jakarta', region: 'Asia', offset: 'UTC+7' }, + { value: 'Asia/Manila', label: 'Manila', region: 'Asia', offset: 'UTC+8' }, + { value: 'Asia/Taipei', label: 'Taipei', region: 'Asia', offset: 'UTC+8' }, + { value: 'Asia/Karachi', label: 'Karachi', region: 'Asia', offset: 'UTC+5' }, + { value: 'Asia/Tehran', label: 'Tehran', region: 'Asia', offset: 'UTC+3:30' }, + { value: 'Asia/Jerusalem', label: 'Jerusalem', region: 'Asia', offset: 'UTC+2' }, + { value: 'Asia/Baghdad', label: 'Baghdad', region: 'Asia', offset: 'UTC+3' }, + + // Oceania + { value: 'Australia/Sydney', label: 'Sydney', region: 'Oceania', offset: 'UTC+10' }, + { value: 'Australia/Melbourne', label: 'Melbourne', region: 'Oceania', offset: 'UTC+10' }, + { value: 'Australia/Brisbane', label: 'Brisbane', region: 'Oceania', offset: 'UTC+10' }, + { value: 'Australia/Perth', label: 'Perth', region: 'Oceania', offset: 'UTC+8' }, + { value: 'Pacific/Auckland', label: 'Auckland', region: 'Oceania', offset: 'UTC+12' }, + + // Africa + { value: 'Africa/Cairo', label: 'Cairo', region: 'Africa', offset: 'UTC+2' }, + { value: 'Africa/Johannesburg', label: 'Johannesburg', region: 'Africa', offset: 'UTC+2' }, + { value: 'Africa/Lagos', label: 'Lagos', region: 'Africa', offset: 'UTC+1' }, + { value: 'Africa/Nairobi', label: 'Nairobi', region: 'Africa', offset: 'UTC+3' }, + { value: 'Africa/Casablanca', label: 'Casablanca', region: 'Africa', offset: 'UTC+1' }, + { value: 'Africa/Algiers', label: 'Algiers', region: 'Africa', offset: 'UTC+1' }, +] as const; + +const GROUPED_TIMEZONES = TIMEZONES.reduce>( + (acc, tz) => { + if (!acc[tz.region]) acc[tz.region] = []; + acc[tz.region].push(tz); + return acc; + }, + {}, +); + +interface TimezoneSwitcherProps { + /** Selected IANA timezone (e.g. "America/New_York"). */ + value: string; + /** Called with the new IANA timezone when the user picks one. */ + onChange: (timezone: string) => void; + className?: string; +} + +/** + * Controlled timezone picker. Accepts an IANA timezone string and emits the + * selected zone via `onChange`. Used by the action creation form so the + * author can declare which zone their start/deadline times are expressed in. + */ +export function TimezoneSwitcher({ value, onChange, className }: TimezoneSwitcherProps) { + const [open, setOpen] = useState(false); + + const currentData = TIMEZONES.find((tz) => tz.value === value); + const currentLabel = currentData?.label ?? value.replace(/_/g, ' '); + const currentOffset = currentData?.offset ?? ''; + + return ( + + + + + + + + + No timezone found. + {Object.entries(GROUPED_TIMEZONES).map(([region, zones]) => ( + + {zones.map((tz) => ( + { + onChange(tz.value); + setOpen(false); + }} + className="gap-2" + > + + {tz.label} + {tz.offset} + + + + ))} + + ))} + + + + + ); +} diff --git a/src/lib/defaultChallengeCovers.ts b/src/lib/defaultChallengeCovers.ts new file mode 100644 index 00000000..ae5d2b98 --- /dev/null +++ b/src/lib/defaultChallengeCovers.ts @@ -0,0 +1,30 @@ +/** + * Default cover images for actions/challenges. + * Bold, eye-catching visuals related to civil unrest, protests, and human rights. + * + * Used by the action creation form as a gallery the author can pick from when + * they don't want to upload a custom cover, and as a fallback for action cards + * whose author never set an image. + */ +export const DEFAULT_CHALLENGE_COVERS = [ + { id: 'cover1', url: '/challenge-covers/cover1.png', name: 'Protest March' }, + { id: 'cover2', url: '/challenge-covers/cover2.png', name: 'Raised Fists' }, + { id: 'cover3', url: '/challenge-covers/cover3.png', name: 'Unity' }, + { id: 'cover4', url: '/challenge-covers/cover4.jpeg', name: 'Demonstration' }, + { id: 'cover5', url: '/challenge-covers/cover5.jpeg', name: 'People Power' }, + { id: 'cover6', url: '/challenge-covers/cover6.png', name: 'Solidarity' }, + { id: 'cover7', url: '/challenge-covers/cover7.png', name: 'Resistance' }, + { id: 'cover8', url: '/challenge-covers/cover8.png', name: 'Freedom' }, + { id: 'cover9', url: '/challenge-covers/cover9.png', name: 'Justice' }, + { id: 'cover10', url: '/challenge-covers/cover10.png', name: 'Revolution' }, + { id: 'cover11', url: '/challenge-covers/cover11.png', name: 'Change' }, +] as const; + +/** Default cover image when an action has none set. */ +export const DEFAULT_COVER_IMAGE = '/challenge-covers/cover9.png'; + +/** Pick a random default cover image (used for variety in fallbacks). */ +export function getRandomDefaultCover() { + const index = Math.floor(Math.random() * DEFAULT_CHALLENGE_COVERS.length); + return DEFAULT_CHALLENGE_COVERS[index]; +} diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 25a1b179..1e49a908 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -172,7 +172,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [ { id: "help", label: "Help", path: "/help", icon: LifeBuoy }, { id: "agent", label: "Agent", path: "/agent", icon: Bot }, // Content types - { id: "actions", label: "Actions", path: "/actions", icon: Zap }, + { id: "actions", label: "Actions", path: "/actions", icon: Megaphone }, { id: "events", label: "Events", path: "/events", icon: CalendarDays }, { id: "photos", label: "Photos", path: "/photos", icon: Camera }, { id: "videos", label: "Videos", path: "/videos", icon: Film }, diff --git a/src/pages/ActionsPage.tsx b/src/pages/ActionsPage.tsx index 56b775ab..f970db4e 100644 --- a/src/pages/ActionsPage.tsx +++ b/src/pages/ActionsPage.tsx @@ -18,10 +18,13 @@ import { isAdmin } from '@/lib/admins'; import { createCountryIdentifier } from '@/lib/countryIdentifiers'; import { getAllCountries, getGeoDisplayName, countryCodeToFlag } from '@/lib/countries'; import { getDisplayName } from '@/lib/genUserName'; +import { DEFAULT_CHALLENGE_COVERS, DEFAULT_COVER_IMAGE } from '@/lib/defaultChallengeCovers'; +import { useLayoutOptions } from '@/contexts/LayoutContext'; import { cn } from '@/lib/utils'; import { PageHeader } from '@/components/PageHeader'; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { TimezoneSwitcher } from '@/components/TimezoneSwitcher'; +import { Card, CardContent } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; @@ -50,7 +53,7 @@ import { import { Camera, Palette, Info, Zap, Clock, Bitcoin, Plus, ChevronRight, Loader2, Link as LinkIcon, Check, MoreHorizontal, Trash2, Upload, ListFilter, - Calendar, DollarSign, Globe, AlertTriangle, + Calendar, DollarSign, Globe, Megaphone, } from 'lucide-react'; const CHALLENGE_ICONS = { @@ -64,31 +67,60 @@ function formatSats(sats: number): string { return sats.toLocaleString(); } +/** + * Convert a calendar date+time (interpreted in the given IANA timezone) to a + * Unix timestamp in seconds. + * + * The trick: we ask `Intl.DateTimeFormat` to format a candidate UTC instant in + * the target zone, see how far off the wall-clock fields are, and shift by + * that delta. One iteration suffices because the zone offset is locally + * constant (DST transitions don't move by more than an hour, well below the + * day-granularity inputs we receive). + */ +function unixSecondsInTimezone( + year: number, month: number, day: number, + hours: number, minutes: number, + timezone: string, +): number { + const utcGuess = Date.UTC(year, month - 1, day, hours, minutes, 0); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }); + const parts = Object.fromEntries( + formatter.formatToParts(new Date(utcGuess)).map((p) => [p.type, p.value]), + ); + const asWallClock = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour) === 24 ? 0 : Number(parts.hour), + Number(parts.minute), + Number(parts.second), + ); + const offsetMs = utcGuess - asWallClock; + return Math.floor((utcGuess + offsetMs) / 1000); +} + // ───────────────────────────────────────────────────────────────────────────── // Skeletons / Cards // ───────────────────────────────────────────────────────────────────────────── function ChallengeSkeleton() { return ( - - -
- -
- - -
-
-
- -
- - + + + + + + +
+ +
- - -
); } @@ -205,124 +237,96 @@ function ChallengeCard({ challenge, isExpired }: { challenge: Challenge; isExpir identifier: challenge.id, }); + // Always show a cover — fall back to the default if the author didn't set + // one, or the URL failed to validate / load. + const coverImage = (challenge.image && !imageLoadFailed) + ? challenge.image + : DEFAULT_COVER_IMAGE; + return ( - + - {challenge.image && !imageLoadFailed && ( -
- {challenge.title} setImageLoadFailed(true)} - /> -
-
-
- )} - {!challenge.image && challenge.imageError && ( -
-
- - Cover image rejected -
-

{challenge.imageError}

-
- )} - {challenge.image && imageLoadFailed && ( -
-
- - Cover image failed to load -
-

{challenge.image}

-
- )} + {/* Cover image — full bleed, modest height */} +
+ {challenge.title} setImageLoadFailed(true)} + loading="lazy" + /> +
- -
-
- + {/* Country flag — top-left, sitting on the image */} + + {countryCodeToFlag(challenge.countryCode)} + + + {/* Deadline / expired pill — top-right */} + {isExpired ? ( +
+ + Expired
+ ) : challenge.deadline ? ( +
+ + {format(challenge.deadline * 1000, 'MMM d')} +
+ ) : null} +
+ + +
+
-
- - {challenge.title} - - -
-
- - {countryCodeToFlag(challenge.countryCode)} - - {isExpired ? ( -
- - Expired -
- ) : challenge.deadline ? ( -
- - {format(challenge.deadline * 1000, 'MMM d, yyyy')} -
- ) : null} -
+

+ {challenge.title} +

+
+
e.preventDefault()}> +
- -

{challenge.description}

-
-
- - {formatSats(challenge.bounty)} - sats -
-
- - - - {displayName.slice(0, 2).toUpperCase()} - - - {displayName} -
+ + {/* Meta row: bounty · author. No nested box. */} +
+ + {formatSats(challenge.bounty)} + sats + · + + + + {displayName.slice(0, 2).toUpperCase()} + + + {displayName}
- - - - ); @@ -343,6 +347,8 @@ interface CreateFormState { time: string; coverImage: string; selectedCountry: string; + /** IANA timezone used to interpret start/deadline date+time fields. */ + timezone: string; } function CreateChallengeForm({ @@ -396,12 +402,22 @@ function CreateChallengeForm({ try { const [[, url]] = await uploadFile(file); setFormData({ ...formData, coverImage: url }); + setSelectedDefaultId(null); } catch (error) { console.error('Failed to upload cover image:', error); } }; + const handleDefaultCoverSelect = (coverId: string, coverUrl: string) => { + setFormData({ ...formData, coverImage: coverUrl }); + setSelectedDefaultId(coverId); + }; + const [countryPickerOpen, setCountryPickerOpen] = useState(false); + const [selectedDefaultId, setSelectedDefaultId] = useState(() => { + const match = DEFAULT_CHALLENGE_COVERS.find((c) => c.url === formData.coverImage); + return match?.id ?? null; + }); return ( <> @@ -463,16 +479,44 @@ function CreateChallengeForm({ )}
- - {formData.coverImage && ( -
- Cover preview + + + {/* Live preview */} +
+ Cover preview +
+ + {/* Default cover gallery — horizontal scroll */} +
+
+ {DEFAULT_CHALLENGE_COVERS.map((cover) => { + const isActive = selectedDefaultId === cover.id || formData.coverImage === cover.url; + return ( + + ); + })}
- )} +
+ + {/* Custom upload */}
+ + {/* Timezone — auto-revealed once any date field is set, since the start / + deadline times are interpreted in this zone. */} + {(formData.startDate || formData.deadline) && ( +
+ + setFormData({ ...formData, timezone })} + /> +

+ Start and deadline times will be interpreted in this timezone. +

+
+ )}
@@ -623,9 +683,12 @@ function CreateChallengeForm({ } function CreateChallengeDialog({ - countryCode, variant = 'inline', -}: { countryCode?: string; variant?: 'inline' | 'fab' }) { - const [open, setOpen] = useState(false); + countryCode, open, onOpenChange, +}: { + countryCode?: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { const [isSubmitting, setIsSubmitting] = useState(false); const { user } = useCurrentUser(); const { mutateAsync: createEvent } = useNostrPublish(); @@ -633,6 +696,7 @@ function CreateChallengeDialog({ const isMobile = useIsMobile(); const { isOrganizer } = useOrganizers(); const { toast } = useToast(); + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const [formData, setFormData] = useState({ title: '', @@ -643,8 +707,9 @@ function CreateChallengeDialog({ startTime: '', deadline: '', time: '', - coverImage: '', + coverImage: DEFAULT_COVER_IMAGE, selectedCountry: countryCode || '', + timezone: browserTimezone, }); const userIsAdmin = user ? isAdmin(user.pubkey) : false; @@ -680,16 +745,16 @@ function CreateChallengeDialog({ const [hours, minutes] = formData.startTime ? formData.startTime.split(':').map(Number) : [0, 0]; - const startDate = new Date(year, month - 1, day, hours, minutes, 0); - tags.push(['start', String(Math.floor(startDate.getTime() / 1000))]); + const startSeconds = unixSecondsInTimezone(year, month, day, hours, minutes, formData.timezone); + tags.push(['start', String(startSeconds)]); } if (formData.deadline) { const [year, month, day] = formData.deadline.split('-').map(Number); const [hours, minutes] = formData.time ? formData.time.split(':').map(Number) : [23, 59]; - const deadlineDate = new Date(year, month - 1, day, hours, minutes, 0); - tags.push(['deadline', String(Math.floor(deadlineDate.getTime() / 1000))]); + const deadlineSeconds = unixSecondsInTimezone(year, month, day, hours, minutes, formData.timezone); + tags.push(['deadline', String(deadlineSeconds)]); } await createEvent({ @@ -701,12 +766,14 @@ function CreateChallengeDialog({ await queryClient.invalidateQueries({ queryKey: ['agora-challenges'] }); await queryClient.refetchQueries({ queryKey: ['agora-challenges'] }); - setOpen(false); setFormData({ title: '', description: '', type: 'photo', bounty: '', startDate: '', startTime: '', deadline: '', time: '', - coverImage: '', selectedCountry: countryCode || '', + coverImage: DEFAULT_COVER_IMAGE, + selectedCountry: countryCode || '', + timezone: browserTimezone, }); + onOpenChange(false); toast({ title: 'Action created' }); } catch (error) { console.error('Failed to create action:', error); @@ -718,28 +785,13 @@ function CreateChallengeDialog({ if (!user || !canCreateChallenge) return null; - const trigger = variant === 'fab' - ? ( - - ) - : ( - - ); - if (isMobile) { return ( - - {trigger} + - + Create action @@ -754,7 +806,7 @@ function CreateChallengeDialog({ setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} - onCancel={() => setOpen(false)} + onCancel={() => onOpenChange(false)} userIsAdmin={userIsAdmin} pageCountryCode={countryCode} /> @@ -765,12 +817,11 @@ function CreateChallengeDialog({ } return ( - - {trigger} + - + Create action @@ -785,7 +836,7 @@ function CreateChallengeDialog({ setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} - onCancel={() => setOpen(false)} + onCancel={() => onOpenChange(false)} userIsAdmin={userIsAdmin} pageCountryCode={countryCode} /> @@ -808,6 +859,7 @@ export default function ActionsPage() { const [selectedCountry, setSelectedCountry] = useState(undefined); const [sortBy, setSortBy] = useState('recent'); const [headerCountryPickerOpen, setHeaderCountryPickerOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); const { data: challenges, isLoading: challengesLoading } = useChallenges({ countryCode: selectedCountry, @@ -819,6 +871,13 @@ export default function ActionsPage() { user && selectedCountry ? isOrganizer(user.pubkey, selectedCountry) : false; const canCreateChallenge = userIsAdmin || userIsLocalOrganizer; + // Drive the global FAB from the canonical layout API so we get the same + // circular Plus button every other page has. + useLayoutOptions({ + showFAB: !!user && canCreateChallenge, + onFabClick: () => setCreateOpen(true), + }); + const allCountries = useMemo(() => getAllCountries(), []); const countryOptions = useMemo(() => { @@ -971,21 +1030,18 @@ export default function ActionsPage() { ); return ( -
- } /> +
+ } /> -
+
{isLoading ? ( -
+
{[...Array(4)].map((_, i) => )}
) : (challenges && challenges.length > 0) ? (
-

- - {primarySectionTitle} -

+

{primarySectionTitle}

{headerControls}
@@ -1019,7 +1075,7 @@ export default function ActionsPage() { ) : null} {hasCurrent && hasUpcoming && ( - }> + 0 && (hasCurrent || hasUpcoming) && ( - } muted> +
-

- - Active actions -

+

Active actions

{headerControls}
- - -
-
-
-
-
- -
-
-
-

No actions yet

-

- Be the first to create an action for {selectedCountryName}. -

-
-
- -
- - +
+
+ +
+
+

No actions yet

+

+ Be the first to create an action for {selectedCountryName}. +

+
+ {canCreateChallenge && ( + + )} +
)}
- {user && canCreateChallenge && ( -
- -
- )} +
); } @@ -1095,7 +1144,7 @@ function ChallengeSection({ }) { return (
-
+
{items.map((challenge) => (
-
-

- {icon} +

{title}