Compare commits

...

3 Commits

7 changed files with 404 additions and 330 deletions
+24 -11
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import { GripVertical, X, Globe, BookOpen } from 'lucide-react';
import { GripVertical, X, Plus, Globe, BookOpen } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useMemo } from 'react';
@@ -18,6 +18,9 @@ export interface ExternalContentSidebarItemProps {
active: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onAdd?: (id: string) => void;
/** True when this item is below the "More..." separator (hidden zone). */
belowMore?: boolean;
onClick?: (e: React.MouseEvent) => void;
/** Extra classes on the link. */
linkClassName?: string;
@@ -66,7 +69,7 @@ function ExternalSidebarLabel({ id }: { id: string }) {
// ── Main component ────────────────────────────────────────────────────────────
export function ExternalContentSidebarItem({
id, active, editing, onRemove, onClick, linkClassName,
id, active, editing, onRemove, onAdd, belowMore, onClick, linkClassName,
}: ExternalContentSidebarItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
@@ -77,7 +80,7 @@ export function ExternalContentSidebarItem({
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85 hover:bg-secondary/40', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
@@ -93,7 +96,7 @@ export function ExternalContentSidebarItem({
to={path}
onClick={onClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
'flex items-center gap-4 py-3 rounded-full transition-colors flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
@@ -108,13 +111,23 @@ export function ExternalContentSidebarItem({
</Link>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<X className="size-4" />
</button>
belowMore ? (
<button
onClick={(e) => { e.stopPropagation(); onAdd?.(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-primary hover:bg-primary/10"
title="Add"
>
<Plus className="size-4" />
</button>
) : (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<X className="size-4" />
</button>
)
)}
</div>
);
+69 -26
View File
@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
UserPlus, LogOut,
@@ -11,7 +12,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { DittoLogo } from '@/components/DittoLogo';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { SidebarNavList } from '@/components/SidebarNavItem';
import { SidebarNavList, MORE_SEPARATOR_ID } from '@/components/SidebarNavItem';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import LoginDialog from '@/components/auth/LoginDialog';
@@ -56,7 +57,6 @@ export function LeftSidebar() {
const { startSignup } = useOnboarding();
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
// NIP-38 status
const userStatus = useUserStatus(user?.pubkey);
@@ -67,6 +67,27 @@ export function LeftSidebar() {
const homePage = config.homePage;
// In editing mode, build a combined list: visible + __more__ + hidden
const editingItems = useMemo(() => {
if (!editing) return [];
return [...visibleItems, MORE_SEPARATOR_ID, ...visibleHiddenItems.map((h) => h.id)];
}, [editing, visibleItems, visibleHiddenItems]);
const handleEditReorder = useCallback((newOrder: string[]) => {
const moreIdx = newOrder.indexOf(MORE_SEPARATOR_ID);
if (moreIdx === -1) return;
const newVisible = newOrder.slice(0, moreIdx);
updateSidebarOrder(newVisible);
}, [updateSidebarOrder]);
const handleEditRemove = useCallback((id: string, index?: number) => {
if (id === 'divider' && index !== undefined) {
removeFromSidebar(id, index);
} else {
removeFromSidebar(id);
}
}, [removeFromSidebar]);
const scrollToTopIfCurrent = useCallback((to: string) => (e: React.MouseEvent) => {
if (location.pathname === to) {
e.preventDefault();
@@ -100,29 +121,51 @@ export function LeftSidebar() {
{/* Nav */}
<nav className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<SidebarNavList
items={visibleItems}
editing={editing}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={(id) => id === homePage ? scrollToTopIfCurrent('/') : undefined}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
homePage={homePage}
/>
<SidebarMoreMenu
editing={editing}
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
homePage={homePage}
/>
{editing ? (
<>
<SidebarNavList
items={editingItems}
editing
onRemove={handleEditRemove}
onAdd={addToSidebar}
onReorder={handleEditReorder}
isActive={() => false}
homePage={homePage}
/>
<SidebarMoreMenu
editing
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
homePage={homePage}
/>
</>
) : (
<>
<SidebarNavList
items={visibleItems}
editing={false}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={(id) => id === homePage ? scrollToTopIfCurrent('/') : undefined}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
homePage={homePage}
/>
<SidebarMoreMenu
editing={false}
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
homePage={homePage}
/>
</>
)}
</nav>
{/* Logged-out join pill — same position as account button, pushed up from bottom */}
+83 -81
View File
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { useCallback, useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Bell, Home, Search, User } from 'lucide-react';
import { User } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { cn } from '@/lib/utils';
@@ -8,11 +8,12 @@ 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';
import { ArcBackground, ARC_UP_OVERHANG_PX } from '@/components/ArcBackground';
import { MobileSearchSheet } from '@/components/MobileSearchSheet';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useAppContext } from '@/hooks/useAppContext';
import { getSidebarItem, isSidebarDivider, sidebarItemIcon, itemLabel, itemPath, isItemActive } from '@/lib/sidebarItems';
/** Transform style applied when the bottom nav is hidden (scrolled away). */
const hiddenStyle: React.CSSProperties = {
@@ -26,12 +27,9 @@ export function MobileBottomNav() {
const { scrollContainer, noArcs } = useLayoutSnapshot();
const { hidden } = useScrollDirection(scrollContainer);
const profileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const { orderedItems } = useFeedSettings();
const { config } = useAppContext();
const homeItem = getSidebarItem(config.homePage);
const HomeIcon = homeItem?.icon ?? Home;
const homeLabel = homeItem?.label ?? 'Home';
const homePath = homeItem?.path;
const homePage = config.homePage;
const [searchOpen, setSearchOpen] = useState(false);
@@ -44,7 +42,15 @@ export function MobileBottomNav() {
const isHidden = hidden && !searchOpen;
const displayName = metadata?.name || metadata?.display_name;
const isOnProfile = user && location.pathname === profileUrl;
// Show only the first 4 sidebar items (matching sidebar order), filtering out dividers and auth-gated items when logged out
const allItems = useMemo(() => {
return orderedItems.filter((id) => {
if (isSidebarDivider(id)) return false;
if (!user && getSidebarItem(id)?.requiresAuth) return false;
return true;
}).slice(0, 4);
}, [orderedItems, user]);
return (
<>
@@ -61,80 +67,76 @@ export function MobileBottomNav() {
<div className="relative">
<ArcBackground variant={noArcs ? 'rect' : 'up'} />
<div className="h-11 flex items-center relative">
{allItems.map((id) => {
const isSearch = id === 'search';
const isProfile = id === 'profile';
const isNotifications = id === 'notifications';
const active = isSearch
? searchOpen
: isItemActive(id, location.pathname, location.search, profileUrl, homePage);
const label = itemLabel(id);
const path = isProfile ? profileUrl : itemPath(id, undefined, homePage);
{/* Home */}
<Link
to="/"
onClick={() => setSearchOpen(false)}
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 opens the sheet instead of navigating
if (isSearch) {
return (
<button
key={id}
onClick={handleSearchClick}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
{sidebarItemIcon(id, 'size-5')}
<span className="text-[10px] font-medium">{label}</span>
</button>
);
}
{/* 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"
onClick={() => 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}
onClick={() => 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 shape={getAvatarShape(metadata)} 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>
)}
// Profile shows the user avatar
if (isProfile && user) {
return (
<Link
key={id}
to={path}
onClick={() => setSearchOpen(false)}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
<Avatar shape={getAvatarShape(metadata)} 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">{label}</span>
</Link>
);
}
return (
<Link
key={id}
to={path}
onClick={() => setSearchOpen(false)}
className={cn(
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
<span className="relative">
{sidebarItemIcon(id, 'size-5')}
{isNotifications && hasUnread && (
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
)}
</span>
<span className="text-[10px] font-medium">{label}</span>
</Link>
);
})}
</div>
</div>
{/* Safe area spacer — fully opaque so any subpixel gap is invisible */}
+75 -31
View File
@@ -1,10 +1,10 @@
import { useState, useId, useMemo } from 'react';
import { useState, useId, useMemo, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2 } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import { SidebarNavList } from '@/components/SidebarNavItem';
import { SidebarNavList, MORE_SEPARATOR_ID } from '@/components/SidebarNavItem';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import { LoginArea } from '@/components/auth/LoginArea';
@@ -57,7 +57,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
const homePage = config.homePage;
const hasUnread = useHasUnreadNotifications();
const [editing, setEditing] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [accountExpanded, setAccountExpanded] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const { startSignup } = useOnboarding();
@@ -96,14 +96,35 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
const visibleHiddenItems = hiddenItems;
const handleClose = () => { onOpenChange(false); setMoreMenuOpen(false); };
// In editing mode, build a combined list: visible + __more__ + hidden
const editingItems = useMemo(() => {
if (!editing) return [];
return [...visibleItems, MORE_SEPARATOR_ID, ...visibleHiddenItems.map((h) => h.id)];
}, [editing, visibleItems, visibleHiddenItems]);
const handleEditReorder = useCallback((newOrder: string[]) => {
const moreIdx = newOrder.indexOf(MORE_SEPARATOR_ID);
if (moreIdx === -1) return;
const newVisible = newOrder.slice(0, moreIdx);
updateSidebarOrder(newVisible);
}, [updateSidebarOrder]);
const handleEditRemove = useCallback((id: string, index?: number) => {
if (id === 'divider' && index !== undefined) {
removeFromSidebar(id, index);
} else {
removeFromSidebar(id);
}
}, [removeFromSidebar]);
const handleClose = () => { onOpenChange(false); };
const handleLogout = async () => { await logout(); handleClose(); navigate('/'); };
const getDisplayName = (account: Account) => account.metadata.name ?? genUserName(account.pubkey);
const displayName = metadata?.name || (user ? genUserName(user.pubkey) : 'Anonymous');
return (
<>
<Sheet open={open} onOpenChange={(v) => { if (!v) setMoreMenuOpen(false); onOpenChange(v); }}>
<Sheet open={open} onOpenChange={(v) => { if (!v) { setEditing(false); } onOpenChange(v); }}>
<SheetContent side="left" className="w-[300px] p-0 gap-0 border-r-border flex flex-col overflow-visible">
{/* SVG clip path definition for the drawer + arc shape.
The clip path uses objectBoundingBox units so the arc scales with the
@@ -291,30 +312,55 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-1"
>
<div className="contents">
<SidebarNavList
items={visibleItems}
editing={editing}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={() => handleClose}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
linkClassName="text-base"
homePage={homePage}
/>
<SidebarMoreMenu
editing={editing}
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
onNavigate={handleClose}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
homePage={homePage}
/>
{editing ? (
<>
<SidebarNavList
items={editingItems}
editing
onRemove={handleEditRemove}
onAdd={addToSidebar}
onReorder={handleEditReorder}
isActive={() => false}
linkClassName="text-base"
homePage={homePage}
/>
<SidebarMoreMenu
editing
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
onNavigate={handleClose}
homePage={homePage}
/>
</>
) : (
<>
<SidebarNavList
items={visibleItems}
editing={false}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={() => handleClose}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
linkClassName="text-base"
homePage={homePage}
/>
<SidebarMoreMenu
editing={false}
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
onNavigate={handleClose}
homePage={homePage}
/>
</>
)}
</div>
</nav>
@@ -355,8 +401,6 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
onNavigate={handleClose}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
homePage={homePage}
/>
</div>
+24 -11
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import { GripVertical, X, FileText, Scroll } from 'lucide-react';
import { GripVertical, X, Plus, FileText, Scroll } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { nip19 } from 'nostr-tools';
@@ -32,6 +32,9 @@ export interface NostrEventSidebarItemProps {
active: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onAdd?: (id: string) => void;
/** True when this item is below the "More..." separator (hidden zone). */
belowMore?: boolean;
onClick?: (e: React.MouseEvent) => void;
/** Extra classes on the link. */
linkClassName?: string;
@@ -101,7 +104,7 @@ function EventSidebarLabel({ decoded }: EventSidebarLabelProps) {
// ── Main component ────────────────────────────────────────────────────────────
export function NostrEventSidebarItem({
id, active, editing, onRemove, onClick, linkClassName,
id, active, editing, onRemove, onAdd, belowMore, onClick, linkClassName,
}: NostrEventSidebarItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
@@ -121,7 +124,7 @@ export function NostrEventSidebarItem({
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85 hover:bg-secondary/40', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
@@ -137,7 +140,7 @@ export function NostrEventSidebarItem({
to={path}
onClick={onClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
'flex items-center gap-4 py-3 rounded-full transition-colors flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
@@ -160,13 +163,23 @@ export function NostrEventSidebarItem({
</Link>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<X className="size-4" />
</button>
belowMore ? (
<button
onClick={(e) => { e.stopPropagation(); onAdd?.(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-primary hover:bg-primary/10"
title="Add"
>
<Plus className="size-4" />
</button>
) : (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<X className="size-4" />
</button>
)
)}
</div>
);
+52 -158
View File
@@ -1,9 +1,7 @@
import { Link } from 'react-router-dom';
import { Plus, Pencil, Check, SeparatorHorizontal, Search, ChevronDown, ChevronUp, LinkIcon } from 'lucide-react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { SeparatorHorizontal, ChevronDown, ChevronUp, LinkIcon, Pencil, Check } from 'lucide-react';
import { useState } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { sidebarItemIcon, itemPath } from '@/lib/sidebarItems';
import type { HiddenSidebarItem } from '@/hooks/useFeedSettings';
import { nip19 } from 'nostr-tools';
@@ -16,105 +14,22 @@ interface SidebarMoreMenuProps {
onAdd: (id: string) => void;
onAddDivider: () => void;
onNavigate?: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Extra classes on the link text. */
linkClassName?: string;
/** Sidebar item ID configured as the homepage. */
homePage?: string;
}
function useScrollCarets(centerOnOpen = false) {
const scrollRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
const [canScrollUp, setCanScrollUp] = useState(false);
const [canScrollDown, setCanScrollDown] = useState(false);
const update = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollUp(el.scrollTop > 0);
setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1);
}, []);
const refCallback = useCallback((el: HTMLDivElement | null) => {
// Disconnect previous observer if any
roRef.current?.disconnect();
roRef.current = null;
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
if (!el) return;
if (centerOnOpen) {
el.scrollTop = (el.scrollHeight - el.clientHeight) / 2;
}
const ro = new ResizeObserver(update);
ro.observe(el);
roRef.current = ro;
update();
}, [centerOnOpen, update]);
const stopScroll = useCallback(() => {
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
}, []);
const startScroll = useCallback((direction: 'up' | 'down') => {
stopScroll();
intervalRef.current = setInterval(() => {
const el = scrollRef.current;
if (!el) return stopScroll();
el.scrollBy({ top: direction === 'up' ? -8 : 8 });
update();
// stop automatically when the limit is reached
const atLimit = direction === 'up' ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
if (atLimit) stopScroll();
}, 16);
}, [update, stopScroll]);
// clean up interval on unmount
useEffect(() => stopScroll, [stopScroll]);
return { scrollRef, refCallback, canScrollUp, canScrollDown, onScroll: update, startScroll, stopScroll };
}
function ScrollCaret({ direction, onMouseEnter, onMouseLeave }: { direction: 'up' | 'down'; onMouseEnter: () => void; onMouseLeave: () => void }) {
return (
<button className="flex cursor-default items-center justify-center py-1 w-full shrink-0" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{direction === 'up' ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
);
}
function ItemRow({ item, onAdd, onClose }: { item: HiddenSidebarItem; onAdd: (id: string) => void; onClose: () => void }) {
return (
<div className="flex items-center">
<button
onClick={() => { onAdd(item.id); onClose(); }}
className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors cursor-pointer"
>
{sidebarItemIcon(item.id, 'size-5 shrink-0')}
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{item.label}</span>
</button>
<button
onClick={() => { onAdd(item.id); onClose(); }}
className="size-8 flex items-center justify-center shrink-0 rounded-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title={`Add ${item.label} to sidebar`}
>
<Plus className="size-4" strokeWidth={4} />
</button>
</div>
);
}
export function SidebarMoreMenu({
editing, hiddenItems, onDoneEditing, onStartEditing, onAdd, onAddDivider, onNavigate, open, onOpenChange, homePage,
editing, hiddenItems, onDoneEditing, onStartEditing, onAdd, onAddDivider, onNavigate, linkClassName, homePage,
}: SidebarMoreMenuProps) {
const [query, setQuery] = useState('');
const [addMenuOpen, setAddMenuOpen] = useState(false);
const [addQuery, setAddQuery] = useState('');
const { user } = useCurrentUser();
const [expanded, setExpanded] = useState(false);
const [linkInput, setLinkInput] = useState(false);
const [linkValue, setLinkValue] = useState('');
const [linkError, setLinkError] = useState('');
const filtered = hiddenItems.filter((item) => item.label.toLowerCase().includes(query.toLowerCase()));
const addFiltered = hiddenItems.filter((item) => item.label.toLowerCase().includes(addQuery.toLowerCase()));
const sizeClass = linkClassName ?? 'text-lg';
const handleAddLink = () => {
const raw = linkValue.trim();
@@ -176,35 +91,11 @@ export function SidebarMoreMenu({
setLinkError('');
};
const main = useScrollCarets(true);
const add = useScrollCarets();
if (editing) {
return (
<div className="flex flex-col gap-0.5">
<DropdownMenu open={addMenuOpen} onOpenChange={(o) => { setAddMenuOpen(o); if (!o) setAddQuery(''); }}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85">
<Plus className="size-4" />
<span>Add</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" collisionPadding={8} className="w-[240px] p-1 flex flex-col max-h-[calc(var(--radix-dropdown-menu-content-available-height)-12px)]">
<div className="flex items-center gap-3 px-2 py-2 shrink-0">
<Search className="size-5 shrink-0" />
<input value={addQuery} onChange={(e) => setAddQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
</div>
<div className="h-px bg-border mb-1 shrink-0" />
{add.canScrollUp && <ScrollCaret direction="up" onMouseEnter={() => add.startScroll('up')} onMouseLeave={add.stopScroll} />}
<div ref={add.refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={add.onScroll}>
{addFiltered.map((item) => <ItemRow key={item.id} item={item} onAdd={onAdd} onClose={() => setAddMenuOpen(false)} />)}
{addFiltered.length === 0 && <p className="px-2 py-3 text-sm text-muted-foreground text-center">No results</p>}
</div>
{add.canScrollDown && <ScrollCaret direction="down" onMouseEnter={() => add.startScroll('down')} onMouseLeave={add.stopScroll} />}
</DropdownMenuContent>
</DropdownMenu>
<button onClick={onAddDivider} className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85">
<SeparatorHorizontal className="size-4" />
<button onClick={onAddDivider} className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}>
<SeparatorHorizontal className="size-6" />
<span>Add divider</span>
</button>
{linkInput ? (
@@ -248,56 +139,59 @@ export function SidebarMoreMenu({
) : (
<button
onClick={() => setLinkInput(true)}
className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85"
className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
>
<LinkIcon className="size-4" />
<LinkIcon className="size-6" />
<span>Add link</span>
</button>
)}
<button onClick={onDoneEditing} className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-primary font-medium hover:bg-primary/10 bg-background/85">
<Check className="size-4" />
<button onClick={onDoneEditing} className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-primary font-medium hover:bg-primary/10 bg-background/85 ${sizeClass}`}>
<Check className="size-6" />
<span>Done editing</span>
</button>
</div>
);
}
// Non-editing mode: inline collapsible list (no popover)
return (
<DropdownMenu open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) setQuery(''); }}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-secondary/40 bg-background/85">
{open ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
<span>More...</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" collisionPadding={8} className="w-[240px] p-1 flex flex-col max-h-[calc(var(--radix-dropdown-menu-content-available-height)-12px)]">
<div className="flex items-center gap-3 px-2 py-2 shrink-0">
<Search className="size-5 shrink-0" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
</div>
<div className="h-px bg-border mb-1 shrink-0" />
{main.canScrollUp && <ScrollCaret direction="up" onMouseEnter={() => main.startScroll('up')} onMouseLeave={main.stopScroll} />}
<div ref={main.refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={main.onScroll}>
{filtered.map((item) => (
<div key={item.id} className="flex items-center">
<Link to={itemPath(item.id, undefined, homePage)} onClick={() => { onOpenChange(false); onNavigate?.(); }} className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors">
{sidebarItemIcon(item.id, 'size-5 shrink-0')}
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{item.label}</span>
</Link>
<button onClick={() => { onAdd(item.id); onOpenChange(false); }} className="size-8 flex items-center justify-center shrink-0 rounded-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors" title={`Add ${item.label} to sidebar`}>
<Plus className="size-4" strokeWidth={4} />
</button>
</div>
<div className="flex flex-col gap-0.5">
<button
onClick={() => setExpanded((v) => !v)}
className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground/60 hover:text-muted-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
>
{expanded ? <ChevronUp className="size-6" /> : <ChevronDown className="size-6" />}
<span>{expanded ? 'Less...' : 'More...'}</span>
</button>
{expanded && (
<div className="flex flex-col gap-0.5">
{hiddenItems.map((item) => (
<Link
key={item.id}
to={itemPath(item.id, undefined, homePage)}
onClick={() => { setExpanded(false); onNavigate?.(); }}
className={`flex items-center gap-4 px-3 py-3 rounded-full font-normal text-foreground transition-colors hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
>
{sidebarItemIcon(item.id)}
<span className="truncate">{item.label}</span>
</Link>
))}
{filtered.length === 0 && <p className="px-2 py-3 text-sm text-muted-foreground text-center">No results</p>}
{hiddenItems.length === 0 && (
<p className={`px-3 py-3 text-muted-foreground ${sizeClass}`}>All items are in the sidebar</p>
)}
{user && (
<button
onClick={() => { setExpanded(false); onStartEditing(); }}
className={`flex items-center gap-4 px-3 py-3 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-secondary/40 bg-background/85 ${sizeClass}`}
>
<Pencil className="size-6" />
<span>Edit sidebar</span>
</button>
)}
</div>
{main.canScrollDown && <ScrollCaret direction="down" onMouseEnter={() => main.startScroll('down')} onMouseLeave={main.stopScroll} />}
<div className="h-px bg-border my-1 shrink-0" />
<button onClick={() => { onStartEditing(); onOpenChange(false); }} className="flex items-center gap-3 w-full px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors cursor-pointer shrink-0">
<Pencil className="size-5" />
<span>Edit sidebar</span>
</button>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
+77 -12
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import { GripVertical, X } from 'lucide-react';
import { GripVertical, X, Plus, ChevronDown } from 'lucide-react';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
type DragEndEvent,
@@ -21,6 +21,9 @@ export interface SidebarNavItemProps {
active: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onAdd?: (id: string) => void;
/** True when this item is below the "More..." separator (hidden zone). */
belowMore?: boolean;
onClick?: (e: React.MouseEvent) => void;
profilePath?: string;
showIndicator?: boolean;
@@ -31,7 +34,7 @@ export interface SidebarNavItemProps {
}
export function SidebarNavItem({
id, active, editing, onRemove, onClick, profilePath, showIndicator, linkClassName, homePage,
id, active, editing, onRemove, onAdd, belowMore, onClick, profilePath, showIndicator, linkClassName, homePage,
}: SidebarNavItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
@@ -43,7 +46,7 @@ export function SidebarNavItem({
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85 hover:bg-secondary/40', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
@@ -59,7 +62,7 @@ export function SidebarNavItem({
to={path}
onClick={onClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
'flex items-center gap-4 py-3 rounded-full transition-colors flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
@@ -75,13 +78,23 @@ export function SidebarNavItem({
</Link>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title={`Remove ${label}`}
>
<X className="size-4" />
</button>
belowMore ? (
<button
onClick={(e) => { e.stopPropagation(); onAdd?.(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-primary hover:bg-primary/10"
title={`Add ${label}`}
>
<Plus className="size-4" />
</button>
) : (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title={`Remove ${label}`}
>
<X className="size-4" />
</button>
)
)}
</div>
);
@@ -130,12 +143,49 @@ function SidebarDividerItem({ sortableId, editing, onRemove }: SidebarDividerIte
);
}
// ── "More..." separator (draggable boundary in editing mode) ──────────────────
function MoreSeparatorItem({ sortableId, editing, linkClassName }: { sortableId: string; editing: boolean; linkClassName?: string }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortableId, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85 hover:bg-secondary/40', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
)}
<div className={cn(
'flex items-center gap-4 py-3 rounded-full flex-1 min-w-0 text-muted-foreground/60',
editing ? 'px-2' : 'px-3',
linkClassName ?? 'text-lg',
)}>
<ChevronDown className="size-6" />
<span>More...</span>
</div>
</div>
);
}
// ── DnD-aware nav list ────────────────────────────────────────────────────────
/** Sentinel ID representing the "More..." boundary in the editing list. */
export const MORE_SEPARATOR_ID = '__more__';
export interface SidebarNavListProps {
items: string[];
editing: boolean;
onRemove: (id: string, index?: number) => void;
onAdd?: (id: string) => void;
onReorder: (newOrder: string[]) => void;
isActive: (id: string) => boolean;
getOnClick?: (id: string) => ((e: React.MouseEvent) => void) | undefined;
@@ -147,7 +197,7 @@ export interface SidebarNavListProps {
}
export function SidebarNavList({
items, editing, onRemove, onReorder, isActive, getOnClick, getProfilePath, getShowIndicator, linkClassName, homePage,
items, editing, onRemove, onAdd, onReorder, isActive, getOnClick, getProfilePath, getShowIndicator, linkClassName, homePage,
}: SidebarNavListProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -157,6 +207,9 @@ export function SidebarNavList({
// Assign unique sortable IDs: regular items use their id, dividers get "divider-{index}"
const sortableIds = items.map((id, i) => isSidebarDivider(id) ? `divider-${i}` : id);
// Find the "More..." boundary to determine which items are below it
const moreIndex = items.indexOf(MORE_SEPARATOR_ID);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
@@ -171,6 +224,12 @@ export function SidebarNavList({
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{items.map((id, i) => {
const sortableId = sortableIds[i];
const isBelowMore = moreIndex !== -1 && i > moreIndex;
if (id === MORE_SEPARATOR_ID) {
return <MoreSeparatorItem key={MORE_SEPARATOR_ID} sortableId={MORE_SEPARATOR_ID} editing={editing} linkClassName={linkClassName} />;
}
if (isSidebarDivider(id)) {
return (
<SidebarDividerItem
@@ -189,6 +248,8 @@ export function SidebarNavList({
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onAdd={onAdd}
belowMore={isBelowMore}
onClick={getOnClick?.(id)}
linkClassName={linkClassName}
/>
@@ -202,6 +263,8 @@ export function SidebarNavList({
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onAdd={onAdd}
belowMore={isBelowMore}
onClick={getOnClick?.(id)}
linkClassName={linkClassName}
/>
@@ -214,6 +277,8 @@ export function SidebarNavList({
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onAdd={onAdd}
belowMore={isBelowMore}
onClick={getOnClick?.(id)}
profilePath={getProfilePath?.(id)}
showIndicator={getShowIndicator?.(id)}