Anchor the community FAB menu to the FAB and merge Goals + Events

The community detail page previously fanned out the FAB into a stack of
chips positioned in the page's bottom-right corner, which drifted away
from the actual FAB on desktop (where the FAB is sticky inside the
center column). Move the menu into FloatingComposeButton itself: when a
page declares a `fabMenu` via useLayoutOptions, the FAB renders as a
Radix Popover trigger and the menu opens anchored to it on both mobile
and desktop. Hover state inverts to primary surface + foreground so
icons stop sitting on a same-color background (`--accent` mirrors
`--primary` in this theme system).

Initiatives now renders goals + events as one chronological list. The
sub-toggle is gone; active events sort ascending by start date, then
active goals by newest, then a single Past section in descending order
by closing/end timestamp.
This commit is contained in:
Chad Curtis
2026-05-14 01:03:51 -05:00
parent 719b76a362
commit 26475cf08f
4 changed files with 164 additions and 83 deletions
+81 -77
View File
@@ -406,6 +406,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 ?? [];
@@ -458,30 +489,46 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}
}, [event, toast]);
// ── FAB — visible on comments, goals, events tabs ──────────────────────────
const handleFabClick = useCallback(() => {
if (activeTab === 'comments') {
setComposeOpen(true);
} else if (activeTab === 'goals') {
setGoalDialogOpen(true);
} else if (activeTab === 'events') {
setEventDialogOpen(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 === 'comments' || activeTab === 'initiatives';
const fabIcon = activeTab === 'goals'
? <Target strokeWidth={3} size={18} />
: 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('comments');
setComposeOpen(true);
},
},
{
id: 'new-goal',
label: 'New goal',
icon: <Target className="size-4" />,
onSelect: () => {
setActiveTab('initiatives');
setGoalDialogOpen(true);
},
},
{
id: 'new-event',
label: 'New event',
icon: <CalendarDays className="size-4" />,
onSelect: () => {
setActiveTab('initiatives');
setEventDialogOpen(true);
},
},
];
}, [fabAvailable]);
useLayoutOptions({
showFAB:
activeTab === 'comments'
|| activeTab === 'goals'
|| activeTab === 'events',
onFabClick: handleFabClick,
fabIcon,
showFAB: fabAvailable,
fabMenu,
});
const moderationCtx = useMemo(
@@ -618,18 +665,11 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
Posts
</TabsTrigger>
<TabsTrigger
value="goals"
value="initiatives"
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
Initiatives
</TabsTrigger>
</TabsList>
@@ -680,70 +720,34 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
)}
</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!' : ''}</>}
</div>
) : (
<div className="divide-y divide-border">
{/* Active goals first */}
{activeGoals.map((e) => (
<NoteCard key={e.id} event={e} />
))}
{/* Past/expired goals */}
{pastGoals.length > 0 && activeGoals.length > 0 && (
<div className="px-5 pt-4 pb-1">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Past Goals
</h4>
</div>
)}
{pastGoals.map((e) => (
<NoteCard key={e.id} event={e} />
))}
</div>
)}
</TabsContent>
{/* ── Events tab ── */}
<TabsContent value="events" className="mt-0">
{eventsLoading ? (
{/* ── Initiatives tab (Goals + Events, unified list) ── */}
<TabsContent value="initiatives" className="mt-0">
{goalsLoading || eventsLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
) : activeEventItems.length === 0 && pastEventItems.length === 0 ? (
) : activeInitiatives.length === 0 && pastInitiatives.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!' : ''}</>}
{membersOnly && ((goals ?? []).length > 0 || (communityEvents ?? []).length > 0)
? 'No initiatives from community members yet. Toggle the shield icon to see all initiatives.'
: <>No initiatives yet.{user ? ' Create a goal or an event to get started!' : ''}</>}
</div>
) : (
<div className="divide-y divide-border">
{activeEventItems.map((e) => (
{activeInitiatives.map((e) => (
<NoteCard key={e.id} event={e} />
))}
{pastEventItems.length > 0 && activeEventItems.length > 0 && (
{pastInitiatives.length > 0 && activeInitiatives.length > 0 && (
<div className="px-5 pt-4 pb-1">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Past Events
Past
</h4>
</div>
)}
{pastEventItems.map((e) => (
{pastInitiatives.map((e) => (
<NoteCard key={e.id} event={e} />
))}
</div>
+60 -2
View File
@@ -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) */}
+3 -3
View File
@@ -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>
)}
+20 -1
View File
@@ -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',