253 lines
9.1 KiB
TypeScript
253 lines
9.1 KiB
TypeScript
import { useCallback, useMemo, useState, lazy, Suspense, memo } from '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 { Plus } from 'lucide-react';
|
|
|
|
import { WidgetCard } from '@/components/WidgetCard';
|
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
|
import { LinkFooter } from '@/components/LinkFooter';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { getWidgetDefinition } from '@/lib/sidebarWidgets';
|
|
import type { WidgetConfig } from '@/contexts/AppContext';
|
|
import type { WidgetDefinition } from '@/lib/sidebarWidgets';
|
|
|
|
// ── Lazy-loaded widget components ────────────────────────────────────────────
|
|
|
|
const TrendingWidget = lazy(() => import('@/components/widgets/TrendingWidget').then((m) => ({ default: m.TrendingWidget })));
|
|
const HotPostsWidget = lazy(() => import('@/components/widgets/HotPostsWidget').then((m) => ({ default: m.HotPostsWidget })));
|
|
const BlobbiWidget = lazy(() => import('@/components/widgets/BlobbiWidget').then((m) => ({ default: m.BlobbiWidget })));
|
|
const StatusWidget = lazy(() => import('@/components/widgets/StatusWidget').then((m) => ({ default: m.StatusWidget })));
|
|
const AIChatWidget = lazy(() => import('@/components/widgets/AIChatWidget').then((m) => ({ default: m.AIChatWidget })));
|
|
const WikipediaWidget = lazy(() => import('@/components/widgets/WikipediaWidget').then((m) => ({ default: m.WikipediaWidget })));
|
|
const BlueskyWidget = lazy(() => import('@/components/widgets/BlueskyWidget').then((m) => ({ default: m.BlueskyWidget })));
|
|
const PhotoWidget = lazy(() => import('@/components/widgets/PhotoWidget').then((m) => ({ default: m.PhotoWidget })));
|
|
const MusicWidget = lazy(() => import('@/components/widgets/MusicWidget').then((m) => ({ default: m.MusicWidget })));
|
|
const FeedWidget = lazy(() => import('@/components/widgets/FeedWidget').then((m) => ({ default: m.FeedWidget })));
|
|
|
|
const WidgetPickerDialog = lazy(() => import('@/components/WidgetPickerDialog').then((m) => ({ default: m.WidgetPickerDialog })));
|
|
|
|
// ── Widget content resolver ──────────────────────────────────────────────────
|
|
|
|
function WidgetContent({ id }: { id: string }) {
|
|
switch (id) {
|
|
case 'trends':
|
|
return <TrendingWidget />;
|
|
case 'hot-posts':
|
|
return <HotPostsWidget />;
|
|
case 'blobbi':
|
|
return <BlobbiWidget />;
|
|
case 'status':
|
|
return <StatusWidget />;
|
|
case 'ai-chat':
|
|
return <AIChatWidget />;
|
|
case 'wikipedia':
|
|
return <WikipediaWidget />;
|
|
case 'bluesky':
|
|
return <BlueskyWidget />;
|
|
case 'feed:photos':
|
|
return <PhotoWidget />;
|
|
case 'feed:music':
|
|
return <MusicWidget />;
|
|
case 'feed:articles':
|
|
return <FeedWidget kinds={[30023]} feedPath="/articles" feedLabel="View all articles" />;
|
|
case 'feed:events':
|
|
return <FeedWidget kinds={[31922, 31923]} feedPath="/events" feedLabel="View all events" />;
|
|
|
|
default:
|
|
return <p className="text-xs text-muted-foreground p-1">Unknown widget.</p>;
|
|
}
|
|
}
|
|
|
|
/** Fallback while a widget component is loading. */
|
|
function WidgetSkeleton() {
|
|
return (
|
|
<div className="space-y-2 p-1">
|
|
<Skeleton className="h-4 w-3/4" />
|
|
<Skeleton className="h-3 w-full" />
|
|
<Skeleton className="h-3 w-4/5" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Compact fallback shown when a widget crashes. */
|
|
function WidgetErrorFallback({ name }: { name: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-2 py-4 px-3 text-center">
|
|
<p className="text-xs text-muted-foreground">{name} failed to load.</p>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="text-xs text-primary hover:underline"
|
|
>
|
|
Reload page
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Sortable widget wrapper ──────────────────────────────────────────────────
|
|
|
|
interface SortableWidgetProps {
|
|
config: WidgetConfig;
|
|
definition: WidgetDefinition;
|
|
onRemove: (id: string) => void;
|
|
onHeightChange: (id: string, height: number) => void;
|
|
}
|
|
|
|
const SortableWidget = memo(function SortableWidget({ config, definition, onRemove, onHeightChange }: SortableWidgetProps) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: config.id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
zIndex: isDragging ? 10 : undefined,
|
|
};
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style} {...attributes}>
|
|
<WidgetCard
|
|
definition={definition}
|
|
config={config}
|
|
onRemove={() => onRemove(config.id)}
|
|
onHeightChange={(h) => onHeightChange(config.id, h)}
|
|
isDragging={isDragging}
|
|
dragHandleProps={listeners}
|
|
>
|
|
<ErrorBoundary fallback={<WidgetErrorFallback name={definition.label} />} reportToSentry>
|
|
<Suspense fallback={<WidgetSkeleton />}>
|
|
<WidgetContent id={config.id} />
|
|
</Suspense>
|
|
</ErrorBoundary>
|
|
</WidgetCard>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
// ── Main sidebar ─────────────────────────────────────────────────────────────
|
|
|
|
const EMPTY_WIDGETS: WidgetConfig[] = [];
|
|
|
|
export function WidgetSidebar() {
|
|
const { config, updateConfig } = useAppContext();
|
|
const widgets = config.sidebarWidgets ?? EMPTY_WIDGETS;
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
|
|
// Filter out widgets with unknown definitions
|
|
const validWidgets = useMemo(
|
|
() => widgets.filter((w) => getWidgetDefinition(w.id)),
|
|
[widgets],
|
|
);
|
|
|
|
const updateWidgets = useCallback((updater: (current: WidgetConfig[]) => WidgetConfig[]) => {
|
|
updateConfig((c) => ({
|
|
...c,
|
|
sidebarWidgets: updater(c.sidebarWidgets ?? widgets),
|
|
}));
|
|
}, [updateConfig, widgets]);
|
|
|
|
const removeWidget = useCallback((id: string) => {
|
|
updateWidgets((ws) => ws.filter((w) => w.id !== id));
|
|
}, [updateWidgets]);
|
|
|
|
const changeHeight = useCallback((id: string, height: number) => {
|
|
updateWidgets((ws) => ws.map((w) => w.id === id ? { ...w, height } : w));
|
|
}, [updateWidgets]);
|
|
|
|
const addWidget = useCallback((id: string) => {
|
|
updateWidgets((ws) => {
|
|
if (ws.some((w) => w.id === id)) return ws;
|
|
return [...ws, { id }];
|
|
});
|
|
}, [updateWidgets]);
|
|
|
|
// Drag-and-drop
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
useSensor(KeyboardSensor),
|
|
);
|
|
|
|
const sortableIds = useMemo(() => validWidgets.map((w) => w.id), [validWidgets]);
|
|
|
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
updateWidgets((ws) => {
|
|
const oldIndex = ws.findIndex((w) => w.id === active.id);
|
|
const newIndex = ws.findIndex((w) => w.id === over.id);
|
|
if (oldIndex === -1 || newIndex === -1) return ws;
|
|
return arrayMove(ws, oldIndex, newIndex);
|
|
});
|
|
}, [updateWidgets]);
|
|
|
|
return (
|
|
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-2">
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
|
<div className="space-y-2 flex-1">
|
|
{validWidgets.map((w) => {
|
|
const def = getWidgetDefinition(w.id);
|
|
if (!def) return null;
|
|
return (
|
|
<SortableWidget
|
|
key={w.id}
|
|
config={w}
|
|
definition={def}
|
|
onRemove={removeWidget}
|
|
onHeightChange={changeHeight}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Add widget button */}
|
|
<button
|
|
onClick={() => setPickerOpen(true)}
|
|
className="flex items-center justify-center gap-1.5 w-full py-2.5 rounded-xl bg-background/85 text-muted-foreground hover:text-foreground hover:bg-background transition-colors text-xs"
|
|
>
|
|
<Plus className="size-3.5" />
|
|
Add widget
|
|
</button>
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
<div className="mt-3">
|
|
<LinkFooter />
|
|
</div>
|
|
|
|
{/* Widget picker dialog */}
|
|
<Suspense fallback={null}>
|
|
{pickerOpen && (
|
|
<WidgetPickerDialog
|
|
open={pickerOpen}
|
|
onOpenChange={setPickerOpen}
|
|
currentWidgets={widgets}
|
|
onAdd={addWidget}
|
|
onRemove={removeWidget}
|
|
/>
|
|
)}
|
|
</Suspense>
|
|
</aside>
|
|
);
|
|
}
|