Compare commits
7 Commits
v2.8.4
...
ui-overhaul
| Author | SHA1 | Date | |
|---|---|---|---|
| 67de9c84be | |||
| 738f33a594 | |||
| ae17b06eda | |||
| 312b5de8b3 | |||
| 26475cf08f | |||
| 719b76a362 | |||
| b900c53016 |
+157
-118
@@ -76,6 +76,19 @@ interface AddMemberDialogProps {
|
||||
existingMemberPubkeys: string[];
|
||||
}
|
||||
|
||||
interface AddMemberPanelProps {
|
||||
/** The raw community definition event. */
|
||||
communityEvent: NostrEvent;
|
||||
/** Parsed community data. */
|
||||
community: ParsedCommunity;
|
||||
/** Whether the current user is the founder (can add moderators). */
|
||||
isFounder: boolean;
|
||||
/** Existing active members and moderators, excluded from duplicate adds. */
|
||||
existingMemberPubkeys: string[];
|
||||
/** Called after a successful publish so the host (dialog/page) can close or refresh. */
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
|
||||
if (!aTag) return undefined;
|
||||
const [kind, pubkey, ...identifierParts] = aTag.split(':');
|
||||
@@ -119,6 +132,57 @@ export function AddMemberDialog({
|
||||
isFounder,
|
||||
existingMemberPubkeys,
|
||||
}: AddMemberDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
|
||||
|
||||
const dialogContentRef = useCallback((node: HTMLElement | null) => {
|
||||
setPortalContainer(node ?? undefined);
|
||||
}, []);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
|
||||
<PortalContainerProvider value={portalContainer}>
|
||||
<DialogHeader className="px-5 pt-5 pb-3">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="size-5 text-primary" />
|
||||
Add Members
|
||||
</DialogTitle>
|
||||
<DialogDescription>Add to community</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="px-5 pb-5">
|
||||
<AddMemberPanel
|
||||
communityEvent={communityEvent}
|
||||
community={community}
|
||||
isFounder={isFounder}
|
||||
existingMemberPubkeys={existingMemberPubkeys}
|
||||
onComplete={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PortalContainerProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline form that searches for people and adds them as community members or
|
||||
* moderators. Pulled out of `AddMemberDialog` so the same flow can be
|
||||
* embedded inside other surfaces — e.g. the members dialog on
|
||||
* `CommunityDetailPage` — without nesting a second `Dialog`.
|
||||
*/
|
||||
export function AddMemberPanel({
|
||||
communityEvent,
|
||||
community,
|
||||
isFounder,
|
||||
existingMemberPubkeys,
|
||||
onComplete,
|
||||
}: AddMemberPanelProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { toast } = useToast();
|
||||
@@ -129,11 +193,6 @@ export function AddMemberDialog({
|
||||
const [badgeImageUrl, setBadgeImageUrl] = useState('');
|
||||
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
|
||||
|
||||
const dialogContentRef = useCallback((node: HTMLElement | null) => {
|
||||
setPortalContainer(node ?? undefined);
|
||||
}, []);
|
||||
|
||||
// Mutations
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -158,11 +217,6 @@ export function AddMemberDialog({
|
||||
setIsPublishing(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
}, [onOpenChange, resetForm]);
|
||||
|
||||
// ── People management ─────────────────────────────────────────────────────
|
||||
|
||||
const addPerson = useCallback((profile: SearchProfile) => {
|
||||
@@ -407,7 +461,8 @@ export function AddMemberDialog({
|
||||
|
||||
const addedCount = pendingMembers.length;
|
||||
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
|
||||
handleOpenChange(false);
|
||||
resetForm();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to add members',
|
||||
@@ -419,121 +474,105 @@ export function AddMemberDialog({
|
||||
}
|
||||
}, [
|
||||
user, pendingMembers, existingBadgeATag, hasBadge, needsBadgeCreation, isFounder, community, communityEvent,
|
||||
badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, applyOptimisticMembership, isBadgeImageUploading,
|
||||
badgeImageUrl, nostr, publishEvent, queryClient, toast, resetForm, onComplete, applyOptimisticMembership, isBadgeImageUploading,
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
|
||||
<PortalContainerProvider value={portalContainer}>
|
||||
<DialogHeader className="px-5 pt-5 pb-3">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="size-5 text-primary" />
|
||||
Add Members
|
||||
</DialogTitle>
|
||||
<DialogDescription>Add to community</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* People search */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Search</Label>
|
||||
<PersonSearch
|
||||
onAdd={addPerson}
|
||||
onAddMany={addPeople}
|
||||
excludePubkeys={[
|
||||
community.founderPubkey,
|
||||
...existingMemberPubkeys,
|
||||
...pendingMembers.map((m) => m.profile.pubkey),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
{/* People search */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Search</Label>
|
||||
<PersonSearch
|
||||
onAdd={addPerson}
|
||||
onAddMany={addPeople}
|
||||
excludePubkeys={[
|
||||
community.founderPubkey,
|
||||
...existingMemberPubkeys,
|
||||
...pendingMembers.map((m) => m.profile.pubkey),
|
||||
]}
|
||||
{/* Pending members list */}
|
||||
{pendingMembers.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
People to add
|
||||
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
{pendingMembers.map((pm) => (
|
||||
<PendingMemberChip
|
||||
key={pm.profile.pubkey}
|
||||
pending={pm}
|
||||
onRemove={removePerson}
|
||||
onRoleChange={isFounder ? setRole : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pending members list */}
|
||||
{pendingMembers.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
People to add
|
||||
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
{pendingMembers.map((pm) => (
|
||||
<PendingMemberChip
|
||||
key={pm.profile.pubkey}
|
||||
pending={pm}
|
||||
onRemove={removePerson}
|
||||
onRoleChange={isFounder ? setRole : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPendingMembers && (
|
||||
<div className="space-y-2">
|
||||
<Label>Member badge</Label>
|
||||
{hasBadge ? (
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/30 p-3">
|
||||
{isBadgeError ? (
|
||||
<p className="text-sm text-destructive">Failed to load the current member badge.</p>
|
||||
) : isBadgeLoading ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-12 animate-pulse rounded-lg bg-muted" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-44 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : existingBadge ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<BadgeThumbnail badge={existingBadge} size={48} className="shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{existingBadge.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{existingBadge.description || 'Selected members will receive this badge.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">The configured member badge could not be found.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ImageUploadField
|
||||
id="member-badge-image"
|
||||
label={<>Create Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
|
||||
value={badgeImageUrl}
|
||||
onChange={setBadgeImageUrl}
|
||||
onUploadingChange={setIsBadgeImageUploading}
|
||||
uploadToastTitle="Badge image uploaded"
|
||||
previewAlt="Badge preview"
|
||||
objectFit="contain"
|
||||
dropAreaClassName="min-h-24"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="size-4 animate-spin" /> Adding...</>
|
||||
) : (
|
||||
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PortalContainerProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPendingMembers && (
|
||||
<div className="space-y-2">
|
||||
<Label>Member badge</Label>
|
||||
{hasBadge ? (
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/30 p-3">
|
||||
{isBadgeError ? (
|
||||
<p className="text-sm text-destructive">Failed to load the current member badge.</p>
|
||||
) : isBadgeLoading ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-12 animate-pulse rounded-lg bg-muted" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-44 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : existingBadge ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<BadgeThumbnail badge={existingBadge} size={48} className="shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{existingBadge.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{existingBadge.description || 'Selected members will receive this badge.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">The configured member badge could not be found.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ImageUploadField
|
||||
id="member-badge-image"
|
||||
label={<>Create Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
|
||||
value={badgeImageUrl}
|
||||
onChange={setBadgeImageUrl}
|
||||
onUploadingChange={setIsBadgeImageUploading}
|
||||
uploadToastTitle="Badge image uploaded"
|
||||
previewAlt="Badge preview"
|
||||
objectFit="contain"
|
||||
dropAreaClassName="min-h-24"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="size-4 animate-spin" /> Adding...</>
|
||||
) : (
|
||||
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,15 @@ export const ARC_OVERHANG_PX = 20;
|
||||
/** Larger overhang for the upward arc (bottom nav) so the harsher curve isn't clipped. */
|
||||
export const ARC_UP_OVERHANG_PX = 28;
|
||||
|
||||
/** SVG path for a downward arc (used by top bar and sub-header bar). */
|
||||
const ARC_DOWN_PATH = 'M0,0 L100,0 L100,44 Q50,64 0,44 Z';
|
||||
/** SVG path for a downward angled bar (used by top bar and sub-header bar).
|
||||
* Bottom edge slopes from each corner down to a center apex, forming an
|
||||
* inverted-V that points toward the content below. */
|
||||
const ARC_DOWN_PATH = 'M0,0 L100,0 L100,34 L50,46 L0,34 Z';
|
||||
|
||||
/** SVG path for an upward arc (used by bottom nav). */
|
||||
const ARC_UP_PATH = 'M0,30 Q50,0 100,30 L100,64 L0,64 Z';
|
||||
/** SVG path for an upward angled bar (used by bottom nav).
|
||||
* Top edge slopes from each corner up to a center apex, forming a V that
|
||||
* points away from the content. */
|
||||
const ARC_UP_PATH = 'M0,40 L50,16 L100,40 L100,64 L0,64 Z';
|
||||
|
||||
/** SVG path for a plain rectangle with no arc. */
|
||||
const RECT_PATH = 'M0,0 L100,0 L100,64 L0,64 Z';
|
||||
@@ -54,8 +58,8 @@ export function ArcBackground({ variant, className }: ArcBackgroundProps) {
|
||||
style={hasArc ? (variant === 'up' ? arcUpHeightStyle : arcDownHeightStyle) : fullHeightStyle}
|
||||
>
|
||||
<path d={path} className="fill-background/85" />
|
||||
{variant === 'down' && <path d="M0,44 Q50,64 100,44" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />}
|
||||
{variant === 'up' && <path d="M0,30 Q50,0 100,30" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />}
|
||||
{variant === 'down' && <path d="M0,34 L50,46 L100,34" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
|
||||
{variant === 'up' && <path d="M0,40 L50,16 L100,40" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,47 @@
|
||||
import { useMemo, useCallback, useEffect, useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Activity as ActivityIcon,
|
||||
CalendarDays,
|
||||
Crown,
|
||||
Loader2,
|
||||
Info,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Rss,
|
||||
Radio,
|
||||
Shield,
|
||||
ShieldBan,
|
||||
Share2,
|
||||
Target,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { AddMemberDialog } from '@/components/AddMemberDialog';
|
||||
import { AddMemberPanel } from '@/components/AddMemberDialog';
|
||||
import { CreateCommunityDialog } from '@/components/CreateCommunityDialog';
|
||||
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
|
||||
import { PeopleAvatarStack } from '@/components/PeopleAvatarStack';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
|
||||
import { CommunityChatPanel } from '@/components/CommunityChatPanel';
|
||||
import { CommunityPulsePanel } from '@/components/CommunityPulsePanel';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { CommunityBadgePanel } from '@/components/CommunityBadgePanel';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { FollowToggleButton } from '@/components/FollowButton';
|
||||
import { CreateGoalDialog } from '@/components/CreateGoalDialog';
|
||||
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
@@ -47,18 +52,13 @@ import { useCommunityEvents } from '@/hooks/useCommunityEvents';
|
||||
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
|
||||
import { useCommunityGoals } from '@/hooks/useCommunityGoals';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { usePageRefresh } from '@/hooks/usePageRefresh';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { applyCommunityModerationToEvents, canBanTarget, getViewerAuthority, parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { shouldHideFeedEvent, type FeedItem } from '@/lib/feedUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -138,94 +138,6 @@ function ReplyCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityMemberFeed({ authorPubkeys, isMembershipLoading }: { authorPubkeys: string[]; isMembershipLoading: boolean }) {
|
||||
const { muteItems } = useMuteList();
|
||||
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
const queryKey = useMemo(() => ['feed', 'follows'], []);
|
||||
const handleRefresh = usePageRefresh(queryKey);
|
||||
const uniqueAuthors = useMemo(() => Array.from(new Set(authorPubkeys)), [authorPubkeys]);
|
||||
|
||||
const {
|
||||
data: rawData,
|
||||
isPending,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useFeed('follows', { authors: uniqueAuthors });
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const feedItems = useMemo(() => {
|
||||
if (!rawData?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
return (rawData.pages as { items: FeedItem[] }[])
|
||||
.flatMap((page) => page.items)
|
||||
.filter((item) => {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
if (shouldHideFeedEvent(item.event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [rawData?.pages, muteItems]);
|
||||
|
||||
if (!isMembershipLoading && uniqueAuthors.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
No active members found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMembershipLoading || isPending || (isLoading && !rawData)) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<FeedEmptyState message="No posts from active community members yet." />
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div>
|
||||
{feedItems.map((item) => (
|
||||
<NoteCard
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div ref={scrollRef} className="py-4">
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
@@ -271,12 +183,13 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
const [banTargetPubkey, setBanTargetPubkey] = useState<string | null>(null);
|
||||
|
||||
// ── Tab + FAB state ────────────────────────────────────────────────────────
|
||||
const [activeTab, setActiveTab] = useState('members');
|
||||
const [activeTab, setActiveTab] = useState('chat');
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [goalDialogOpen, setGoalDialogOpen] = useState(false);
|
||||
const [eventDialogOpen, setEventDialogOpen] = useState(false);
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||
const [editCommunityOpen, setEditCommunityOpen] = useState(false);
|
||||
const [membersDialogOpen, setMembersDialogOpen] = useState(false);
|
||||
const [descriptionDialogOpen, setDescriptionDialogOpen] = useState(false);
|
||||
|
||||
// Parse community definition
|
||||
const community = useMemo(() => parseCommunityEvent(event), [event]);
|
||||
@@ -296,6 +209,27 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
|
||||
}, [description, descriptionUrl]);
|
||||
|
||||
// Whether to render the description info button next to the title — true
|
||||
// whenever there's any description text or a stripped trailing URL.
|
||||
const descriptionExpandable = !!descriptionText || !!descriptionUrl;
|
||||
|
||||
/**
|
||||
* Synthesize a kind-1 pseudo-event so we can hand the description off to
|
||||
* `NoteContent` for the same link / hashtag / nostr-URI / embed rendering
|
||||
* used in NoteCard. Reuses the community event's `pubkey` and `id` to
|
||||
* satisfy hooks inside `NoteContent` (author lookup, memo keys, etc.); the
|
||||
* synthesized event is never published.
|
||||
*/
|
||||
const descriptionPseudoEvent = useMemo<NostrEvent>(() => ({
|
||||
id: `community-description-${event.id}`,
|
||||
pubkey: event.pubkey,
|
||||
kind: 1,
|
||||
created_at: event.created_at,
|
||||
tags: [],
|
||||
content: description,
|
||||
sig: '',
|
||||
}), [description, event.id, event.pubkey, event.created_at]);
|
||||
|
||||
// ── Members ─────────────────────────────────────────────────────────────────
|
||||
const { data: membership, moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
|
||||
const viewerMember = user ? getViewerAuthority(user.pubkey, rankMap, moderation) : undefined;
|
||||
@@ -404,6 +338,37 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
[eventItems, now],
|
||||
);
|
||||
|
||||
// ── Initiatives (Goals + Events merged into one chronological list) ───────
|
||||
// Active items go first, sorted: future events ascending by start date,
|
||||
// then active goals by creation date (newest first). Past items follow.
|
||||
const activeInitiatives = useMemo(() => {
|
||||
const items = [...activeGoals, ...activeEventItems];
|
||||
return items.sort((a, b) => {
|
||||
const aIsEvent = a.kind === 31922 || a.kind === 31923;
|
||||
const bIsEvent = b.kind === 31922 || b.kind === 31923;
|
||||
if (aIsEvent && bIsEvent) {
|
||||
return getCalendarEventStart(a) - getCalendarEventStart(b);
|
||||
}
|
||||
if (aIsEvent) return -1;
|
||||
if (bIsEvent) return 1;
|
||||
return b.created_at - a.created_at;
|
||||
});
|
||||
}, [activeGoals, activeEventItems]);
|
||||
|
||||
const pastInitiatives = useMemo(() => {
|
||||
const items = [...pastGoals, ...pastEventItems];
|
||||
return items.sort((a, b) => {
|
||||
// Newest-first by the relevant "end" timestamp.
|
||||
const aEnd = a.kind === 31922 || a.kind === 31923
|
||||
? getCalendarEventEnd(a)
|
||||
: parseInt(a.tags.find(([n]) => n === 'closed_at')?.[1] ?? String(a.created_at), 10);
|
||||
const bEnd = b.kind === 31922 || b.kind === 31923
|
||||
? getCalendarEventEnd(b)
|
||||
: parseInt(b.tags.find(([n]) => n === 'closed_at')?.[1] ?? String(b.created_at), 10);
|
||||
return bEnd - aEnd;
|
||||
});
|
||||
}, [pastGoals, pastEventItems]);
|
||||
|
||||
const replyTree = useMemo((): ReplyNode[] => {
|
||||
if (!commentsData) return [];
|
||||
const topLevel = commentsData.topLevelComments ?? [];
|
||||
@@ -439,6 +404,30 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
.map((r) => buildNode(r));
|
||||
}, [commentsData, moderation, membersOnly, rankMap]);
|
||||
|
||||
// ── Activity feed — merge active initiatives with top-level discussion
|
||||
// posts into one chronological stream (newest first). Each entry is tagged
|
||||
// so the renderer can dispatch to NoteCard (initiative) or
|
||||
// ThreadedReplyList (discussion subtree).
|
||||
type ActivityItem =
|
||||
| { kind: 'initiative'; event: NostrEvent; ts: number }
|
||||
| { kind: 'discussion'; node: ReplyNode; ts: number };
|
||||
|
||||
const activityItems = useMemo((): ActivityItem[] => {
|
||||
const items: ActivityItem[] = [
|
||||
...activeInitiatives.map((ev) => ({
|
||||
kind: 'initiative' as const,
|
||||
event: ev,
|
||||
ts: ev.created_at,
|
||||
})),
|
||||
...replyTree.map((node) => ({
|
||||
kind: 'discussion' as const,
|
||||
node,
|
||||
ts: node.event.created_at,
|
||||
})),
|
||||
];
|
||||
return items.sort((a, b) => b.ts - a.ts);
|
||||
}, [activeInitiatives, replyTree]);
|
||||
|
||||
// ── Share handler ───────────────────────────────────────────────────────────
|
||||
const handleShare = useCallback(async () => {
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
@@ -456,35 +445,46 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
}
|
||||
}, [event, toast]);
|
||||
|
||||
// ── FAB — visible on comments, goals, and members tabs ─────────────────────
|
||||
const handleFabClick = useCallback(() => {
|
||||
if (activeTab === 'comments') {
|
||||
setComposeOpen(true);
|
||||
} else if (activeTab === 'goals') {
|
||||
setGoalDialogOpen(true);
|
||||
} else if (activeTab === 'events') {
|
||||
setEventDialogOpen(true);
|
||||
} else if (activeTab === 'members') {
|
||||
setAddMemberOpen(true);
|
||||
}
|
||||
}, [activeTab]);
|
||||
// ── FAB — opens a floating action menu anchored to the FAB itself ─────────
|
||||
// Visible on tabs whose surfaces accept user-authored content.
|
||||
const fabAvailable = activeTab === 'activity';
|
||||
|
||||
const fabIcon = activeTab === 'goals'
|
||||
? <Target strokeWidth={3} size={18} />
|
||||
: activeTab === 'members'
|
||||
? <UserPlus className="size-5" />
|
||||
: activeTab === 'events'
|
||||
? <CalendarDays className="size-5" />
|
||||
: undefined; // default Plus icon for comments
|
||||
const fabMenu = useMemo(() => {
|
||||
if (!fabAvailable) return undefined;
|
||||
return [
|
||||
{
|
||||
id: 'new-post',
|
||||
label: 'New post',
|
||||
icon: <MessageCircle className="size-4" />,
|
||||
onSelect: () => {
|
||||
setActiveTab('activity');
|
||||
setComposeOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'new-goal',
|
||||
label: 'New goal',
|
||||
icon: <Target className="size-4" />,
|
||||
onSelect: () => {
|
||||
setActiveTab('activity');
|
||||
setGoalDialogOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'new-event',
|
||||
label: 'New event',
|
||||
icon: <CalendarDays className="size-4" />,
|
||||
onSelect: () => {
|
||||
setActiveTab('activity');
|
||||
setEventDialogOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [fabAvailable]);
|
||||
|
||||
useLayoutOptions({
|
||||
showFAB:
|
||||
activeTab === 'comments'
|
||||
|| activeTab === 'goals'
|
||||
|| activeTab === 'events'
|
||||
|| (activeTab === 'members' && canAddMembers),
|
||||
onFabClick: handleFabClick,
|
||||
fabIcon,
|
||||
showFAB: fabAvailable,
|
||||
fabMenu,
|
||||
});
|
||||
|
||||
const moderationCtx = useMemo(
|
||||
@@ -494,184 +494,189 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
const heroIconClassName = 'size-5 text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]';
|
||||
const actionButtonClassName = 'p-2 rounded-full text-muted-foreground hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none transition-colors';
|
||||
const bannerActionClassName = 'p-2 rounded-full text-white/90 hover:text-white hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50 disabled:pointer-events-none transition-colors';
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto pb-16">
|
||||
{/* ── Hero image ── */}
|
||||
<div className="relative h-32 w-full overflow-hidden sm:h-40">
|
||||
{image ? (
|
||||
<img src={image} alt={name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/50 via-primary/25 to-primary/5" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/10 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||
{!image && (
|
||||
<Users className="size-12 text-primary/20 sm:size-16" />
|
||||
)}
|
||||
<CommunityModerationContext.Provider value={moderationCtx}>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
{/* ── Hero banner + tabs share a single image/gradient backdrop so the
|
||||
banner image continues underneath the tab strip and fades into the
|
||||
page background — eliminating the seam between the two. ── */}
|
||||
<div className="relative isolate overflow-hidden">
|
||||
{/* Shared backdrop — image (or fallback gradient) + darkening overlay
|
||||
that spans the full height of (banner + tabs) and fades to the
|
||||
page background at its bottom edge. */}
|
||||
<div aria-hidden className="absolute inset-0 -z-10">
|
||||
{image ? (
|
||||
<img src={image} alt="" className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/50 via-primary/25 to-primary/5" />
|
||||
)}
|
||||
{/* Darkening overlay that fades to the page background at the
|
||||
bottom of the tab strip — makes tab text legible and erases the
|
||||
hard seam between banner and tabs. Stops push the heavy darkness
|
||||
down so it sits behind the tabs, not over the banner. */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_bottom,transparent_0%,transparent_15%,rgba(0,0,0,0.9)_75%,rgba(0,0,0,0.9)_97%,hsl(var(--background))_100%)]" />
|
||||
</div>
|
||||
|
||||
{/* ── Top bar ── */}
|
||||
<div className="absolute left-0 top-0 z-10 px-4 pt-4">
|
||||
{/* Banner — fixed aspect ratio, title/description/buttons overlaid */}
|
||||
<div className="relative w-full aspect-[2/1] sm:aspect-[21/9]">
|
||||
{!image && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Users className="size-16 text-primary/20 sm:size-20" />
|
||||
</div>
|
||||
)}
|
||||
{/* Extra top/bottom darkening on the hero specifically (above the
|
||||
shared overlay) so overlaid title/description stay legible. */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-transparent via-transparent to-black/40" />
|
||||
|
||||
{/* Top bar — back button (left) + follow toggle (right) */}
|
||||
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between px-4 pt-4">
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors"
|
||||
className="p-2 -ml-2 rounded-full hover:bg-black/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className={heroIconClassName} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 px-5 pb-3">
|
||||
<h2 className="text-xl font-bold text-white leading-tight drop-shadow-lg sm:text-2xl">{name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Community info ── */}
|
||||
<div className="px-5 mt-3 space-y-3">
|
||||
{/* Description */}
|
||||
{descriptionText && (
|
||||
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{user && communityATag && (
|
||||
<FollowToggleButton
|
||||
size="sm"
|
||||
isFollowing={communityFollowed}
|
||||
isPending={toggleCommunityFollow.isPending}
|
||||
onClick={handleToggleFollow}
|
||||
icon={<UserPlus className="size-4" />}
|
||||
followingIcon={
|
||||
<>
|
||||
<UserCheck className="size-4 group-hover:hidden group-focus-visible:hidden" />
|
||||
<UserMinus className="size-4 hidden group-hover:inline group-focus-visible:inline" />
|
||||
</>
|
||||
}
|
||||
hoverToUnfollow
|
||||
className={cn(
|
||||
'shadow-md',
|
||||
!communityFollowed && 'bg-white text-black hover:bg-white/90',
|
||||
communityFollowed && 'bg-black/30 backdrop-blur-sm border-white/40 text-white hover:bg-destructive/30 hover:text-white hover:border-destructive/60',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<MembersOnlyToggle />
|
||||
{isFounder && community && (
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClassName}
|
||||
onClick={() => setEditCommunityOpen(true)}
|
||||
aria-label="Edit community"
|
||||
>
|
||||
<Pencil className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClassName}
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
>
|
||||
<Share2 className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<CommunityModerationContext.Provider value={moderationCtx}>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="-mx-5">
|
||||
<TabsList className="w-full justify-start overflow-x-auto scrollbar-none rounded-none border-b border-border bg-transparent p-0 h-auto">
|
||||
<TabsTrigger
|
||||
value="members"
|
||||
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
|
||||
>
|
||||
<Users className="size-4 mr-1.5" />
|
||||
Members
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="chat"
|
||||
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
|
||||
>
|
||||
<MessageSquare className="size-4 mr-1.5" />
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="feed"
|
||||
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
|
||||
>
|
||||
<Rss className="size-4 mr-1.5" />
|
||||
Feed
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="comments"
|
||||
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
|
||||
>
|
||||
<MessageCircle className="size-4 mr-1.5" />
|
||||
Posts
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="goals"
|
||||
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
|
||||
>
|
||||
<Target className="size-4 mr-1.5" />
|
||||
Goals
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="events"
|
||||
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
|
||||
>
|
||||
<CalendarDays className="size-4 mr-1.5" />
|
||||
Events
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Members tab ── */}
|
||||
<TabsContent value="members" className="mt-0">
|
||||
{community && (
|
||||
<section className="border-b border-border px-5 py-4">
|
||||
<CommunityBadgePanel
|
||||
communityEvent={event}
|
||||
community={community}
|
||||
isFounder={isFounder}
|
||||
/>
|
||||
</section>
|
||||
{/* Member stack sits ABOVE the title; the title row carries the Info
|
||||
button (left of name) and action buttons (right). Description has
|
||||
moved behind an Info button to reduce banner clutter. */}
|
||||
<div className="absolute bottom-0 left-0 right-0 px-5 pb-3 pt-8 [text-shadow:0_1px_4px_rgba(0,0,0,0.7),0_2px_8px_rgba(0,0,0,0.4)]">
|
||||
<div className="flex [text-shadow:none]">
|
||||
{/* Avatar stack — clickable to open full members dialog */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMembersDialogOpen(true)}
|
||||
className="flex items-center gap-2 -ml-1 px-1 py-1 rounded-md hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors min-w-0"
|
||||
aria-label="Show all members"
|
||||
>
|
||||
<PeopleAvatarStack
|
||||
pubkeys={allMemberPubkeys}
|
||||
maxVisible={6}
|
||||
size="sm"
|
||||
className="[&_.ring-2]:ring-black/40 pointer-events-none"
|
||||
/>
|
||||
{allMemberPubkeys.length > 0 && (
|
||||
<span className="text-xs font-medium text-white/90 [text-shadow:0_1px_3px_rgba(0,0,0,0.7)] truncate">
|
||||
{allMemberPubkeys.length} member{allMemberPubkeys.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{membersLoading ? (
|
||||
<MembersSkeleton />
|
||||
) : memberSections.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
No members found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{memberSections.map(({ key, label, members }) => (
|
||||
<section key={key} className="px-5 py-4">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
{key === 'leadership' ? <Crown className="size-3.5 text-amber-500" /> : <Shield className="size-3.5" />}
|
||||
{label}
|
||||
<span className="text-muted-foreground/60 font-normal">({members.length})</span>
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{members.map((m) => {
|
||||
let roleLabel: string | undefined;
|
||||
if (m.rank === 0) {
|
||||
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
|
||||
}
|
||||
// Determine if the current user can ban this member
|
||||
const canBanMember = viewerMember
|
||||
&& m.pubkey !== user?.pubkey
|
||||
&& canBanTarget(viewerMember, m);
|
||||
return (
|
||||
<PersonRow
|
||||
key={m.pubkey}
|
||||
pubkey={m.pubkey}
|
||||
label={roleLabel}
|
||||
size="sm"
|
||||
onBan={canBanMember ? () => {
|
||||
setBanTargetPubkey(m.pubkey);
|
||||
setBanDialogOpen(true);
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-end justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<h2 className="text-xl font-bold text-white leading-tight sm:text-2xl truncate">{name}</h2>
|
||||
{descriptionExpandable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionDialogOpen(true)}
|
||||
className="-my-1 -mr-1 p-1 rounded-full text-white/75 hover:text-white hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors"
|
||||
aria-label="About this community"
|
||||
>
|
||||
<Info className="size-4 [text-shadow:none] drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]" />
|
||||
</button>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
{/* ── Chat tab ── */}
|
||||
{/* Banner action row — MembersOnly + Share + overflow menu (Unfollow / Edit) */}
|
||||
<div className="flex items-center gap-0.5 shrink-0 [text-shadow:none]">
|
||||
<MembersOnlyToggle
|
||||
className="text-white/90 hover:text-white hover:bg-white/15 data-[state=on]:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={bannerActionClassName}
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
>
|
||||
<Share2 className="size-5" />
|
||||
</button>
|
||||
{isFounder && community && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={bannerActionClassName}
|
||||
aria-label="More actions"
|
||||
>
|
||||
<MoreVertical className="size-5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top" sideOffset={6} className="min-w-[180px]">
|
||||
<DropdownMenuItem onSelect={() => setEditCommunityOpen(true)}>
|
||||
<Pencil className="size-4 mr-2" />
|
||||
Edit community
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<TabsList className="w-full justify-stretch rounded-none border-b border-white/15 bg-transparent p-0 h-auto">
|
||||
<TabsTrigger
|
||||
value="chat"
|
||||
className="flex-1 min-w-0 rounded-none border-b-2 border-transparent text-white/75 hover:text-white data-[state=active]:text-white data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2 [text-shadow:0_1px_3px_rgba(0,0,0,0.6)]"
|
||||
>
|
||||
<MessageCircle className="size-4 mr-1.5" />
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="activity"
|
||||
className="flex-1 min-w-0 rounded-none border-b-2 border-transparent text-white/75 hover:text-white data-[state=active]:text-white data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2 [text-shadow:0_1px_3px_rgba(0,0,0,0.6)]"
|
||||
>
|
||||
<ActivityIcon className="size-4 mr-1.5" />
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="pulse"
|
||||
className="flex-1 min-w-0 rounded-none border-b-2 border-transparent text-white/75 hover:text-white data-[state=active]:text-white data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2 [text-shadow:0_1px_3px_rgba(0,0,0,0.6)]"
|
||||
>
|
||||
<Radio className="size-4 mr-1.5" />
|
||||
Pulse
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
{/* ── /shared banner+tabs backdrop wrapper ── */}
|
||||
|
||||
{/* Sublabel for the currently-active tab. Only rendered when the
|
||||
tab has a descriptor to show — keeps the rest of the tab strip
|
||||
clean. */}
|
||||
{activeTab === 'pulse' && (
|
||||
<div className="px-5 py-2 text-xs text-muted-foreground text-center">
|
||||
What members are posting elsewhere across Nostr.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chat tab — community kind-9 messages ── */}
|
||||
<TabsContent value="chat" className="mt-0">
|
||||
{communityATag ? (
|
||||
<CommunityChatPanel
|
||||
@@ -687,109 +692,179 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Feed tab ── */}
|
||||
<TabsContent value="feed" className="mt-0">
|
||||
<CommunityMemberFeed
|
||||
authorPubkeys={allMemberPubkeys}
|
||||
isMembershipLoading={membersLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Comments tab ── */}
|
||||
<TabsContent value="comments" className="mt-0">
|
||||
{/* ── Activity tab — chronological stream of initiatives
|
||||
(goals + events) interleaved with threaded NIP-22 discussion,
|
||||
followed by past initiatives. ── */}
|
||||
<TabsContent value="activity" className="mt-0">
|
||||
<ComposeBox compact replyTo={event} />
|
||||
|
||||
{commentsLoading ? (
|
||||
{(commentsLoading || goalsLoading || eventsLoading) ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
) : membersOnly && commentsData && (commentsData.topLevelComments?.length ?? 0) > 0 ? (
|
||||
) : activityItems.length === 0 && pastInitiatives.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
No comments from community members yet. Toggle the shield icon to see all comments.
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No comments yet. Be the first to comment!
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Goals tab ── */}
|
||||
<TabsContent value="goals" className="mt-0">
|
||||
{goalsLoading ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : activeGoals.length === 0 && pastGoals.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
{membersOnly && (goals ?? []).length > 0
|
||||
? 'No goals from community members yet. Toggle the shield icon to see all goals.'
|
||||
: <>No goals yet.{user ? ' Create one to get started!' : ''}</>}
|
||||
{membersOnly && (
|
||||
(commentsData && (commentsData.topLevelComments?.length ?? 0) > 0) ||
|
||||
(goals ?? []).length > 0 ||
|
||||
(communityEvents ?? []).length > 0
|
||||
)
|
||||
? 'No activity from community members yet. Toggle the shield icon to see everything.'
|
||||
: <>No activity yet.{user ? ' Start a discussion, set a goal, or schedule an event!' : ''}</>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{/* Active goals first */}
|
||||
{activeGoals.map((e) => (
|
||||
<NoteCard key={e.id} event={e} />
|
||||
))}
|
||||
{activityItems.map((item) =>
|
||||
item.kind === 'initiative' ? (
|
||||
<NoteCard key={item.event.id} event={item.event} />
|
||||
) : (
|
||||
<ThreadedReplyList key={item.node.event.id} roots={[item.node]} />
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Past/expired goals */}
|
||||
{pastGoals.length > 0 && activeGoals.length > 0 && (
|
||||
{pastInitiatives.length > 0 && (
|
||||
<div className="px-5 pt-4 pb-1">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Past Goals
|
||||
Past
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
{pastGoals.map((e) => (
|
||||
{pastInitiatives.map((e) => (
|
||||
<NoteCard key={e.id} event={e} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Events tab ── */}
|
||||
<TabsContent value="events" className="mt-0">
|
||||
{eventsLoading ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : activeEventItems.length === 0 && pastEventItems.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
{membersOnly && (communityEvents ?? []).length > 0
|
||||
? 'No events from community members yet. Toggle the shield icon to see all events.'
|
||||
: <>No events yet.{user ? ' Create one to get started!' : ''}</>}
|
||||
</div>
|
||||
{/* ── Pulse tab — what community members are posting elsewhere
|
||||
across Nostr. Excludes events tagged with this community's
|
||||
`a` reference. ── */}
|
||||
<TabsContent value="pulse" className="mt-0">
|
||||
{communityATag ? (
|
||||
<CommunityPulsePanel
|
||||
communityATag={communityATag}
|
||||
memberPubkeys={allMemberPubkeys}
|
||||
isMembershipLoading={membersLoading}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{activeEventItems.map((e) => (
|
||||
<NoteCard key={e.id} event={e} />
|
||||
))}
|
||||
|
||||
{pastEventItems.length > 0 && activeEventItems.length > 0 && (
|
||||
<div className="px-5 pt-4 pb-1">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Past Events
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
{pastEventItems.map((e) => (
|
||||
<NoteCard key={e.id} event={e} />
|
||||
))}
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
Pulse is unavailable for this community.
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CommunityModerationContext.Provider>
|
||||
</div>
|
||||
|
||||
{/* Description dialog — opened by clicking the truncated description in
|
||||
the banner. Renders the full raw description plus a clickable
|
||||
website link when the description ends with a URL. */}
|
||||
<Dialog open={descriptionDialogOpen} onOpenChange={setDescriptionDialogOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col overflow-hidden p-0 gap-0">
|
||||
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border shrink-0">
|
||||
<DialogTitle>About {name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-5 py-4 overflow-y-auto">
|
||||
{description ? (
|
||||
<NoteContent
|
||||
event={descriptionPseudoEvent}
|
||||
className="text-sm leading-relaxed break-words"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No description provided.</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Members dialog — opened from the avatar stack in the banner. Replaces
|
||||
the former Members tab; contains badge panel, leadership +
|
||||
rank-and-file sections, and (for founders/mods) the inline
|
||||
search-and-add panel at the bottom. */}
|
||||
<Dialog open={membersDialogOpen} onOpenChange={setMembersDialogOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col overflow-hidden p-0 gap-0">
|
||||
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="size-5" />
|
||||
Members
|
||||
{allMemberPubkeys.length > 0 && (
|
||||
<span className="text-muted-foreground font-normal text-sm">({allMemberPubkeys.length})</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{community && (
|
||||
<section className="border-b border-border px-5 py-4">
|
||||
<CommunityBadgePanel
|
||||
communityEvent={event}
|
||||
community={community}
|
||||
isFounder={isFounder}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{membersLoading ? (
|
||||
<MembersSkeleton />
|
||||
) : memberSections.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
No members found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{memberSections.map(({ key, label, members }) => (
|
||||
<section key={key} className="px-5 py-4">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
{key === 'leadership' ? <Crown className="size-3.5 text-amber-500" /> : <Shield className="size-3.5" />}
|
||||
{label}
|
||||
<span className="text-muted-foreground/60 font-normal">({members.length})</span>
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{members.map((m) => {
|
||||
let roleLabel: string | undefined;
|
||||
if (m.rank === 0) {
|
||||
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
|
||||
}
|
||||
const canBanMember = viewerMember
|
||||
&& m.pubkey !== user?.pubkey
|
||||
&& canBanTarget(viewerMember, m);
|
||||
return (
|
||||
<PersonRow
|
||||
key={m.pubkey}
|
||||
pubkey={m.pubkey}
|
||||
label={roleLabel}
|
||||
size="sm"
|
||||
onBan={canBanMember ? () => {
|
||||
setBanTargetPubkey(m.pubkey);
|
||||
setBanDialogOpen(true);
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canAddMembers && community && (
|
||||
<div className="border-t border-border px-5 py-4 shrink-0 max-h-[50vh] overflow-y-auto">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
<UserPlus className="size-3.5" />
|
||||
Add members
|
||||
</h3>
|
||||
<AddMemberPanel
|
||||
communityEvent={event}
|
||||
community={community}
|
||||
isFounder={isFounder}
|
||||
existingMemberPubkeys={allMemberPubkeys}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Member ban confirmation dialog */}
|
||||
{banTargetPubkey && communityATag && (
|
||||
@@ -830,18 +905,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add member dialog */}
|
||||
{canAddMembers && community && (
|
||||
<AddMemberDialog
|
||||
open={addMemberOpen}
|
||||
onOpenChange={setAddMemberOpen}
|
||||
communityEvent={event}
|
||||
community={community}
|
||||
isFounder={isFounder}
|
||||
existingMemberPubkeys={allMemberPubkeys}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit community dialog — founder only */}
|
||||
{isFounder && community && (
|
||||
<CreateCommunityDialog
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* CommunityPulsePanel
|
||||
*
|
||||
* "Pulse" tab on the community detail page — an infinite-scrolling feed of
|
||||
* posts published by community members *outside* this community. The intent
|
||||
* is to surface what members are sharing in the wider Nostr ecosystem, as
|
||||
* opposed to the in-community Activity tab.
|
||||
*
|
||||
* Implementation notes:
|
||||
* - Authors come from the community `rankMap` (founders + moderators +
|
||||
* members). Without authors the relay would return the entire global
|
||||
* timeline.
|
||||
* - Kinds come from `getEnabledFeedKinds(feedSettings)` so the feed
|
||||
* respects the user's "Notes / Articles / Reposts / etc." preferences,
|
||||
* exactly like the home feed.
|
||||
* - Events tagged with this community's `a` reference are dropped — those
|
||||
* belong on the Activity tab.
|
||||
* - Replies (NIP-10 / NIP-22) are dropped so the Pulse reads like a
|
||||
* timeline of top-level posts, not threaded responses.
|
||||
* - Mute list, content-warning, and repost unwrap behavior come for free
|
||||
* by reusing `useTabFeed` + the `feedUtils` helpers.
|
||||
*/
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
import { useTabFeed } from '@/hooks/useProfileFeed';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { isReplyEvent } from '@/lib/nostrEvents';
|
||||
|
||||
interface CommunityPulsePanelProps {
|
||||
/** `34550:<pubkey>:<d>` — used both for the cache key and the in-community filter. */
|
||||
communityATag: string;
|
||||
/** Author allowlist — founders + moderators + members. */
|
||||
memberPubkeys: string[];
|
||||
/** True while membership is still resolving; suppresses an empty-state flash. */
|
||||
isMembershipLoading: boolean;
|
||||
}
|
||||
|
||||
export function CommunityPulsePanel({
|
||||
communityATag,
|
||||
memberPubkeys,
|
||||
isMembershipLoading,
|
||||
}: CommunityPulsePanelProps) {
|
||||
const { muteItems } = useMuteList();
|
||||
const { ref: sentinelRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
|
||||
// Build the TabFeed filter — kinds default to the user's enabled feed kinds
|
||||
// (handled inside useTabFeed when `kinds` is omitted from the filter).
|
||||
const filter = useMemo<NostrFilter | null>(
|
||||
() => (memberPubkeys.length > 0 ? { authors: memberPubkeys } : null),
|
||||
[memberPubkeys],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTabFeed(filter, `community-pulse-${communityATag}`, memberPubkeys.length > 0);
|
||||
|
||||
// Fetch next page when the sentinel scrolls into view.
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
/**
|
||||
* Drop events that reference *this* community via an `a` tag — they belong
|
||||
* to the Activity tab, not Pulse. We check both the original event and the
|
||||
* embedded event of a repost.
|
||||
*/
|
||||
const referencesThisCommunity = (tags: string[][]): boolean => {
|
||||
for (const tag of tags) {
|
||||
if (tag[0] === 'a' && tag[1] === communityATag) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Flatten pages, dedupe, and apply mute / content-warning / reply /
|
||||
// in-community filters.
|
||||
const feedItems = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
return data.pages
|
||||
.flatMap((page) => page.items)
|
||||
.filter((item) => {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
|
||||
if (shouldHideFeedEvent(item.event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
|
||||
// Hide replies on original (non-repost) text notes; a repost of a
|
||||
// reply is still a legitimate top-level surface.
|
||||
if (item.event.kind === 1 && !item.repostedBy && isReplyEvent(item.event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drop anything authored against this community — that's Activity.
|
||||
if (referencesThisCommunity(item.event.tags)) return false;
|
||||
if (item.repostEvent && referencesThisCommunity(item.repostEvent.tags)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
// `referencesThisCommunity` and `communityATag` referenced via closure —
|
||||
// adding `communityATag` to deps is sufficient.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.pages, muteItems, communityATag]);
|
||||
|
||||
// ── States ────────────────────────────────────────────────────────────────
|
||||
if (memberPubkeys.length === 0 && !isMembershipLoading) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
No community members yet — nothing to surface here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ((isLoading || isMembershipLoading) && feedItems.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-11 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm px-5">
|
||||
No posts from community members elsewhere yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{feedItems.map((item) => (
|
||||
<NoteCard
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-6">
|
||||
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,11 +55,22 @@ export function CommunityStatsPanel({ countryCode, className, compact = false }:
|
||||
|
||||
const [tf, setTf] = useState<StatsTimeframe>('7d');
|
||||
|
||||
if (isLoading && !stats) return <PanelSkeleton className={className} />;
|
||||
if (isLoading && !stats) return <PanelSkeleton className={className} compact={compact} />;
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<section className={cn('rounded-2xl border border-border bg-background/40 p-4 space-y-4', className)}>
|
||||
<section
|
||||
className={cn(
|
||||
// Standalone usage gets a card-style border. Compact usage is
|
||||
// embedded inside another bordered surface (the world discovery
|
||||
// modal / docked panel) where an extra border produces a
|
||||
// box-in-a-box look — drop it and rely on spacing alone.
|
||||
compact
|
||||
? 'space-y-4'
|
||||
: 'rounded-2xl border border-border bg-background/40 p-4 space-y-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<PanelHeader stats={stats} timeframe={tf} onTimeframeChange={setTf} />
|
||||
<AggregateCounts stats={stats} timeframe={tf} compact={compact} />
|
||||
<Leaderboards stats={stats} timeframe={tf} compact={compact} />
|
||||
@@ -371,9 +382,16 @@ function RankBadge({ rank }: { rank: number }) {
|
||||
|
||||
// ── Skeleton ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PanelSkeleton({ className }: { className?: string }) {
|
||||
function PanelSkeleton({ className, compact }: { className?: string; compact?: boolean }) {
|
||||
return (
|
||||
<section className={cn('rounded-2xl border border-border bg-background/40 p-4 space-y-4', className)}>
|
||||
<section
|
||||
className={cn(
|
||||
compact
|
||||
? 'space-y-4'
|
||||
: 'rounded-2xl border border-border bg-background/40 p-4 space-y-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
|
||||
@@ -236,7 +236,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hideCompose && <ComposeBox compact />}
|
||||
{!hideCompose && <ComposeBox compact hideBorder />}
|
||||
|
||||
{/* Tabs (logged in) */}
|
||||
{user && (
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { FabButton } from '@/components/FabButton';
|
||||
import type { FabMenuItem } from '@/contexts/LayoutContext';
|
||||
|
||||
// Lazy-load the compose modal (pulls in emoji-mart ~620K)
|
||||
const ReplyComposeModal = lazy(() => import('@/components/ReplyComposeModal').then(m => ({ default: m.ReplyComposeModal })));
|
||||
@@ -24,18 +26,74 @@ interface FloatingComposeButtonProps {
|
||||
onFabClick?: () => void;
|
||||
/** If set, overrides the default Plus icon. */
|
||||
icon?: React.ReactNode;
|
||||
/** If set, the FAB opens an anchored popover with these items. */
|
||||
menu?: FabMenuItem[];
|
||||
}
|
||||
|
||||
export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: FloatingComposeButtonProps) {
|
||||
export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }: FloatingComposeButtonProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [comingSoonOpen, setComingSoonOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderedIcon = icon ?? <Plus strokeWidth={4} size={16} />;
|
||||
|
||||
// ── Menu mode — anchor a Popover to the FAB itself ────────────────────────
|
||||
if (menu && menu.length > 0) {
|
||||
return (
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
|
||||
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-primary rounded-full" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
|
||||
{renderedIcon}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className="w-auto min-w-[180px] p-1.5 rounded-2xl"
|
||||
>
|
||||
<div role="menu" aria-label="Add" className="flex flex-col gap-0.5">
|
||||
{menu.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
className="group flex items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground focus-visible:bg-primary focus-visible:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors text-left"
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="text-primary shrink-0 group-hover:text-primary-foreground group-focus-visible:text-primary-foreground transition-colors">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (onFabClick) {
|
||||
onFabClick();
|
||||
@@ -52,7 +110,7 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: Floa
|
||||
<>
|
||||
<FabButton
|
||||
onClick={handleClick}
|
||||
icon={icon ?? <Plus strokeWidth={4} size={16} />}
|
||||
icon={renderedIcon}
|
||||
/>
|
||||
|
||||
{/* Kind 1: Compose modal (lazy-loaded) */}
|
||||
|
||||
@@ -29,6 +29,16 @@ interface FollowToggleButtonProps {
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
/** Disable the button. */
|
||||
disabled?: boolean;
|
||||
/** Optional leading icon shown before the label. */
|
||||
icon?: React.ReactNode;
|
||||
/** Optional leading icon shown when the target is followed (overrides `icon`). */
|
||||
followingIcon?: React.ReactNode;
|
||||
/**
|
||||
* If true, the followed state shows "Following" by default and swaps to
|
||||
* "Unfollow" on hover/focus (Twitter-style). When false (default), the
|
||||
* followed state shows "Unfollow" directly.
|
||||
*/
|
||||
hoverToUnfollow?: boolean;
|
||||
}
|
||||
|
||||
export function FollowToggleButton({
|
||||
@@ -38,21 +48,42 @@ export function FollowToggleButton({
|
||||
className,
|
||||
size = 'sm',
|
||||
disabled = false,
|
||||
icon,
|
||||
followingIcon,
|
||||
hoverToUnfollow = false,
|
||||
}: FollowToggleButtonProps) {
|
||||
const leadingIcon = isFollowing ? (followingIcon ?? icon) : icon;
|
||||
const followedLabel = (
|
||||
isPending
|
||||
? '...'
|
||||
: isFollowing
|
||||
? hoverToUnfollow
|
||||
// Two spans crossfade on hover/focus via group state — keeps button width stable.
|
||||
? (
|
||||
<>
|
||||
<span className="group-hover:hidden group-focus-visible:hidden">Following</span>
|
||||
<span className="hidden group-hover:inline group-focus-visible:inline">Unfollow</span>
|
||||
</>
|
||||
)
|
||||
: 'Unfollow'
|
||||
: 'Follow'
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size={size}
|
||||
variant={isFollowing ? 'outline' : 'default'}
|
||||
className={cn(
|
||||
'rounded-full font-bold',
|
||||
'group rounded-full font-bold gap-1.5',
|
||||
isFollowing && 'bg-transparent border border-border text-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled || isPending}
|
||||
>
|
||||
{isPending ? '...' : isFollowing ? 'Unfollow' : 'Follow'}
|
||||
{!isPending && leadingIcon}
|
||||
{followedLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ function PageSkeleton() {
|
||||
|
||||
/** Inner component that reads layout options from the context store. */
|
||||
function MainLayoutInner() {
|
||||
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
|
||||
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||
const centerColumnRef = useRef<HTMLDivElement>(null);
|
||||
@@ -98,7 +98,7 @@ function MainLayoutInner() {
|
||||
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
|
||||
<div className="flex justify-end pr-4">
|
||||
<div className="pointer-events-auto">
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} />
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +128,7 @@ function MainLayoutInner() {
|
||||
style={navHidden ? { transform: `translateY(calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))` } : undefined}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} />
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, Home, Search, User } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Bell, Earth, Search, Users } from 'lucide-react';
|
||||
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { selectionChanged } from '@/lib/haptics';
|
||||
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { getSidebarItem } from '@/lib/sidebarItems';
|
||||
@@ -20,20 +19,51 @@ const hiddenStyle: React.CSSProperties = {
|
||||
transform: `translateY(calc(100% + ${ARC_UP_OVERHANG_PX}px))`,
|
||||
};
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
active: boolean;
|
||||
badge?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
to?: string;
|
||||
/** 'sm' shrinks the slot (smaller flex basis + smaller icon/label) for outer items. */
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
/** A side item in the bottom nav row. */
|
||||
function NavItem({ icon: Icon, label, active, badge, onClick, to, size = 'md' }: NavItemProps) {
|
||||
const isSm = size === 'sm';
|
||||
const className = cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 py-2 transition-colors min-w-0',
|
||||
isSm ? 'flex-[0.7]' : 'flex-1',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
);
|
||||
const inner = (
|
||||
<>
|
||||
<span className="relative">
|
||||
<Icon className={isSm ? 'size-4' : 'size-5'} />
|
||||
{badge && (
|
||||
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
<span className={cn('font-medium truncate', isSm ? 'text-[9px]' : 'text-[10px]')}>{label}</span>
|
||||
</>
|
||||
);
|
||||
if (to) return <Link to={to} onClick={onClick} className={className}>{inner}</Link>;
|
||||
return <button onClick={onClick} className={className}>{inner}</button>;
|
||||
}
|
||||
|
||||
export function MobileBottomNav() {
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { user, metadata } = useCurrentUser();
|
||||
const { user } = useCurrentUser();
|
||||
const hasUnread = useHasUnreadNotifications();
|
||||
const { scrollContainer, noArcs } = useLayoutSnapshot();
|
||||
const { hidden } = useScrollDirection(scrollContainer);
|
||||
const profileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
||||
|
||||
const { config } = useAppContext();
|
||||
const homeItem = getSidebarItem(config.homePage);
|
||||
const HomeIcon = homeItem?.icon ?? Home;
|
||||
const homeLabel = homeItem?.label ?? 'Home';
|
||||
const homePath = homeItem?.path;
|
||||
const homePath = homeItem?.path ?? '/';
|
||||
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
@@ -43,11 +73,24 @@ export function MobileBottomNav() {
|
||||
setSearchOpen((v) => !v);
|
||||
}, []);
|
||||
|
||||
const handleFeedClick = useCallback((e: React.MouseEvent) => {
|
||||
selectionChanged();
|
||||
setSearchOpen(false);
|
||||
if (location.pathname === '/' || location.pathname === homePath) {
|
||||
e.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
void queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['ditto-curated-feed'] });
|
||||
}
|
||||
}, [location.pathname, homePath, queryClient]);
|
||||
|
||||
// Hide the nav when search sheet is open so it doesn't compete for space
|
||||
const isHidden = hidden || searchOpen;
|
||||
|
||||
const displayName = metadata?.name || metadata?.display_name;
|
||||
const isOnProfile = user && location.pathname === profileUrl;
|
||||
const isOnFeed = location.pathname === '/' || location.pathname === homePath;
|
||||
const isOnCommunities = location.pathname === '/communities' || location.pathname.startsWith('/communities/');
|
||||
const isOnWorld = location.pathname === '/world' || location.pathname.startsWith('/world/');
|
||||
const isOnNotifications = location.pathname === '/notifications';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -63,91 +106,79 @@ export function MobileBottomNav() {
|
||||
{/* Arc + items wrapper */}
|
||||
<div className="relative">
|
||||
<ArcBackground variant={noArcs ? 'rect' : 'up'} />
|
||||
<div className="h-11 flex items-center relative">
|
||||
<div className="h-12 flex items-end pb-0 relative translate-y-2">
|
||||
|
||||
{/* Home */}
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => {
|
||||
selectionChanged();
|
||||
setSearchOpen(false);
|
||||
// When already on the home page, scroll to top and refresh the feed
|
||||
if (location.pathname === '/' || location.pathname === homePath) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
void queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['ditto-curated-feed'] });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
(location.pathname === '/' || location.pathname === homePath) ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<HomeIcon className="size-5" />
|
||||
<span className="text-[10px] font-medium">{homeLabel}</span>
|
||||
</Link>
|
||||
{/* Search */}
|
||||
<NavItem
|
||||
icon={Search}
|
||||
label="Search"
|
||||
active={searchOpen}
|
||||
onClick={handleSearchClick}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<button
|
||||
onClick={handleSearchClick}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
searchOpen ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Search className="size-5" />
|
||||
<span className="text-[10px] font-medium">Search</span>
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
{user && (
|
||||
<Link
|
||||
to="/notifications"
|
||||
{/* Communities */}
|
||||
<NavItem
|
||||
icon={Users}
|
||||
label="Communities"
|
||||
active={isOnCommunities}
|
||||
to="/communities"
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
location.pathname === '/notifications' ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="relative">
|
||||
<Bell className="size-5" />
|
||||
{hasUnread && (
|
||||
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium">Notifications</span>
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Profile */}
|
||||
{user ? (
|
||||
<Link
|
||||
to={profileUrl}
|
||||
{/* Center spacer — reserved for the apex Feed button */}
|
||||
<div className="flex-[0.4]" aria-hidden="true" />
|
||||
|
||||
{/* Notifications */}
|
||||
{user ? (
|
||||
<NavItem
|
||||
icon={Bell}
|
||||
label="Notifications"
|
||||
active={isOnNotifications}
|
||||
badge={hasUnread}
|
||||
to="/notifications"
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{/* World */}
|
||||
<NavItem
|
||||
icon={Earth}
|
||||
label="World"
|
||||
active={isOnWorld}
|
||||
to="/world"
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
isOnProfile ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName?.[0]?.toUpperCase() || <User className="size-3" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[10px] font-medium">Profile</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors text-muted-foreground"
|
||||
>
|
||||
<User className="size-5" />
|
||||
<span className="text-[10px] font-medium">Profile</span>
|
||||
</Link>
|
||||
)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Apex Feed button — Agora bolt mark cradled in the V notch, with label below. */}
|
||||
<Link
|
||||
to={homePath}
|
||||
onClick={handleFeedClick}
|
||||
aria-label={homeItem?.label ?? 'Feed'}
|
||||
className={cn(
|
||||
'absolute left-1/2 -translate-x-1/2 z-10 -top-6',
|
||||
'flex flex-col items-center gap-3',
|
||||
'transition-transform hover:scale-105 active:scale-95',
|
||||
)}
|
||||
>
|
||||
<AgoraBoltIcon
|
||||
className={cn(
|
||||
'size-16 drop-shadow-md',
|
||||
isOnFeed && 'drop-shadow-[0_0_8px_hsl(var(--primary)/0.6)]',
|
||||
)}
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-[10px] font-semibold leading-none',
|
||||
isOnFeed ? 'text-primary' : 'text-foreground',
|
||||
)}>
|
||||
{homeItem?.label ?? 'Feed'}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Safe area fill — matches the arc's semi-transparent background */}
|
||||
<div className="safe-area-bottom bg-background/85" />
|
||||
|
||||
@@ -11,7 +11,7 @@ interface MobileTopBarProps {
|
||||
hasSubHeader?: boolean;
|
||||
}
|
||||
|
||||
export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps) {
|
||||
export function MobileTopBar({ onAvatarClick, hasSubHeader: _hasSubHeader }: MobileTopBarProps) {
|
||||
const location = useLocation();
|
||||
const navHidden = useNavHidden();
|
||||
|
||||
@@ -34,7 +34,7 @@ export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps)
|
||||
/>
|
||||
{/* Relative wrapper so ArcBackground only covers the content area, not the safe-area padding above it. */}
|
||||
<div className="relative">
|
||||
<ArcBackground variant={hasSubHeader ? 'rect' : 'down'} />
|
||||
<ArcBackground variant="rect" />
|
||||
<div className="relative flex items-center px-3 h-10">
|
||||
{/* Left: hamburger menu icon */}
|
||||
<div className="flex items-center justify-center w-7 shrink-0">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { ArcBackground } from '@/components/ArcBackground';
|
||||
import { useNavHidden } from '@/contexts/LayoutContext';
|
||||
import { SubHeaderBarContext } from '@/components/SubHeaderBarContext';
|
||||
|
||||
@@ -30,7 +30,7 @@ interface SubHeaderBarProps {
|
||||
* Used by all tab bars (Feed, Search, Notifications, etc.) and the MobileTopBar
|
||||
* fallback arc.
|
||||
*/
|
||||
export function SubHeaderBar({ children, className, innerClassName, noArc, pinned }: SubHeaderBarProps) {
|
||||
export function SubHeaderBar({ children, className, innerClassName, noArc: _noArc, pinned }: SubHeaderBarProps) {
|
||||
const [hover, setHover] = useState<HoverSlice | null>(null);
|
||||
const [active, setActive] = useState<HoverSlice | null>(null);
|
||||
const navHidden = useNavHidden();
|
||||
@@ -125,38 +125,35 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
style={{ height: 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Inner wrapper so ArcBackground covers only the tab area, not the safe-area padding above.
|
||||
sidebar:pt-2 adds desktop top padding inside the arc rather than outside it. */}
|
||||
<div className="relative sidebar:pt-2">
|
||||
<ArcBackground variant={noArc ? 'rect' : 'down'} />
|
||||
{/* Per-tab arc hover highlight: full-width arc, clipped to the hovered tab's x-slice */}
|
||||
{hover && !noArc && (
|
||||
{/* Inner wrapper holds the ArcBackground and tab content. */}
|
||||
<div className="relative">
|
||||
<ArcBackground variant="rect" />
|
||||
{/* Per-tab hover highlight: a flat-bottomed slab clipped to the hovered tab's x-slice */}
|
||||
{hover && (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="absolute top-0 left-0 w-full pointer-events-none"
|
||||
className="absolute top-0 left-0 w-full h-full pointer-events-none"
|
||||
style={{
|
||||
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
|
||||
clipPath: `inset(0 calc(100% - ${hover.left + hover.width}px) 0 ${hover.left}px)`,
|
||||
}}
|
||||
viewBox="0 0 100 64"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d="M0,0 L100,0 L100,44 Q50,64 0,44 Z" className="fill-secondary/40" />
|
||||
<path d="M0,0 L100,0 L100,64 L0,64 Z" className="fill-secondary/40" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Active tab indicator: the arc's bottom edge as a stroke, clipped to the active tab's x-slice */}
|
||||
{active && !noArc && (
|
||||
{/* Active tab indicator: a flat underline along the bottom edge, clipped to the active tab's x-slice */}
|
||||
{active && (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="absolute top-0 left-0 w-full pointer-events-none"
|
||||
className="absolute top-0 left-0 w-full h-full pointer-events-none"
|
||||
style={{
|
||||
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
|
||||
clipPath: `inset(0 calc(100% - ${active.left + active.width}px) 0 ${active.left}px)`,
|
||||
}}
|
||||
viewBox="0 0 100 64"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d="M100,44 Q50,64 0,44" fill="none" className="stroke-primary" strokeWidth="3" />
|
||||
<path d="M0,62 L100,62" fill="none" className="stroke-primary" strokeWidth="3" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Tab content sits above the SVG background */}
|
||||
@@ -174,7 +171,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}
|
||||
className={cn('relative flex overflow-x-auto scrollbar-none py-1', innerClassName)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Agora lightning-bolt icon — a stylized double-bolt mark in primary brand orange.
|
||||
* The artwork uses fixed brand colors and gradients (it's a logo mark, not a
|
||||
* monochrome icon), so it ignores `currentColor`. Pass `className` to size it
|
||||
* (e.g. `size-6`).
|
||||
*/
|
||||
export const AgoraBoltIcon = React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 720 880"
|
||||
fill="none"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<g filter="url(#agora_bolt_inner_shadow)">
|
||||
<path
|
||||
d="M415.596 0L0 417.12L284.123 702.287L346.922 468.3H189.533L415.596 0Z"
|
||||
fill="url(#agora_bolt_grad_a)"
|
||||
fillOpacity="0.9"
|
||||
/>
|
||||
<path
|
||||
d="M415.596 0L0 417.12L284.123 702.287L346.922 468.3H189.533L415.596 0Z"
|
||||
fill="#FF6600"
|
||||
/>
|
||||
<path
|
||||
d="M431.879 127.936L363.762 381.328H527.876L258.808 880L720 417.114L431.879 127.936Z"
|
||||
fill="url(#agora_bolt_grad_b)"
|
||||
fillOpacity="0.9"
|
||||
/>
|
||||
<path
|
||||
d="M431.879 127.936L363.762 381.328H527.876L258.808 880L720 417.114L431.879 127.936Z"
|
||||
fill="#FF6600"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="agora_bolt_inner_shadow"
|
||||
x="0"
|
||||
y="0"
|
||||
width="720"
|
||||
height="914"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="34" />
|
||||
<feGaussianBlur stdDeviation="27" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0"
|
||||
/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="7" />
|
||||
<feGaussianBlur stdDeviation="0.5" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0"
|
||||
/>
|
||||
<feBlend mode="normal" in2="effect1_innerShadow" result="effect2_innerShadow" />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="agora_bolt_grad_a"
|
||||
x1="-19.0481"
|
||||
y1="318.823"
|
||||
x2="373.469"
|
||||
y2="591.355"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="white" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="agora_bolt_grad_b"
|
||||
x1="346.531"
|
||||
y1="288.645"
|
||||
x2="739.048"
|
||||
y2="561.177"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="white" />
|
||||
<stop offset="1" stopColor="white" stopOpacity="0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
);
|
||||
|
||||
AgoraBoltIcon.displayName = 'AgoraBoltIcon';
|
||||
@@ -37,7 +37,7 @@ export function CountryBrowser({ gridClassName, className }: CountryBrowserProps
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col min-h-0', className)}>
|
||||
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur px-4 py-2 border-b border-border/40">
|
||||
<div className="sticky top-0 z-10 bg-background px-4 py-2 border-b border-border/40">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Drawer as Vaul } from 'vaul';
|
||||
import { ChevronUp, Globe2, Flame } from 'lucide-react';
|
||||
import { Compass, Flame, Globe2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { CommunityStatsPanel } from '@/components/CommunityStatsPanel';
|
||||
import { COUNTRIES } from '@/lib/countries';
|
||||
import { CountryBrowser } from './CountryBrowser';
|
||||
@@ -9,49 +15,32 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
const TOTAL_COUNTRIES = Object.keys(COUNTRIES).length;
|
||||
|
||||
type SnapPoint = string | number;
|
||||
|
||||
// Peek is intentionally generous (~160px) so the strip of trending country
|
||||
// chips inside it is visible without dragging — that gives the bottom sheet
|
||||
// real visual weight and an immediate reason to engage.
|
||||
const SNAP_PEEK: SnapPoint = '168px';
|
||||
const SNAP_MID: SnapPoint = 0.55;
|
||||
const SNAP_FULL: SnapPoint = 0.92;
|
||||
const SNAP_POINTS: SnapPoint[] = [SNAP_PEEK, SNAP_MID, SNAP_FULL];
|
||||
|
||||
interface WorldDiscoveryDrawerProps {
|
||||
/**
|
||||
* Element the drawer should portal into. The drawer renders as
|
||||
* `position: absolute` inside this container, so passing the page wrapper
|
||||
* keeps the drawer scoped to the center column on desktop instead of
|
||||
* stretching across the full viewport. Set to `null` to defer rendering
|
||||
* until the container ref is attached.
|
||||
*/
|
||||
container: HTMLElement | null;
|
||||
interface WorldDiscoveryModalProps {
|
||||
/**
|
||||
* Per-country activity counts derived from the trusted global stats
|
||||
* snapshot. Used to surface the top-N hottest countries as instant-tap
|
||||
* chips in the peek state. Pass `undefined` while loading.
|
||||
* snapshot. Drives the "trending" chips at the top and the active-country
|
||||
* badge on the floating button.
|
||||
*/
|
||||
activities?: Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent bottom sheet that surfaces the global stats snapshot, country
|
||||
* search, and the A–Z grid as a single discovery surface alongside the
|
||||
* full-bleed world map. The peek snap keeps the entry point visible at all
|
||||
* times so neither the stats nor the country list ever feels hidden.
|
||||
* Mobile / tablet (sub-sidebar breakpoint) discovery surface for the
|
||||
* `/world` map. Replaces the previous persistent bottom drawer with a
|
||||
* compact floating button that opens a centered information-style modal
|
||||
* on demand — the map stays fully visible until the user explicitly opts
|
||||
* into discovery, instead of having half the screen permanently occupied
|
||||
* by a sheet.
|
||||
*
|
||||
* Built on vaul primitives (rather than the shared shadcn `Drawer` wrapper)
|
||||
* because we need a non-modal, non-dismissible, snap-point sheet — the
|
||||
* shared wrapper is hardcoded for modal dialogs with an overlay.
|
||||
* Above the `sidebar` breakpoint (900px) the docked `WorldDiscoveryPanel`
|
||||
* takes over and this component is unmounted by `WorldPage`.
|
||||
*/
|
||||
export function WorldDiscoveryDrawer({ container, activities }: WorldDiscoveryDrawerProps) {
|
||||
const [snap, setSnap] = useState<SnapPoint | null>(SNAP_PEEK);
|
||||
export function WorldDiscoveryDrawer({ activities }: WorldDiscoveryModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Top-N hottest countries for the peek strip. Subdivision codes (e.g.
|
||||
// `US-TX`) are folded into their parent country so the strip mirrors the
|
||||
// user's mental model of the world map. Sorted by activity count desc.
|
||||
// Top-N hottest countries for the modal's trending strip. Subdivision
|
||||
// codes (e.g. `US-TX`) are folded into their parent country so the strip
|
||||
// mirrors the world map's mental model. Sorted by activity count desc.
|
||||
const topCountries = useMemo(() => {
|
||||
if (!activities || activities.size === 0) return [];
|
||||
const byCountry = new Map<string, number>();
|
||||
@@ -79,128 +68,104 @@ export function WorldDiscoveryDrawer({ container, activities }: WorldDiscoveryDr
|
||||
return set;
|
||||
}, new Set<string>()).size;
|
||||
|
||||
const isPeeking = snap === SNAP_PEEK;
|
||||
|
||||
// Defer rendering until the container ref is attached so vaul can portal
|
||||
// into a known DOM node from the first render.
|
||||
if (!container) return null;
|
||||
|
||||
return (
|
||||
<Vaul.Root
|
||||
open
|
||||
modal={false}
|
||||
dismissible={false}
|
||||
snapPoints={SNAP_POINTS}
|
||||
activeSnapPoint={snap}
|
||||
setActiveSnapPoint={setSnap}
|
||||
container={container}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<Vaul.Portal>
|
||||
<Vaul.Content
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
// Anchor inside the page wrapper. `world-drawer-anchor` handles
|
||||
// the bottom offset (clears the mobile bottom nav). The drawer
|
||||
// is `absolute` (not `fixed`) so it stays inside the column on
|
||||
// desktop instead of spanning the full viewport.
|
||||
'world-drawer-anchor absolute inset-x-0 z-30 flex flex-col rounded-t-2xl border border-border/60 bg-background/95 backdrop-blur shadow-[0_-8px_32px_rgba(0,0,0,0.12)]',
|
||||
// Snap-point sized: vaul translates this element via transform.
|
||||
'h-full max-h-[92dvh]',
|
||||
)}
|
||||
>
|
||||
{/* Drag handle — vaul ships its own [data-vaul-handle] CSS so this
|
||||
element is fully draggable to change snap points. */}
|
||||
<Vaul.Handle className="!mx-auto !mt-2.5 !h-1.5 !w-12 !rounded-full !bg-muted-foreground/40" />
|
||||
<>
|
||||
{/* Floating discovery launcher. Anchored to the top-right of the
|
||||
page wrapper at the same vertical offset as Leaflet's zoom
|
||||
controls (which dock at `top: 10px; left: 10px` inside the map
|
||||
container), so the two sit on the same horizontal line. The
|
||||
mobile top bar overlays the map column but is semi-transparent
|
||||
on the world page (`fullBleed`), so a 10px offset still keeps
|
||||
the button visually clear of the bar. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open world discovery"
|
||||
className={cn(
|
||||
'absolute right-2.5 top-2.5 z-30 flex items-center gap-2 rounded-full',
|
||||
'border border-border/60 bg-background/95 backdrop-blur',
|
||||
'px-4 py-2.5 shadow-lg hover:bg-background',
|
||||
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
{topCountries.length > 0 ? (
|
||||
<Flame className="size-4 text-primary shrink-0" />
|
||||
) : (
|
||||
<Compass className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-semibold">
|
||||
{topCountries.length > 0
|
||||
? `${totalActiveCountries.toLocaleString()} active`
|
||||
: 'Discover'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Peek header — always visible. Tapping anywhere here toggles the
|
||||
drawer between peek and mid snap so users discover the expanded
|
||||
content without needing to drag. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSnap(isPeeking ? SNAP_MID : SNAP_PEEK)}
|
||||
className="flex items-center justify-between gap-3 px-4 pt-2 pb-2 text-left transition-colors hover:bg-secondary/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{/* Tall, scrollable modal — flex column so the embedded
|
||||
`CountryBrowser` can manage its own internal scroll while the
|
||||
sticky search header stays reachable. Responsive width: hugs
|
||||
the viewport on phones, expands to a comfortable reading
|
||||
width on tablets and small laptops (where the docked
|
||||
discovery panel hasn't kicked in yet at `xl`/1280px). */}
|
||||
<DialogContent className="flex max-h-[85dvh] w-[calc(100%-1.5rem)] max-w-lg sm:max-w-xl md:max-w-2xl lg:max-w-3xl flex-col gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 pt-5 pb-3 border-b border-border/40">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
{topCountries.length > 0 ? (
|
||||
<Flame className="size-4 text-primary shrink-0" />
|
||||
) : (
|
||||
<Globe2 className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{topCountries.length > 0
|
||||
? `${totalActiveCountries.toLocaleString()} countries active right now`
|
||||
: `Explore ${TOTAL_COUNTRIES} countries`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
{isPeeking ? 'More' : 'Hide'}
|
||||
<ChevronUp
|
||||
className={cn(
|
||||
'size-4 transition-transform',
|
||||
!isPeeking && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{topCountries.length > 0
|
||||
? `${totalActiveCountries.toLocaleString()} countries active`
|
||||
: `Explore ${TOTAL_COUNTRIES} countries`}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Trending countries, community stats, and the full A–Z country browser.
|
||||
</DialogDescription>
|
||||
|
||||
{/* Vaul drawer titles are required for accessibility; visually
|
||||
hidden because the peek header above already labels it. */}
|
||||
<Vaul.Title className="sr-only">Discover countries and community stats</Vaul.Title>
|
||||
|
||||
{/* Trending strip — horizontally scrollable chips of the hottest
|
||||
countries. Visible in the peek state so the bottom sheet
|
||||
immediately shows real, tappable value rather than just a
|
||||
label. Each chip routes straight into the country feed. When
|
||||
there's no trusted snapshot yet, falls back to a search hint. */}
|
||||
<div className="px-4 pb-2 pt-0.5">
|
||||
{topCountries.length > 0 ? (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scroll-smooth">
|
||||
{topCountries.map((c) => (
|
||||
<Link
|
||||
key={c.code}
|
||||
to={`/i/iso3166:${c.code}`}
|
||||
className="group flex items-center gap-2 shrink-0 rounded-full border border-border/60 bg-card hover:border-primary/40 hover:bg-primary/5 px-3 py-1.5 transition-colors"
|
||||
>
|
||||
<span className="text-base leading-none" role="img" aria-label={`Flag of ${c.name}`}>
|
||||
{c.flag}
|
||||
</span>
|
||||
<span className="text-xs font-medium truncate max-w-[10ch] group-hover:text-foreground">
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums font-semibold text-primary">
|
||||
{c.count.toLocaleString()}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{/* Trending strip — horizontally scrollable chips of the
|
||||
hottest countries. Each chip routes straight into the
|
||||
country feed and closes the modal so the user lands on the
|
||||
destination immediately. */}
|
||||
{topCountries.length > 0 && (
|
||||
<div className="-mx-4 mt-2 overflow-x-auto px-4 pb-1">
|
||||
<div className="flex gap-2">
|
||||
{topCountries.map((c) => (
|
||||
<Link
|
||||
key={c.code}
|
||||
to={`/i/iso3166:${c.code}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="group flex shrink-0 items-center gap-2 rounded-full border border-border/60 bg-card px-3 py-1.5 transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
<span
|
||||
className="text-base leading-none"
|
||||
role="img"
|
||||
aria-label={`Flag of ${c.name}`}
|
||||
>
|
||||
{c.flag}
|
||||
</span>
|
||||
<span className="max-w-[10ch] truncate text-xs font-medium group-hover:text-foreground">
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold tabular-nums text-primary">
|
||||
{c.count.toLocaleString()}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground py-2">
|
||||
Tap to search and browse all {TOTAL_COUNTRIES} countries.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Expanded body — only mounted when the drawer is above the peek
|
||||
snap point so the peek state stays light and the heavy
|
||||
CommunityStatsPanel doesn't run its queries when the user
|
||||
might never expand the sheet. */}
|
||||
{!isPeeking && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
||||
{/* Stats snapshot first — putting it on top means the hashtag
|
||||
chips are immediately visible at the mid snap point.
|
||||
Renders nothing when no trusted snapshot exists. */}
|
||||
<div className="px-4 pt-2 pb-4">
|
||||
<CommunityStatsPanel compact />
|
||||
</div>
|
||||
{/* Search input + A–Z grid (shared with the desktop right
|
||||
column). The browser owns its own sticky search header so
|
||||
it stays reachable while the grid scrolls. */}
|
||||
<CountryBrowser gridClassName="grid-cols-3 sm:grid-cols-4 md:grid-cols-5" />
|
||||
{/* Scrollable body: stats snapshot + A–Z browser. */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<CommunityStatsPanel compact />
|
||||
</div>
|
||||
)}
|
||||
</Vaul.Content>
|
||||
</Vaul.Portal>
|
||||
</Vaul.Root>
|
||||
<CountryBrowser gridClassName="grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,13 +18,15 @@ interface WorldDiscoveryPanelProps {
|
||||
|
||||
/**
|
||||
* Desktop right-column variant of the world discovery surface. Always
|
||||
* visible alongside the full-bleed map at the `sidebar` breakpoint and up.
|
||||
* Mirrors the content of `WorldDiscoveryDrawer` (mobile) so users get the
|
||||
* same affordances regardless of device — trending countries, community
|
||||
* stats snapshot, and the full A–Z country browser.
|
||||
* visible alongside the full-bleed map at the `xl` breakpoint (1280px)
|
||||
* and up. Mirrors the content of `WorldDiscoveryDrawer` (mobile / tablet)
|
||||
* so users get the same affordances regardless of device — trending
|
||||
* countries, community stats snapshot, and the full A–Z country browser.
|
||||
*
|
||||
* Hidden below the sidebar breakpoint via `hidden sidebar:flex`; the
|
||||
* mobile bottom drawer takes over there.
|
||||
* Hidden below `xl` via `hidden xl:flex`; the floating discovery launcher
|
||||
* + modal takes over there. The cutoff sits at `xl` (not the lower
|
||||
* `sidebar` breakpoint) because the map needs at least ~700-800px of
|
||||
* horizontal room to be readable next to the 360px panel.
|
||||
*/
|
||||
export function WorldDiscoveryPanel({ activities, className }: WorldDiscoveryPanelProps) {
|
||||
// Same trending derivation as the drawer's peek strip — fold subdivision
|
||||
@@ -54,11 +56,12 @@ export function WorldDiscoveryPanel({ activities, className }: WorldDiscoveryPan
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
// `hidden sidebar:flex` keeps the panel out of the layout flow on
|
||||
// mobile so the bottom drawer is the only discovery surface there.
|
||||
// `hidden xl:flex` keeps the panel out of the layout flow below
|
||||
// 1280px so the map gets the full column width and the floating
|
||||
// discovery launcher is the only discovery surface there.
|
||||
// `sticky top-0 h-screen` pins it next to the map column without
|
||||
// affecting page scroll behavior.
|
||||
'hidden sidebar:flex flex-col w-[360px] shrink-0 h-screen sticky top-0 border-l border-border bg-background overflow-hidden',
|
||||
'hidden xl:flex flex-col w-[360px] shrink-0 h-screen sticky top-0 border-l border-border bg-background overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
aria-label="World discovery panel"
|
||||
|
||||
@@ -25,6 +25,22 @@ const POSITRON_DARK_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y
|
||||
const ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
|
||||
/**
|
||||
* Pick a sensible initial zoom so the world fills the viewport vertically
|
||||
* — at zoom 2 the world tile is 1024px tall, which leaves visible ocean
|
||||
* bands above and below the map on phones, tablets, and smaller laptops.
|
||||
* Bumping to zoom 3 (2048px tall) on sub-`xl` viewports eliminates those
|
||||
* gaps while still showing most of the globe. Wider viewports keep zoom
|
||||
* 2 because the docked discovery panel appears there and we want as
|
||||
* much of the world visible next to it.
|
||||
*
|
||||
* Matches the 1280px breakpoint used by `WorldPage` for the panel cutoff.
|
||||
*/
|
||||
function getInitialZoom(): number {
|
||||
if (typeof window === 'undefined') return 2;
|
||||
return window.innerWidth < 1280 ? 3 : 2;
|
||||
}
|
||||
|
||||
interface ActivityMarker {
|
||||
/** ISO 3166-1 / -2 code as published in the stats snapshot. */
|
||||
countryCode: string;
|
||||
@@ -535,8 +551,8 @@ export function WorldMap({
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full relative isolate">
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
center={[8, -66]}
|
||||
zoom={getInitialZoom()}
|
||||
minZoom={2}
|
||||
maxZoom={10}
|
||||
style={{ height: '100%', width: '100%', background: 'transparent' }}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
/** A single entry inside the FAB action menu. */
|
||||
export interface FabMenuItem {
|
||||
/** Stable identifier, used as React key. */
|
||||
id: string;
|
||||
/** Visible label shown next to the icon. */
|
||||
label: string;
|
||||
/** Optional leading icon. */
|
||||
icon?: React.ReactNode;
|
||||
/** Invoked when the item is selected; the menu closes automatically. */
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
/** Options that pages can set to configure the persistent MainLayout. */
|
||||
export interface LayoutOptions {
|
||||
/** Optional custom right sidebar to replace the default one */
|
||||
@@ -14,6 +26,13 @@ export interface LayoutOptions {
|
||||
onFabClick?: () => void;
|
||||
/** If set, overrides the default FAB icon (Plus). */
|
||||
fabIcon?: React.ReactNode;
|
||||
/**
|
||||
* If set, the FAB renders as a Popover trigger; tapping it reveals this
|
||||
* stack of menu items anchored to the FAB. Selecting an item closes the
|
||||
* menu and fires its `onSelect`. Mutually exclusive with `onFabClick` —
|
||||
* if both are set, the menu wins.
|
||||
*/
|
||||
fabMenu?: FabMenuItem[];
|
||||
/** Additional classes for the wrapper div */
|
||||
wrapperClassName?: string;
|
||||
/**
|
||||
@@ -80,7 +99,7 @@ export interface LayoutOptions {
|
||||
|
||||
/** All own-property keys of LayoutOptions used for shallow comparison. */
|
||||
const LAYOUT_KEYS: (keyof LayoutOptions)[] = [
|
||||
'showFAB', 'fabKind', 'fabHref', 'onFabClick', 'fabIcon',
|
||||
'showFAB', 'fabKind', 'fabHref', 'onFabClick', 'fabIcon', 'fabMenu',
|
||||
'wrapperClassName', 'rightSidebar', 'scrollContainer',
|
||||
'noOverscroll', 'noMaxWidth', 'hasSubHeader', 'noArcs',
|
||||
'hideTopBar', 'hideBottomNav',
|
||||
|
||||
+55
-5
@@ -149,11 +149,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Anchor the world discovery drawer above the mobile bottom nav so the
|
||||
bottom-nav stays usable while the drawer sits in its peek state. On
|
||||
desktop there's no bottom nav, so the drawer anchors to the viewport
|
||||
bottom. Vaul drives translation via [data-vaul-drawer] CSS, so we only
|
||||
adjust the anchor — not the transform. */
|
||||
/* Anchor world-page floating overlays (the discovery launcher button and
|
||||
the docked desktop drawer slot) above the mobile bottom nav so the
|
||||
bottom-nav stays usable. On desktop there's no bottom nav, so they
|
||||
anchor to the viewport bottom. */
|
||||
.world-drawer-anchor {
|
||||
bottom: 0;
|
||||
}
|
||||
@@ -164,7 +163,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaflet control theming — override the default white/gray controls to
|
||||
match the app's themed palette. The default `leaflet/dist/leaflet.css`
|
||||
ships with hardcoded `background: white` / dark border colors that
|
||||
clash with our themed surfaces (especially in dark mode and on the
|
||||
`/world` map where controls sit on top of the dark Positron tiles).
|
||||
|
||||
We re-style only the controls; tile rendering and marker popovers are
|
||||
untouched. Hover state uses the primary color so the controls match
|
||||
the same orange accent as buttons elsewhere. */
|
||||
.leaflet-bar {
|
||||
border: 1px solid hsl(var(--border)) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18) !important;
|
||||
border-radius: calc(var(--radius) - 0.25rem) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.leaflet-bar a,
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: hsl(var(--background) / 0.95) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
border-bottom: 1px solid hsl(var(--border)) !important;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: background-color 120ms, color 120ms;
|
||||
}
|
||||
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: hsl(var(--primary)) !important;
|
||||
color: hsl(var(--primary-foreground)) !important;
|
||||
}
|
||||
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
background-color: hsl(var(--muted)) !important;
|
||||
color: hsl(var(--muted-foreground) / 0.5) !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Attribution control (hidden on /world via `attributionControl={false}`,
|
||||
but kept consistent for any other map usage). */
|
||||
.leaflet-control-attribution {
|
||||
background-color: hsl(var(--background) / 0.85) !important;
|
||||
color: hsl(var(--muted-foreground)) !important;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
color: hsl(var(--primary)) !important;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
|
||||
+1
-9
@@ -1,7 +1,5 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Megaphone } from 'lucide-react';
|
||||
import { Feed } from '@/components/Feed';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
@@ -17,13 +15,7 @@ const Index = () => {
|
||||
|
||||
useLayoutOptions({ showFAB: true, fabKind: 1, hasSubHeader: !!user });
|
||||
|
||||
return (
|
||||
<Feed
|
||||
header={(
|
||||
<PageHeader title="Feed" icon={<Megaphone className="size-5 text-primary" />} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return <Feed />;
|
||||
};
|
||||
|
||||
export default Index;
|
||||
|
||||
+19
-17
@@ -14,14 +14,17 @@ import { ChatDialog } from '@/components/chat/ChatDialog';
|
||||
const WorldMap = lazy(() => import('@/components/world/WorldMap'));
|
||||
|
||||
/**
|
||||
* Match the `sidebar` Tailwind breakpoint (900px). At this width and above
|
||||
* the layout has room for a docked right column (`WorldDiscoveryPanel`);
|
||||
* below it we fall back to the bottom drawer (`WorldDiscoveryDrawer`).
|
||||
* Breakpoint at which the world page has room for a docked right column
|
||||
* (`WorldDiscoveryPanel`) alongside the left sidebar and a usable map.
|
||||
* Below this width we fall back to the floating discovery launcher +
|
||||
* modal so the map isn't crushed.
|
||||
*
|
||||
* Hardcoded to avoid pulling Tailwind config into the client bundle, same
|
||||
* approach as `useIsMobile`.
|
||||
* Matches the `xl` Tailwind breakpoint (1280px) — the same threshold the
|
||||
* default `WidgetSidebar` uses. The earlier `sidebar` breakpoint (900px)
|
||||
* left only ~540px of map between the 300px left rail and the 360px
|
||||
* discovery panel, which was too cramped to be useful.
|
||||
*/
|
||||
const SIDEBAR_MEDIA_QUERY = '(min-width: 900px)';
|
||||
const SIDEBAR_MEDIA_QUERY = '(min-width: 1280px)';
|
||||
|
||||
function useHasSidebar(): boolean {
|
||||
const [hasSidebar, setHasSidebar] = useState(() =>
|
||||
@@ -39,7 +42,6 @@ function useHasSidebar(): boolean {
|
||||
|
||||
export function WorldPage() {
|
||||
const { config } = useAppContext();
|
||||
const [pageEl, setPageEl] = useState<HTMLDivElement | null>(null);
|
||||
const hasSidebar = useHasSidebar();
|
||||
|
||||
useSeoMeta({
|
||||
@@ -84,10 +86,11 @@ export function WorldPage() {
|
||||
return (
|
||||
// h-dvh inside the column fills the full viewport on both mobile (where
|
||||
// the column's negative margin pulls content under the translucent top
|
||||
// bar) and desktop (where there's no top/bottom chrome). The drawer is
|
||||
// portaled inside this wrapper so it inherits the column's horizontal
|
||||
// bounds — no overlap with the docked desktop discovery panel.
|
||||
<div ref={setPageEl} className="relative w-full h-dvh overflow-hidden bg-muted/20">
|
||||
// bar) and desktop (where there's no top/bottom chrome). The floating
|
||||
// discovery button is absolutely positioned inside this wrapper so it
|
||||
// stays scoped to the column and doesn't overlap the docked desktop
|
||||
// discovery panel.
|
||||
<div className="relative w-full h-dvh overflow-hidden bg-muted/20">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="absolute inset-0">
|
||||
@@ -105,12 +108,11 @@ export function WorldPage() {
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
{/* Bottom discovery drawer — only mounted below the sidebar breakpoint.
|
||||
Above it, the docked `WorldDiscoveryPanel` (rendered as the layout's
|
||||
right sidebar) takes over and the drawer is unnecessary. We mount
|
||||
conditionally rather than CSS-hiding so vaul's drag handlers and
|
||||
listeners aren't running on desktop where the drawer is invisible. */}
|
||||
{!hasSidebar && <WorldDiscoveryDrawer container={pageEl} activities={activities} />}
|
||||
{/* Below the sidebar breakpoint, surface the discovery experience as
|
||||
a floating button + modal. Above it, the docked
|
||||
`WorldDiscoveryPanel` (rendered as the layout's right sidebar)
|
||||
takes over and this component is unmounted. */}
|
||||
{!hasSidebar && <WorldDiscoveryDrawer activities={activities} />}
|
||||
|
||||
{/* Per-geohash realtime chat. Only mounted while open so the relay
|
||||
subscription tears down cleanly when the dialog closes. */}
|
||||
|
||||
Reference in New Issue
Block a user