Add decorative arc to logo header when no sub-header exists

Introduces a hasSubHeader layout option. Pages with their own
tab bars set hasSubHeader: true, suppressing the arc on the
mobile top bar. Pages without tab bars (Bookmarks, Trends, etc.)
get the arc on the logo header automatically, ensuring the
bottom-most header always has the curved element.
This commit is contained in:
Lemon
2026-03-18 23:41:44 -07:00
parent 852b7a79de
commit a8f2c54dac
13 changed files with 35 additions and 9 deletions
+2 -2
View File
@@ -64,7 +64,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 } = useLayoutSnapshot();
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, hasSubHeader } = useLayoutSnapshot();
const [drawerOpen, setDrawerOpen] = useState(false);
const { config } = useAppContext();
@@ -74,7 +74,7 @@ function MainLayoutInner() {
{config.magicMouse && <CursorFireEffect />}
{/* Mobile top bar - only on small screens */}
<MobileTopBar onAvatarClick={() => setDrawerOpen(true)} />
<MobileTopBar onAvatarClick={() => setDrawerOpen(true)} showArc={!hasSubHeader} />
{/* Mobile drawer */}
<MobileDrawer open={drawerOpen} onOpenChange={setDrawerOpen} />
+9 -1
View File
@@ -5,9 +5,11 @@ import { BarsStaggeredIcon } from '@/components/icons/BarsStaggeredIcon';
interface MobileTopBarProps {
onAvatarClick: () => void;
/** Whether to show the decorative arc below the header (when no sub-header exists). */
showArc?: boolean;
}
export function MobileTopBar({ onAvatarClick }: MobileTopBarProps) {
export function MobileTopBar({ onAvatarClick, showArc }: MobileTopBarProps) {
const location = useLocation();
const handleLogoClick = useCallback((e: React.MouseEvent) => {
@@ -37,6 +39,12 @@ export function MobileTopBar({ onAvatarClick }: MobileTopBarProps) {
{/* Right: spacer for symmetry */}
<div className="w-7 shrink-0" />
</div>
{/* Decorative arc — only shown when no sub-header provides its own */}
{showArc && (
<svg className="absolute left-0 right-0 top-full w-full pointer-events-none" viewBox="0 0 100 12" preserveAspectRatio="none" style={{ height: 20 }}>
<path d="M0,0 Q50,12 100,0 Z" className="fill-background/80" />
</svg>
)}
</header>
);
}
+8 -1
View File
@@ -34,6 +34,12 @@ export interface LayoutOptions {
* full-width page layouts (e.g. messaging).
*/
noMaxWidth?: boolean;
/**
* If true, indicates the page renders its own sub-header with a decorative
* arc (e.g. tab bars). The mobile top bar will skip its own arc to avoid
* doubling up.
*/
hasSubHeader?: boolean;
}
type Listener = () => void;
@@ -102,7 +108,8 @@ export function useLayoutOptions(options: LayoutOptions): void {
prev.current.rightSidebar !== options.rightSidebar ||
prev.current.scrollContainer !== options.scrollContainer ||
prev.current.noOverscroll !== options.noOverscroll ||
prev.current.noMaxWidth !== options.noMaxWidth;
prev.current.noMaxWidth !== options.noMaxWidth ||
prev.current.hasSubHeader !== options.hasSubHeader;
if (changed) {
prev.current = options;
+3
View File
@@ -18,6 +18,7 @@ import { usePrefetchBookSummaries } from '@/hooks/useBookSummary';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import type { ExtraKindDef } from '@/lib/extraKinds';
type FeedTab = 'follows' | 'global';
@@ -45,6 +46,8 @@ export function BooksPage() {
description: 'Book reviews, ratings, and discussions from the Nostr community',
});
useLayoutOptions({ hasSubHeader: true });
const feedQuery = useBookFeed(activeTab);
const {
+1 -1
View File
@@ -42,7 +42,7 @@ export function EventsFeedPage() {
const [activeTab, setActiveTab] = useFeedTab<FeedTab>('events', ['follows', 'global']);
useSeoMeta({ title: `Events | ${config.appName}` });
useLayoutOptions({ showFAB: true, fabKind: 31923 });
useLayoutOptions({ showFAB: true, fabKind: 31923, hasSubHeader: true });
// Calendar events feed
const feedQuery = useFeed(activeTab, { kinds: [31922, 31923] });
+1 -1
View File
@@ -11,7 +11,7 @@ const Index = () => {
description: 'Your content. Your vibe. Your rules.',
});
useLayoutOptions({ showFAB: true, fabKind: 1 });
useLayoutOptions({ showFAB: true, fabKind: 1, hasSubHeader: true });
return <Feed />;
};
+1 -1
View File
@@ -50,7 +50,7 @@ export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo
});
const fabClick = onFabClick ?? (resolvedDef ? () => setInfoOpen(true) : undefined);
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick });
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick, hasSubHeader: true });
const kinds = Array.isArray(kind) ? kind : [kind];
+3
View File
@@ -31,6 +31,7 @@ import { useProfileBadges } from '@/hooks/useProfileBadges';
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
import { Button } from '@/components/ui/button';
import { useLayoutOptions } from '@/contexts/LayoutContext';
type NotificationTab = 'all' | 'mentions';
@@ -42,6 +43,8 @@ export function NotificationsPage() {
description: 'Your Nostr notifications',
});
useLayoutOptions({ hasSubHeader: true });
const [activeTab, setActiveTab] = useState<NotificationTab>('all');
const { user } = useCurrentUser();
const queryClient = useQueryClient();
+1 -1
View File
@@ -43,7 +43,7 @@ export function PhotosFeedPage() {
const [activeTab, setActiveTab] = useFeedTab<FeedTab>('photos', ['follows', 'global']);
useSeoMeta({ title: `Photos | ${config.appName}`, description: 'Photo posts on Nostr' });
useLayoutOptions({ showFAB: false });
useLayoutOptions({ showFAB: false, hasSubHeader: true });
// ── Follows feed (chronological) ──
const followsQuery = useFeed('follows', { kinds: [PHOTO_KIND] });
+3
View File
@@ -49,6 +49,7 @@ import { TabButton } from '@/components/TabButton';
// getNostrIdentifierPath removed — identifiers are now handled as autocomplete suggestions
import { cn, STICKY_HEADER_CLASS, parseKindFilter } from '@/lib/utils';
import type { TabFilter } from '@/contexts/AppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { isRepostKind, parseRepostContent } from '@/lib/feedUtils';
import { nip19 } from 'nostr-tools';
@@ -91,6 +92,8 @@ export function SearchPage() {
description: 'Search Nostr',
});
useLayoutOptions({ hasSubHeader: true });
const [searchParams, setSearchParams] = useSearchParams();
// Derive tab directly from URL — single source of truth
+1
View File
@@ -47,6 +47,7 @@ export function ThemesPage() {
showFAB: activeTab === 'my-themes',
onFabClick: handleFabClick,
fabIcon: <Pencil strokeWidth={3} size={16} />,
hasSubHeader: true,
});
// Feed queries for follows/global tabs
+1 -1
View File
@@ -719,7 +719,7 @@ export function VideosFeedPage() {
const [feedTab, setFeedTab] = useFeedTab<FeedTab>('videos', ['follows', 'global']);
useSeoMeta({ title: `Videos | ${config.appName}`, description: 'Videos and live streams on Nostr' });
useLayoutOptions({ showFAB: false, noOverscroll: true });
useLayoutOptions({ showFAB: false, noOverscroll: true, hasSubHeader: true });
useEffect(() => { setShowAllVideos(false); }, [feedTab]);
// ── Follows: chronological, small page ──
+1
View File
@@ -633,6 +633,7 @@ export function VinesFeedPage() {
showFAB: false,
scrollContainer,
noOverscroll: true,
hasSubHeader: true,
});
// Track scroll direction to expand vines when bottom nav hides