Files
eranos/src/lib/sidebarItems.tsx
T
mkfain 4e9c6b37d3 Rename Support nav label to Campaigns, Organize to Groups
Touches user-facing labels only:

- TopNav: Support -> Campaigns, Organize -> Groups
- Sidebar: Organize -> Groups
- MobileBottomNav: Organize -> Groups
- /communities hero kicker: Organize -> Groups

Routes, hooks, and the country-organizers admin feature
(`OrganizersPage` / `useOrganizers` — a separate concept covering
appointed pinners for country feeds) are left alone. Code comments
referring to the "Organize hero" are kept as-is so future readers can
still find their way around by structural name.
2026-05-21 22:06:36 -05:00

359 lines
11 KiB
TypeScript

import {
Activity,
Award,
BadgeCheck,
Bell,
Bird,
Blocks,
BookMarked,
BookOpen,
Bookmark,
Bot,
CalendarDays,
Camera,
Clapperboard,
Code,
Earth,
Film,
HandHeart,
HelpCircle,
Highlighter,
LifeBuoy,
List,
MessageSquare,
MessageSquareMore,
Megaphone,
Mic,
Music,
Newspaper,
Palette,
PartyPopper,
Podcast,
Repeat2,
Search,
ScrollText,
Settings,
Smile,
SmilePlus,
Stars,
User,
Users,
Vote,
WalletMinimal,
Zap,
} from "lucide-react";
import { VERIFIED_PAGE_PATH } from "@/lib/agoraDefaults";
import { CardsIcon } from "@/components/icons/CardsIcon";
import { ChestIcon } from "@/components/icons/ChestIcon";
import { LogoIcon } from "@/components/icons/LogoIcon";
// ── Types ─────────────────────────────────────────────────────────────────────
type IconComponent = React.ComponentType<{ className?: string }>;
/** Sentinel ID used to represent a visual divider in the sidebar order. */
export const SIDEBAR_DIVIDER_ID = "divider";
/** Returns true if the given sidebar order ID is a divider sentinel. */
export function isSidebarDivider(id: string): boolean {
return id === SIDEBAR_DIVIDER_ID;
}
/** Returns true if the given sidebar order ID is a `nostr:` URI. */
export function isNostrUri(id: string): boolean {
return id.startsWith("nostr:");
}
/** Returns true if the given sidebar order ID is an `nsite://` URI. */
export function isNsiteUri(id: string): boolean {
return id.startsWith("nsite://");
}
/** Extracts the nsite subdomain from an `nsite://` URI. */
export function nsiteUriToSubdomain(uri: string): string {
return uri.slice("nsite://".length);
}
/** Extracts the NIP-19 bech32 identifier from a `nostr:` URI. Returns the raw string if not a nostr: URI. */
export function nostrUriToNip19(uri: string): string {
return uri.startsWith("nostr:") ? uri.slice(6) : uri;
}
/**
* Returns true if the given sidebar order ID is an external content identifier
* (i-tag value): an https:// URL or a prefixed identifier like `iso3166:US`.
*/
export function isExternalUri(id: string): boolean {
return (
id.startsWith("https://") ||
id.startsWith("http://") ||
id.startsWith("iso3166:") ||
id.startsWith("isbn:")
);
}
/** A sidebar-capable item with everything needed for display and navigation. */
export interface SidebarItemDef {
/** Unique identifier stored in sidebarOrder. */
id: string;
/** Display label. */
label: string;
/** Navigation path (e.g. '/feed', '/notifications', '/vines'). */
path: string;
/** Icon component. */
icon: IconComponent;
/** If true, only shown when a user is logged in. */
requiresAuth?: boolean;
/** If true, only shown to platform admins (implies requiresAuth). */
requiresAdmin?: boolean;
}
// ── Registry ──────────────────────────────────────────────────────────────────
/**
* Single source of truth for all sidebar items.
*
* Every item that can appear in the sidebar — whether it's a system page like
* "Feed" or a Nostr content type like "Vines" — lives here with a consistent
* shape. The order here is the default display order for fresh installs.
*/
export const SIDEBAR_ITEMS: SidebarItemDef[] = [
// System pages
{
id: "wallet",
label: "Wallet",
path: "/wallet",
icon: WalletMinimal,
requiresAuth: true,
},
{ id: "feed", label: "Feed", path: "/feed", icon: LogoIcon },
{ id: "campaigns", label: "Fundraisers", path: "/campaigns", icon: HandHeart },
{
id: "notifications",
label: "Notifications",
path: "/notifications",
icon: Bell,
requiresAuth: true,
},
{
id: "messages",
label: "Messages",
path: "/messages",
icon: MessageSquareMore,
},
{ id: "search", label: "Search", path: "/search", icon: Search },
{
id: "verified",
label: "Verified",
path: VERIFIED_PAGE_PATH,
icon: BadgeCheck,
},
{
id: "bookmarks",
label: "Bookmarks",
path: "/bookmarks",
icon: Bookmark,
requiresAuth: true,
},
{
id: "profile",
label: "Profile",
path: "/profile",
icon: User,
requiresAuth: true,
},
{
id: "lists",
label: "Lists",
path: "/lists",
icon: List,
requiresAuth: true,
},
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
{ id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText },
{ id: "help", label: "Help", path: "/help", icon: LifeBuoy },
{ id: "agent", label: "Agent", path: "/agent", icon: Bot },
// Content types
{ id: "actions", label: "Pledges", path: "/pledges", icon: Megaphone },
{ id: "events", label: "Events", path: "/events", icon: CalendarDays },
{ id: "photos", label: "Photos", path: "/photos", icon: Camera },
{ id: "videos", label: "Videos", path: "/videos", icon: Film },
{ id: "articles", label: "Articles", path: "/articles", icon: Newspaper },
{ id: "highlights", label: "Highlights", path: "/highlights", icon: Highlighter },
{ id: "books", label: "Books", path: "/books", icon: BookMarked },
{ id: "vines", label: "Divines", path: "/vines", icon: Clapperboard },
{ id: "music", label: "Music", path: "/music", icon: Music },
{ id: "podcasts", label: "Podcasts", path: "/podcasts", icon: Podcast },
{ id: "webxdc", label: "Webxdc", path: "/webxdc", icon: Blocks },
{ id: "polls", label: "Polls", path: "/polls", icon: Vote },
{ id: "packs", label: "Follow Packs", path: "/packs", icon: PartyPopper },
{ id: "colors", label: "Color Moments", path: "/colors", icon: Palette },
{ id: "decks", label: "Magic Decks", path: "/decks", icon: CardsIcon },
{ id: "treasures", label: "Treasures", path: "/treasures", icon: ChestIcon },
{ id: "emojis", label: "Emojis", path: "/emojis", icon: SmilePlus },
{ id: "development", label: "Development", path: "/development", icon: Code },
{ id: "badges", label: "Badges", path: "/badges", icon: Award },
{ id: "communities", label: "Groups", path: "/communities", icon: Users },
{ id: "world", label: "World", path: "/world", icon: Earth },
];
/**
* Sidebar items that are addable from their own page but omitted from the
* default/sidebar customization option list.
*/
const OPTIONAL_SIDEBAR_ITEMS: SidebarItemDef[] = [
{ id: "dashboard", label: "Dashboard", path: "/dashboard", icon: Activity },
];
const ALL_SIDEBAR_ITEMS = [...SIDEBAR_ITEMS, ...OPTIONAL_SIDEBAR_ITEMS];
/** Set of all known sidebar item IDs for quick lookup. */
export const SIDEBAR_ITEM_IDS = new Set(ALL_SIDEBAR_ITEMS.map((s) => s.id));
/** Map from ID to definition for O(1) lookup. */
const SIDEBAR_ITEM_MAP = new Map(ALL_SIDEBAR_ITEMS.map((s) => [s.id, s]));
/**
* Icons for content types used outside the sidebar (e.g. ContentSettings).
* Feed-only kinds that don't have sidebar pages are included here too.
*/
export const CONTENT_KIND_ICONS: Record<string, IconComponent> = {
posts: MessageSquare,
comments: MessageSquareMore,
reposts: Repeat2,
"generic-reposts": Repeat2,
reactions: SmilePlus,
zaps: Zap,
voice: Mic,
"custom-emojis": Smile,
statuses: SmilePlus,
"bird-detections": Bird,
constellations: Stars,
...Object.fromEntries(
SIDEBAR_ITEMS.filter((s) => s.icon).map((s) => [s.id, s.icon]),
),
videos: Film,
books: BookOpen,
vines: Clapperboard,
music: Music,
podcasts: Podcast,
webxdc: Palette,
packs: PartyPopper,
colors: Palette,
decks: CardsIcon,
treasures: ChestIcon,
emojis: SmilePlus,
development: Code,
badges: HelpCircle,
communities: Users,
world: Earth,
archive: HelpCircle,
bluesky: HelpCircle,
vanish: MessageSquareMore,
};
// ── Lookups ───────────────────────────────────────────────────────────────────
/** Get the sidebar item definition by ID, or undefined if unknown. */
export function getSidebarItem(id: string): SidebarItemDef | undefined {
return SIDEBAR_ITEM_MAP.get(id);
}
/** Returns the icon element for a sidebar item ID at the given size. */
export function sidebarItemIcon(
id: string,
size = "size-6",
): React.ReactElement {
const Icon = SIDEBAR_ITEM_MAP.get(id)?.icon ?? Palette;
return <Icon className={size} />;
}
/** Lookup display label for a sidebar item ID. */
export function itemLabel(id: string): string {
return SIDEBAR_ITEM_MAP.get(id)?.label ?? id;
}
/** Lookup navigation path for a sidebar item ID. */
export function itemPath(
id: string,
profilePath?: string,
homePage?: string,
): string {
if (id === "profile" && profilePath) return profilePath;
if (homePage && id === homePage) return "/";
return SIDEBAR_ITEM_MAP.get(id)?.path ?? `/${id}`;
}
/**
* Search sidebar items by label. Matches when the query is a prefix of the
* full label or of any word within the label (e.g. "arch" matches "Archive"
* and "Internet Archive" but not "Search"). Whole-label prefix matches are
* sorted before word-boundary matches. Auth-requiring items are excluded
* when the user is not logged in.
*/
export function searchSidebarItems(
query: string,
): SidebarItemDef[] {
const q = query.trim().toLowerCase();
if (q.length === 0) return [];
const prefixMatches: SidebarItemDef[] = [];
const wordMatches: SidebarItemDef[] = [];
for (const item of SIDEBAR_ITEMS) {
const label = item.label.toLowerCase();
if (label.startsWith(q)) {
prefixMatches.push(item);
} else {
// Check if query matches the start of any word in the label
const words = label.split(/\s+/);
if (words.some((word) => word.startsWith(q))) {
wordMatches.push(item);
}
}
}
return [...prefixMatches, ...wordMatches];
}
/** Check if a sidebar item is active given the current location. */
export function isItemActive(
id: string,
pathname: string,
_search: string,
profilePath?: string,
homePage?: string,
): boolean {
// Nostr URI items: active when pathname matches /<nip19>
if (isNostrUri(id)) {
const nip19Id = nostrUriToNip19(id);
return pathname === `/${nip19Id}`;
}
// Nsite URI items: active when the nsite preview is open for this subdomain.
// The pathname will be the naddr of the nsite event, which we can't cheaply
// derive here without async resolution. For now, nsite items are never
// highlighted as "active" via pathname — the visual indication comes from
// the nsite preview panel being open.
if (isNsiteUri(id)) {
return false;
}
// External content items: active when pathname matches /i/<encoded-value>
if (isExternalUri(id)) {
return pathname === `/i/${encodeURIComponent(id)}` || pathname === `/i/${id}`;
}
if (id === "profile") return !!profilePath && pathname === profilePath;
if (id === "settings") return pathname.startsWith("/settings");
const itemDef = SIDEBAR_ITEM_MAP.get(id);
const itemPathname = itemDef?.path ?? `/${id}`;
// Homepage item is active at both "/" and its own path
if (homePage && id === homePage)
return pathname === "/" || pathname === itemPathname;
return pathname === itemPathname;
}