b782297514
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.
229 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|