Restyle Actions page: less boxy, default covers, canonical FAB
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 569 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 520 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 614 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 441 KiB |
@@ -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<Record<string, typeof TIMEZONES[number][]>>(
|
||||
(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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-label="Select timezone"
|
||||
className={cn('w-full justify-between', className)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Clock className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm truncate">
|
||||
{currentLabel}
|
||||
{currentOffset && (
|
||||
<span className="text-muted-foreground ml-2 font-normal">{currentOffset}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start" sideOffset={4}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search timezones..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No timezone found.</CommandEmpty>
|
||||
{Object.entries(GROUPED_TIMEZONES).map(([region, zones]) => (
|
||||
<CommandGroup key={region} heading={region}>
|
||||
{zones.map((tz) => (
|
||||
<CommandItem
|
||||
key={tz.value}
|
||||
value={`${tz.label} ${tz.value} ${tz.region} ${tz.offset}`}
|
||||
onSelect={() => {
|
||||
onChange(tz.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<span className="flex-1">
|
||||
{tz.label}
|
||||
<span className="text-muted-foreground ml-2 font-normal">{tz.offset}</span>
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
value === tz.value ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Card className="overflow-hidden">
|
||||
<Skeleton className="h-40 w-full rounded-none" />
|
||||
<CardContent className="space-y-3 pt-4">
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<RouterLink to={`/${naddr}`} className="block h-full">
|
||||
<RouterLink to={`/${naddr}`} className="block group">
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden border-2 border-primary/30 transition-all duration-300 group cursor-pointer h-full flex flex-col',
|
||||
!isExpired && 'hover:border-primary/60 hover:shadow-2xl hover:shadow-primary/20 hover:-translate-y-1',
|
||||
isExpired && 'border-border/80 bg-muted/10',
|
||||
'overflow-hidden transition-colors',
|
||||
'hover:bg-muted/30',
|
||||
isExpired && 'opacity-70',
|
||||
)}
|
||||
>
|
||||
{challenge.image && !imageLoadFailed && (
|
||||
<div className="relative w-full h-48 overflow-hidden">
|
||||
<img
|
||||
src={challenge.image}
|
||||
alt={challenge.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImageLoadFailed(true)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
|
||||
<div className="absolute top-0 left-0 right-0 h-1.5 bg-gradient-to-r from-primary/80 via-primary to-primary/80" />
|
||||
</div>
|
||||
)}
|
||||
{!challenge.image && challenge.imageError && (
|
||||
<div className="mx-4 mt-4 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Cover image rejected
|
||||
</div>
|
||||
<p className="mt-1">{challenge.imageError}</p>
|
||||
</div>
|
||||
)}
|
||||
{challenge.image && imageLoadFailed && (
|
||||
<div className="mx-4 mt-4 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Cover image failed to load
|
||||
</div>
|
||||
<p className="mt-1 break-all">{challenge.image}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Cover image — full bleed, modest height */}
|
||||
<div className="relative w-full h-40 overflow-hidden bg-muted">
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={challenge.title}
|
||||
className={cn(
|
||||
'w-full h-full object-cover transition-transform duration-300',
|
||||
!isExpired && 'group-hover:scale-[1.02]',
|
||||
isExpired && 'grayscale',
|
||||
)}
|
||||
onError={() => setImageLoadFailed(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
|
||||
<CardHeader className="pb-3 pt-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-xl bg-gradient-to-br from-primary/20 to-primary/20 border-2 border-primary/40 shadow-md',
|
||||
isExpired && 'grayscale',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-7 w-7 text-primary" />
|
||||
{/* Country flag — top-left, sitting on the image */}
|
||||
<span
|
||||
className="absolute top-3 left-3 text-2xl drop-shadow-md"
|
||||
title={getGeoDisplayName(challenge.countryCode)}
|
||||
>
|
||||
{countryCodeToFlag(challenge.countryCode)}
|
||||
</span>
|
||||
|
||||
{/* Deadline / expired pill — top-right */}
|
||||
{isExpired ? (
|
||||
<div className="absolute top-3 right-3 px-2 py-0.5 rounded-full bg-background/90 text-xs font-medium text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Expired
|
||||
</div>
|
||||
) : challenge.deadline ? (
|
||||
<div className="absolute top-3 right-3 px-2 py-0.5 rounded-full bg-background/90 text-xs font-medium flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{format(challenge.deadline * 1000, 'MMM d')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CardContent className="pt-4 pb-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="h-5 w-5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className={cn(
|
||||
'text-xl font-black line-clamp-2 transition-colors leading-tight',
|
||||
!isExpired && 'group-hover:text-primary',
|
||||
isExpired && 'text-muted-foreground',
|
||||
)}>
|
||||
{challenge.title}
|
||||
</CardTitle>
|
||||
<ChallengeShareMenu challenge={challenge} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<span className="text-xl" title={getGeoDisplayName(challenge.countryCode)}>
|
||||
{countryCodeToFlag(challenge.countryCode)}
|
||||
</span>
|
||||
{isExpired ? (
|
||||
<div className="px-2 py-1 rounded-md bg-muted text-muted-foreground text-xs font-semibold flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Expired
|
||||
</div>
|
||||
) : challenge.deadline ? (
|
||||
<div className="px-2 py-1 rounded-md bg-accent/10 border border-accent/30 text-accent text-xs font-semibold flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{format(challenge.deadline * 1000, 'MMM d, yyyy')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className={cn(
|
||||
'text-lg font-bold leading-tight line-clamp-2',
|
||||
!isExpired && 'group-hover:text-primary transition-colors',
|
||||
)}>
|
||||
{challenge.title}
|
||||
</h3>
|
||||
</div>
|
||||
<div onClick={(e) => e.preventDefault()}>
|
||||
<ChallengeShareMenu challenge={challenge} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col pb-4">
|
||||
<p className={cn(
|
||||
'text-sm line-clamp-4 mb-4 flex-1 leading-relaxed',
|
||||
isExpired ? 'text-muted-foreground/90' : 'text-foreground/80',
|
||||
'text-sm line-clamp-3 leading-relaxed',
|
||||
isExpired ? 'text-muted-foreground' : 'text-muted-foreground',
|
||||
)}>
|
||||
{challenge.description}
|
||||
</p>
|
||||
<div className={cn(
|
||||
'p-3 rounded-lg border-2 shadow-sm space-y-2',
|
||||
isExpired
|
||||
? 'bg-muted/40 border-border/70'
|
||||
: 'bg-gradient-to-r from-primary/10 to-primary/5 border-primary/40',
|
||||
)}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Bitcoin className="h-5 w-5 text-primary" />
|
||||
<span className="font-bold text-lg">{formatSats(challenge.bounty)}</span>
|
||||
<span className="text-xs text-muted-foreground">sats</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground min-w-0">
|
||||
<Avatar className="h-6 w-6 border-2 border-background shrink-0">
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className="text-[10px] bg-muted">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium truncate">{displayName}</span>
|
||||
</div>
|
||||
|
||||
{/* Meta row: bounty · author. No nested box. */}
|
||||
<div className="flex items-center gap-2 text-sm pt-1 min-w-0">
|
||||
<Bitcoin className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="font-semibold">{formatSats(challenge.bounty)}</span>
|
||||
<span className="text-muted-foreground text-xs">sats</span>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<Avatar className="h-5 w-5 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className="text-[9px] bg-muted">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-muted-foreground text-xs truncate">{displayName}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="mt-auto pt-0">
|
||||
<Button className="w-full gap-2" variant={isExpired ? 'outline' : 'default'}>
|
||||
<Zap className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{isExpired ? 'View archived action' : 'View action'}</span>
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
);
|
||||
@@ -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<string | null>(() => {
|
||||
const match = DEFAULT_CHALLENGE_COVERS.find((c) => c.url === formData.coverImage);
|
||||
return match?.id ?? null;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -463,16 +479,44 @@ function CreateChallengeForm({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Cover image (optional)</Label>
|
||||
{formData.coverImage && (
|
||||
<div className="relative w-full h-32 rounded-lg overflow-hidden border border-border">
|
||||
<img
|
||||
src={formData.coverImage}
|
||||
alt="Cover preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<Label>Cover image</Label>
|
||||
|
||||
{/* Live preview */}
|
||||
<div className="relative w-full h-32 rounded-lg overflow-hidden border border-border">
|
||||
<img
|
||||
src={formData.coverImage || DEFAULT_COVER_IMAGE}
|
||||
alt="Cover preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default cover gallery — horizontal scroll */}
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1">
|
||||
{DEFAULT_CHALLENGE_COVERS.map((cover) => {
|
||||
const isActive = selectedDefaultId === cover.id || formData.coverImage === cover.url;
|
||||
return (
|
||||
<button
|
||||
key={cover.id}
|
||||
type="button"
|
||||
onClick={() => handleDefaultCoverSelect(cover.id, cover.url)}
|
||||
className={cn(
|
||||
'relative h-20 w-28 flex-shrink-0 rounded-md overflow-hidden border-2 transition-all',
|
||||
isActive
|
||||
? 'border-primary ring-2 ring-primary/50'
|
||||
: 'border-border hover:border-primary/50',
|
||||
)}
|
||||
title={cover.name}
|
||||
aria-label={`Select ${cover.name} cover`}
|
||||
>
|
||||
<img src={cover.url} alt={cover.name} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom upload */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor="cover-upload"
|
||||
@@ -483,9 +527,7 @@ function CreateChallengeForm({
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{formData.coverImage ? 'Replace cover' : 'Upload cover'}
|
||||
</span>
|
||||
<span className="text-sm">Upload custom</span>
|
||||
</Label>
|
||||
<input
|
||||
id="cover-upload"
|
||||
@@ -606,6 +648,24 @@ function CreateChallengeForm({
|
||||
{formData.deadline && !formData.time && ' • Ends at 23:59 local time'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timezone — auto-revealed once any date field is set, since the start /
|
||||
deadline times are interpreted in this zone. */}
|
||||
{(formData.startDate || formData.deadline) && (
|
||||
<div className="space-y-2 bg-muted/30 p-3 rounded-lg border border-border/50 animate-in slide-in-from-top-2 duration-200">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Timezone
|
||||
</Label>
|
||||
<TimezoneSwitcher
|
||||
value={formData.timezone}
|
||||
onChange={(timezone) => setFormData({ ...formData, timezone })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Start and deadline times will be interpreted in this timezone.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-4 pt-2">
|
||||
<Button
|
||||
@@ -613,7 +673,7 @@ function CreateChallengeForm({
|
||||
disabled={!formData.title || !formData.description || !formData.bounty || !formData.selectedCountry || isSubmitting}
|
||||
className="gap-2 w-full"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bitcoin className="h-4 w-4" />}
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
Create action
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCancel} className="w-full">Cancel</Button>
|
||||
@@ -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<CreateFormState>({
|
||||
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'
|
||||
? (
|
||||
<Button className="shadow-lg" aria-label="Create action">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create action
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">Create action</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="h-[85dvh] max-h-[85dvh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
<Megaphone className="h-5 w-5 text-primary" />
|
||||
Create action
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md sm:max-w-lg md:max-w-2xl max-h-[85vh] w-[calc(100vw-2rem)] sm:w-full overflow-hidden flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
<Megaphone className="h-5 w-5 text-primary" />
|
||||
Create action
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -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<string | undefined>(undefined);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('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 (
|
||||
<main>
|
||||
<PageHeader title="Actions" icon={<Zap className="size-5 text-primary" />} />
|
||||
<main className="pb-16 sidebar:pb-0">
|
||||
<PageHeader title="Actions" icon={<Megaphone className="size-5" />} />
|
||||
|
||||
<div className="px-4 pb-24 max-w-5xl mx-auto">
|
||||
<div className="px-4 max-w-2xl mx-auto">
|
||||
{isLoading ? (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
{[...Array(4)].map((_, i) => <ChallengeSkeleton key={i} />)}
|
||||
</div>
|
||||
) : (challenges && challenges.length > 0) ? (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
{primarySectionTitle}
|
||||
</h2>
|
||||
<h2 className="text-xl font-bold">{primarySectionTitle}</h2>
|
||||
{headerControls}
|
||||
</div>
|
||||
|
||||
@@ -1019,7 +1075,7 @@ export default function ActionsPage() {
|
||||
) : null}
|
||||
|
||||
{hasCurrent && hasUpcoming && (
|
||||
<SectionDivider title="Upcoming actions" icon={<Calendar className="h-5 w-5 text-primary" />}>
|
||||
<SectionDivider title="Upcoming">
|
||||
<ChallengeSection
|
||||
items={visibleUpcoming}
|
||||
total={upcomingChallenges.length}
|
||||
@@ -1032,7 +1088,7 @@ export default function ActionsPage() {
|
||||
)}
|
||||
|
||||
{pastChallenges.length > 0 && (hasCurrent || hasUpcoming) && (
|
||||
<SectionDivider title="Past actions" icon={<Clock className="h-5 w-5" />} muted>
|
||||
<SectionDivider title="Past">
|
||||
<ChallengeSection
|
||||
items={visiblePast}
|
||||
total={pastChallenges.length}
|
||||
@@ -1047,43 +1103,36 @@ export default function ActionsPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
Active actions
|
||||
</h2>
|
||||
<h2 className="text-xl font-bold">Active actions</h2>
|
||||
{headerControls}
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed border-2 border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
|
||||
<CardContent className="py-12 px-6 text-center">
|
||||
<div className="relative mx-auto mb-6">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-32 h-32 bg-primary/10 rounded-full blur-2xl" />
|
||||
</div>
|
||||
<div className="relative w-20 h-20 mx-auto rounded-2xl bg-gradient-to-br from-primary/20 to-primary/20 flex items-center justify-center ring-4 ring-primary/10">
|
||||
<Zap className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-bold">No actions yet</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Be the first to create an action for {selectedCountryName}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden md:flex flex-col items-center gap-3 mt-6">
|
||||
<CreateChallengeDialog countryCode={selectedCountry} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<Megaphone className="size-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<h3 className="text-xl font-bold">No actions yet</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Be the first to create an action for {selectedCountryName}.
|
||||
</p>
|
||||
</div>
|
||||
{canCreateChallenge && (
|
||||
<Button onClick={() => setCreateOpen(true)} className="rounded-full">
|
||||
<Plus className="size-4 mr-2" />
|
||||
Create action
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user && canCreateChallenge && (
|
||||
<div className="fixed bottom-safe-20 sm:bottom-24 right-4 z-30">
|
||||
<CreateChallengeDialog countryCode={selectedCountry} variant="fab" />
|
||||
</div>
|
||||
)}
|
||||
<CreateChallengeDialog
|
||||
countryCode={selectedCountry}
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1095,7 +1144,7 @@ function ChallengeSection({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
{items.map((challenge) => (
|
||||
<ChallengeCard
|
||||
key={`${challenge.pubkey}:${challenge.id}`}
|
||||
@@ -1120,14 +1169,12 @@ function ChallengeSection({
|
||||
}
|
||||
|
||||
function SectionDivider({
|
||||
title, icon, muted, children,
|
||||
}: { title: string; icon: React.ReactNode; muted?: boolean; children: React.ReactNode }) {
|
||||
title, children,
|
||||
}: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 border-t border-border/50" />
|
||||
<h2 className={cn('text-lg font-semibold flex items-center gap-2', muted && 'text-muted-foreground')}>
|
||||
{icon}
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex-1 border-t border-border/50" />
|
||||
|
||||