Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c901dd212 | |||
| fee190432e | |||
| ed9968728a |
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user