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:
@@ -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>
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
Reference in New Issue
Block a user