Files
eranos/src/components/SidebarNavItem.tsx
T
Alex Gleason b782297514 Support external content (URLs, iso3166, isbn) as sidebar items
External i-tag identifiers like https:// URLs, iso3166:US country
codes, and isbn: values can now be added as sidebar menu items.

URLs show their favicon and hostname. Country codes show their
flag emoji and name. ISBN shows the book label. Clicking navigates
to /i/<encoded-value>.

The 'Add link' input (renamed from 'Add Nostr link') now accepts
all identifier types: Nostr URIs, URLs, iso3166 codes, and ISBNs.
2026-03-22 23:50:03 -05:00

229 lines
8.5 KiB
TypeScript

import { Link } from 'react-router-dom';
import { GripVertical, X } from 'lucide-react';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isNostrUri, isExternalUri } from '@/lib/sidebarItems';
import { cn } from '@/lib/utils';
import { useCallback } from 'react';
import { NostrEventSidebarItem } from '@/components/NostrEventSidebarItem';
import { ExternalContentSidebarItem } from '@/components/ExternalContentSidebarItem';
// ── Sortable item ─────────────────────────────────────────────────────────────
export interface SidebarNavItemProps {
id: string;
active: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onClick?: (e: React.MouseEvent) => void;
profilePath?: string;
showIndicator?: boolean;
/** Extra classes on the link. Defaults to 'text-lg' for desktop. */
linkClassName?: string;
/** Sidebar item ID configured as the homepage. */
homePage?: string;
}
export function SidebarNavItem({
id, active, editing, onRemove, 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 };
const icon = sidebarItemIcon(id);
const label = itemLabel(id);
const path = itemPath(id, profilePath, homePage);
return (
<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')}
>
{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>
)}
<Link
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',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
)}
>
<span className="shrink-0 relative">
{icon}
{showIndicator && (
<span className="absolute -top-1 right-0 size-2.5 bg-primary rounded-full" />
)}
</span>
<span className="truncate">{label}</span>
</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>
)}
</div>
);
}
// ── Divider item ──────────────────────────────────────────────────────────────
interface SidebarDividerItemProps {
sortableId: string;
editing: boolean;
onRemove: () => void;
}
function SidebarDividerItem({ sortableId, editing, onRemove }: SidebarDividerItemProps) {
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', editing && 'bg-background/85', 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-1 flex items-center py-3', editing ? 'px-2' : 'px-3')}>
<div className="h-px w-full bg-border" />
</div>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
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 divider"
>
<X className="size-4" />
</button>
)}
</div>
);
}
// ── DnD-aware nav list ────────────────────────────────────────────────────────
export interface SidebarNavListProps {
items: string[];
editing: boolean;
onRemove: (id: string, index?: number) => void;
onReorder: (newOrder: string[]) => void;
isActive: (id: string) => boolean;
getOnClick?: (id: string) => ((e: React.MouseEvent) => void) | undefined;
getProfilePath?: (id: string) => string | undefined;
getShowIndicator?: (id: string) => boolean | undefined;
linkClassName?: string;
/** Sidebar item ID configured as the homepage. */
homePage?: string;
}
export function SidebarNavList({
items, editing, onRemove, onReorder, isActive, getOnClick, getProfilePath, getShowIndicator, linkClassName, homePage,
}: SidebarNavListProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor),
);
// Assign unique sortable IDs: regular items use their id, dividers get "divider-{index}"
const sortableIds = items.map((id, i) => isSidebarDivider(id) ? `divider-${i}` : id);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortableIds.indexOf(active.id as string);
const newIndex = sortableIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
onReorder(arrayMove(items, oldIndex, newIndex));
}, [sortableIds, items, onReorder]);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{items.map((id, i) => {
const sortableId = sortableIds[i];
if (isSidebarDivider(id)) {
return (
<SidebarDividerItem
key={sortableId}
sortableId={sortableId}
editing={editing}
onRemove={() => onRemove(id, i)}
/>
);
}
if (isNostrUri(id)) {
return (
<NostrEventSidebarItem
key={id}
id={id}
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onClick={getOnClick?.(id)}
linkClassName={linkClassName}
/>
);
}
if (isExternalUri(id)) {
return (
<ExternalContentSidebarItem
key={id}
id={id}
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onClick={getOnClick?.(id)}
linkClassName={linkClassName}
/>
);
}
return (
<SidebarNavItem
key={id}
id={id}
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onClick={getOnClick?.(id)}
profilePath={getProfilePath?.(id)}
showIndicator={getShowIndicator?.(id)}
linkClassName={linkClassName}
homePage={homePage}
/>
);
})}
</SortableContext>
</DndContext>
);
}