Add customizable widget sidebar for the main feed right column

Replace the empty right sidebar placeholder with a user-configurable widget
system. Users can add, remove, reorder, collapse, and resize widgets via
drag-and-drop and a picker dialog. Config persists in localStorage (same
pattern as sidebarOrder) and syncs via encrypted settings.

v1 widgets: Trending Tags, Blobbi (mini pet), Status (NIP-38), AI Chat,
Wikipedia (featured article), Bluesky (trending posts), and feed widgets
for Photos, Music, Articles, Events, and Books.

Defaults: Trending + Blobbi for fresh installs. Desktop-only (hidden below
xl breakpoint). Profile pages retain their dedicated ProfileRightSidebar.
This commit is contained in:
Mary Kate Fain
2026-04-10 09:28:04 -05:00
parent 72268dfde6
commit f05097087b
18 changed files with 1412 additions and 4 deletions
+4
View File
@@ -152,6 +152,10 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
sidebarWidgets: [
{ id: 'trends' },
{ id: 'blobbi' },
],
};
/**
+5 -3
View File
@@ -1,4 +1,4 @@
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
import { Suspense, useState, useMemo, useCallback, useRef, lazy } from 'react';
import { Outlet } from 'react-router-dom';
import { LeftSidebar } from '@/components/LeftSidebar';
import { MobileTopBar } from '@/components/MobileTopBar';
@@ -12,6 +12,8 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { cn } from '@/lib/utils';
const WidgetSidebar = lazy(() => import('@/components/WidgetSidebar').then((m) => ({ default: m.WidgetSidebar })));
/** Skeleton shown in the content area while a lazy page chunk is loading. */
function PageSkeleton() {
return (
@@ -104,8 +106,8 @@ function MainLayoutInner() {
</div>
)}
</div>
{/* Right sidebar — render page-provided sidebar, or an empty placeholder to preserve layout width */}
{rightSidebar ?? <div className="w-[300px] shrink-0 hidden xl:block" />}
{/* Right sidebar — render page-provided sidebar, or the widget sidebar */}
{rightSidebar ?? <Suspense fallback={<div className="w-[300px] shrink-0 hidden xl:block" />}><WidgetSidebar /></Suspense>}
</Suspense>
</div>
+126
View File
@@ -0,0 +1,126 @@
import { useState, useCallback, type ReactNode } from 'react';
import { ChevronDown, ChevronUp, GripVertical, X } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { WidgetContext } from '@/contexts/WidgetContext';
import { cn } from '@/lib/utils';
import type { WidgetDefinition } from '@/lib/sidebarWidgets';
import type { WidgetConfig } from '@/contexts/AppContext';
interface WidgetCardProps {
definition: WidgetDefinition;
config: WidgetConfig;
onToggleCollapse: () => void;
onRemove: () => void;
onHeightChange: (height: number) => void;
isDragging?: boolean;
dragHandleProps?: Record<string, unknown>;
children: ReactNode;
}
/** Wrapper for each widget in the sidebar — header, collapse, height control. */
export function WidgetCard({
definition,
config,
onToggleCollapse,
onRemove,
onHeightChange,
isDragging,
dragHandleProps,
children,
}: WidgetCardProps) {
const collapsed = config.collapsed ?? false;
const height = config.height ?? definition.defaultHeight;
const Icon = definition.icon;
// Height resize state
const [resizing, setResizing] = useState(false);
const handleResizeStart = useCallback((e: React.PointerEvent) => {
e.preventDefault();
setResizing(true);
const startY = e.clientY;
const startHeight = height;
const onMove = (ev: PointerEvent) => {
const delta = ev.clientY - startY;
const newHeight = Math.max(
definition.minHeight,
Math.min(definition.maxHeight, startHeight + delta),
);
onHeightChange(newHeight);
};
const onUp = () => {
setResizing(false);
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
}, [height, definition.minHeight, definition.maxHeight, onHeightChange]);
return (
<div
className={cn(
'bg-background/85 rounded-xl overflow-hidden transition-shadow',
isDragging && 'shadow-lg ring-1 ring-primary/20',
resizing && 'select-none',
)}
>
{/* Header */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50">
{/* Drag handle */}
<button
className="p-0.5 rounded text-muted-foreground/50 hover:text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
{...dragHandleProps}
tabIndex={-1}
>
<GripVertical className="size-3.5" />
</button>
{/* Icon + label */}
<Icon className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold flex-1 truncate">{definition.label}</span>
{/* Collapse toggle */}
<button
onClick={onToggleCollapse}
className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
aria-label={collapsed ? 'Expand' : 'Collapse'}
>
{collapsed ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
</button>
{/* Remove */}
<button
onClick={onRemove}
className="p-0.5 rounded text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove widget"
>
<X className="size-3.5" />
</button>
</div>
{/* Content */}
{!collapsed && (
<WidgetContext.Provider value={true}>
<ScrollArea style={{ height }} className="transition-[height] duration-200">
<div className="p-2">
{children}
</div>
</ScrollArea>
{/* Resize handle */}
<div
onPointerDown={handleResizeStart}
className="h-1.5 cursor-ns-resize flex items-center justify-center hover:bg-secondary/60 transition-colors group"
>
<div className="w-8 h-0.5 rounded-full bg-border group-hover:bg-muted-foreground/40 transition-colors" />
</div>
</WidgetContext.Provider>
)}
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useMemo } from 'react';
import { Check, Plus } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { WIDGET_DEFINITIONS, WIDGET_CATEGORIES } from '@/lib/sidebarWidgets';
import type { WidgetConfig } from '@/contexts/AppContext';
import { cn } from '@/lib/utils';
interface WidgetPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentWidgets: WidgetConfig[];
onAdd: (id: string) => void;
onRemove: (id: string) => void;
}
/** Dialog for adding/removing widgets from the sidebar. */
export function WidgetPickerDialog({ open, onOpenChange, currentWidgets, onAdd, onRemove }: WidgetPickerDialogProps) {
const activeIds = useMemo(() => new Set(currentWidgets.map((w) => w.id)), [currentWidgets]);
// Group widgets by category
const grouped = useMemo(() => {
const groups: Record<string, typeof WIDGET_DEFINITIONS> = {};
for (const w of WIDGET_DEFINITIONS) {
(groups[w.category] ??= []).push(w);
}
return groups;
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Widget</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="space-y-5 pr-2">
{Object.entries(grouped).map(([category, widgets]) => (
<div key={category}>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-1">
{WIDGET_CATEGORIES[category] ?? category}
</h3>
<div className="space-y-1">
{widgets.map((widget) => {
const isActive = activeIds.has(widget.id);
const Icon = widget.icon;
return (
<button
key={widget.id}
onClick={() => {
if (isActive) {
onRemove(widget.id);
} else {
onAdd(widget.id);
}
}}
className={cn(
'flex items-center gap-3 w-full px-3 py-2.5 rounded-xl transition-colors text-left',
isActive
? 'bg-primary/10 hover:bg-primary/15'
: 'hover:bg-secondary/60',
)}
>
<div className={cn(
'size-9 rounded-lg flex items-center justify-center shrink-0',
isActive ? 'bg-primary/20 text-primary' : 'bg-secondary text-muted-foreground',
)}>
<Icon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{widget.label}</div>
<div className="text-xs text-muted-foreground truncate">{widget.description}</div>
</div>
<div className={cn(
'size-6 rounded-full flex items-center justify-center shrink-0 transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'border border-border text-muted-foreground/50',
)}>
{isActive ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
</div>
</button>
);
})}
</div>
</div>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
+235
View File
@@ -0,0 +1,235 @@
import { useCallback, useMemo, useState, lazy, Suspense } 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 { 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 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 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 'blobbi':
return <BlobbiWidget />;
case 'status':
return <StatusWidget />;
case 'ai-chat':
return <AIChatWidget />;
case 'wikipedia':
return <WikipediaWidget />;
case 'bluesky':
return <BlueskyWidget />;
case 'feed:photos':
return <FeedWidget kinds={[20]} feedPath="/photos" feedLabel="View all photos" />;
case 'feed:music':
return <FeedWidget kinds={[36787, 34139]} feedPath="/music" feedLabel="View all music" />;
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" />;
case 'feed:books':
return <FeedWidget kinds={[30040]} feedPath="/books" feedLabel="View all books" />;
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>
);
}
// ── Sortable widget wrapper ──────────────────────────────────────────────────
interface SortableWidgetProps {
config: WidgetConfig;
definition: WidgetDefinition;
onToggleCollapse: (id: string) => void;
onRemove: (id: string) => void;
onHeightChange: (id: string, height: number) => void;
}
function SortableWidget({ config, definition, onToggleCollapse, 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}
onToggleCollapse={() => onToggleCollapse(config.id)}
onRemove={() => onRemove(config.id)}
onHeightChange={(h) => onHeightChange(config.id, h)}
isDragging={isDragging}
dragHandleProps={listeners}
>
<Suspense fallback={<WidgetSkeleton />}>
<WidgetContent id={config.id} />
</Suspense>
</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 ?? []),
}));
}, [updateConfig]);
const toggleCollapse = useCallback((id: string) => {
updateWidgets((ws) => ws.map((w) => w.id === id ? { ...w, collapsed: !w.collapsed } : w));
}, [updateWidgets]);
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;
const oldIndex = sortableIds.indexOf(active.id as string);
const newIndex = sortableIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
updateWidgets((ws) => arrayMove(ws, oldIndex, newIndex));
}, [sortableIds, 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}
onToggleCollapse={toggleCollapse}
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 border border-dashed border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 hover:bg-secondary/30 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>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Send, Loader2, Bot, Maximize2 } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useShakespeare, type ChatMessage } from '@/hooks/useShakespeare';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn } from '@/lib/utils';
/** Compact AI chat widget for the sidebar. */
export function AIChatWidget() {
const { user } = useCurrentUser();
const { sendStreamingMessage, isLoading, isAuthenticated } = useShakespeare();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [streamingContent, setStreamingContent] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollToBottom = useCallback(() => {
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
if (viewport) {
viewport.scrollTop = viewport.scrollHeight;
}
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
const handleSend = useCallback(async () => {
const text = input.trim();
if (!text || isLoading) return;
const userMessage: ChatMessage = { role: 'user', content: text };
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput('');
setStreamingContent('');
try {
let accumulated = '';
await sendStreamingMessage(
newMessages,
'shakespeare',
(chunk) => {
accumulated += chunk;
setStreamingContent(accumulated);
},
);
setMessages((prev) => [...prev, { role: 'assistant', content: accumulated }]);
setStreamingContent('');
} catch {
setMessages((prev) => [...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }]);
setStreamingContent('');
}
}, [input, isLoading, messages, sendStreamingMessage]);
if (!user || !isAuthenticated) {
return (
<div className="flex flex-col items-center gap-2 py-4 px-2 text-center">
<Bot className="size-8 text-muted-foreground" />
<p className="text-xs text-muted-foreground">Log in to chat with AI</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Messages area */}
<ScrollArea ref={scrollRef} className="flex-1 min-h-0">
<div className="space-y-3 p-2">
{messages.length === 0 && !streamingContent && (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Bot className="size-6 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Ask me anything...</p>
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={i} message={msg} />
))}
{streamingContent && (
<MessageBubble message={{ role: 'assistant', content: streamingContent }} />
)}
{isLoading && !streamingContent && (
<div className="flex gap-2 items-start">
<div className="bg-secondary rounded-xl rounded-tl-sm px-3 py-2">
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
</ScrollArea>
{/* Input area */}
<div className="border-t border-border p-2 space-y-1.5">
<div className="flex items-end gap-1.5">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Message..."
rows={1}
className="flex-1 resize-none text-sm bg-secondary/50 rounded-lg px-2.5 py-1.5 border-0 outline-none focus:ring-1 focus:ring-primary/30 placeholder:text-muted-foreground/60 min-h-[32px] max-h-[80px]"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="shrink-0 p-1.5 rounded-lg text-primary hover:bg-primary/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Send className="size-4" />
</button>
</div>
<div className="flex justify-end">
<Link to="/ai-chat" className="text-[10px] text-muted-foreground hover:text-primary transition-colors flex items-center gap-0.5">
<Maximize2 className="size-2.5" />
Full chat
</Link>
</div>
</div>
</div>
);
}
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === 'user';
const content = typeof message.content === 'string' ? message.content : message.content.map((c) => c.text ?? '').join('');
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[85%] rounded-xl px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap break-words',
isUser
? 'bg-primary text-primary-foreground rounded-br-sm'
: 'bg-secondary text-foreground rounded-bl-sm',
)}
>
{content}
</div>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Egg } from 'lucide-react';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
import { useBlobbisCollection } from '@/blobbi/core/hooks/useBlobbisCollection';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
/** Map stat keys to Tailwind color classes. */
const STAT_COLORS: Record<string, string> = {
hunger: 'bg-orange-500',
happiness: 'bg-yellow-500',
health: 'bg-green-500',
hygiene: 'bg-blue-500',
energy: 'bg-violet-500',
};
const STAT_LABELS: Record<string, string> = {
hunger: 'Hunger',
happiness: 'Happy',
health: 'Health',
hygiene: 'Hygiene',
energy: 'Energy',
};
/** Mini Blobbi widget showing the pet visual + stat bars. */
export function BlobbiWidget() {
const { user } = useCurrentUser();
const { companions, isLoading } = useBlobbisCollection();
// Use the first (active) companion
const companion = useMemo(() => {
if (!companions || companions.length === 0) return null;
// Prefer active companions, then most recently interacted
return companions.find((c) => c.state === 'active') ?? companions[0];
}, [companions]);
if (!user) {
return (
<Link to="/blobbi" className="flex flex-col items-center gap-2 py-4 hover:bg-secondary/40 rounded-lg transition-colors">
<div className="size-16 rounded-2xl bg-primary/10 flex items-center justify-center">
<Egg className="size-8 text-primary" />
</div>
<span className="text-xs text-muted-foreground">Log in to hatch your Blobbi</span>
</Link>
);
}
if (isLoading) {
return (
<div className="flex flex-col items-center gap-3 py-4">
<Skeleton className="size-24 rounded-full" />
<div className="w-full space-y-2 px-4">
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
</div>
</div>
);
}
if (!companion) {
return (
<Link to="/blobbi" className="flex flex-col items-center gap-2 py-4 hover:bg-secondary/40 rounded-lg transition-colors">
<div className="size-16 rounded-2xl bg-primary/10 flex items-center justify-center">
<Egg className="size-8 text-primary" />
</div>
<span className="text-sm font-medium text-primary">Hatch your Blobbi</span>
<span className="text-xs text-muted-foreground">Get your virtual pet companion</span>
</Link>
);
}
return (
<Link to="/blobbi" className="block hover:bg-secondary/20 rounded-lg transition-colors">
<BlobbiWidgetContent companion={companion} />
</Link>
);
}
function BlobbiWidgetContent({ companion }: { companion: ReturnType<typeof useBlobbisCollection>['companions'][number] }) {
const projected = useProjectedBlobbiState(companion);
return (
<div className="flex flex-col items-center gap-3 py-3">
{/* Pet visual */}
<div className="relative">
<BlobbiStageVisual
companion={companion}
size="lg"
animated
lookMode="follow-pointer"
/>
</div>
{/* Name */}
<span className="text-sm font-semibold">{companion.name}</span>
{/* Stat bars */}
{projected && projected.visibleStats.length > 0 && (
<div className="w-full space-y-1.5 px-3">
{projected.visibleStats.map(({ stat, value, status }) => (
<div key={stat} className="flex items-center gap-2">
<span className={cn(
'text-[10px] w-12 text-right shrink-0',
status === 'critical' ? 'text-destructive font-bold' :
status === 'warning' ? 'text-orange-500 font-medium' :
'text-muted-foreground',
)}>
{STAT_LABELS[stat] ?? stat}
</span>
<div className="flex-1 h-1.5 rounded-full bg-secondary overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-1000',
STAT_COLORS[stat] ?? 'bg-primary',
status === 'critical' && 'animate-pulse',
)}
style={{ width: `${Math.max(0, Math.min(100, value))}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { MessageCircle, Repeat2, Heart } from 'lucide-react';
import { useBlueskyTrending, type BlueskyPost } from '@/hooks/useBlueskyTrending';
import { Skeleton } from '@/components/ui/skeleton';
import { formatNumber } from '@/lib/formatNumber';
/** Bluesky trending posts widget for the sidebar. */
export function BlueskyWidget() {
const { data, isLoading } = useBlueskyTrending();
const posts = useMemo(() => {
if (!data?.pages) return [];
return data.pages.flatMap((p) => p.posts).slice(0, 5);
}, [data]);
if (isLoading) {
return (
<div className="space-y-3 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-3/4" />
</div>
))}
</div>
);
}
if (posts.length === 0) {
return <p className="text-sm text-muted-foreground p-1">No trending posts right now.</p>;
}
return (
<div className="space-y-0.5">
{posts.map((post) => (
<BlueskyPostCard key={post.cid} post={post} />
))}
<div className="pt-1 px-2">
<Link to="/bluesky" className="text-xs text-primary hover:underline">View more on Bluesky</Link>
</div>
</div>
);
}
function BlueskyPostCard({ post }: { post: BlueskyPost }) {
const text = post.record.text;
const snippet = text.length > 120 ? text.slice(0, 120) + '...' : text;
const webUrl = `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`;
const internalUrl = `/i/${encodeURIComponent(webUrl)}`;
const timeAgo = useMemo(() => {
const diff = Date.now() - new Date(post.indexedAt).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}, [post.indexedAt]);
return (
<Link
to={internalUrl}
className="block hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
>
{/* Author line */}
<div className="flex items-center gap-1.5 mb-0.5">
{post.author.avatar ? (
<img src={post.author.avatar} alt="" className="size-4 rounded-full object-cover" loading="lazy" />
) : (
<div className="size-4 rounded-full bg-sky-500 flex items-center justify-center text-white text-[8px] font-bold">
{(post.author.displayName ?? post.author.handle).charAt(0).toUpperCase()}
</div>
)}
<span className="text-xs font-semibold truncate">{post.author.displayName ?? post.author.handle}</span>
<span className="text-xs text-muted-foreground shrink-0">&middot; {timeAgo}</span>
</div>
{/* Content */}
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2 mb-1">{snippet}</p>
{/* Engagement */}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
<span className="flex items-center gap-0.5"><MessageCircle className="size-2.5" />{formatNumber(post.replyCount)}</span>
<span className="flex items-center gap-0.5"><Repeat2 className="size-2.5" />{formatNumber(post.repostCount)}</span>
<span className="flex items-center gap-0.5"><Heart className="size-2.5" />{formatNumber(post.likeCount)}</span>
</div>
</Link>
);
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Generic compact feed widget that queries Nostr events by kind and renders
* them as a compact list. Used for Photos, Music, Articles, Events, Books widgets.
*/
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { getAvatarShape } from '@/lib/avatarShape';
import { timeAgo } from '@/lib/timeAgo';
interface FeedWidgetProps {
/** Event kind(s) to fetch. */
kinds: number[];
/** Link to the full feed page. */
feedPath: string;
/** Label for "View all" link. */
feedLabel: string;
/** Number of items to show. */
limit?: number;
/** Empty state message. */
emptyMessage?: string;
}
/** Compact feed widget showing recent events for given kind(s). */
export function FeedWidget({ kinds, feedPath, feedLabel, limit = 5, emptyMessage = 'No content yet.' }: FeedWidgetProps) {
const { nostr } = useNostr();
const kindsKey = kinds.join(',');
const { data: events, isLoading } = useQuery({
queryKey: ['widget-feed', kindsKey, limit],
queryFn: async () => {
return nostr.query([{ kinds, limit: limit * 2 }]);
},
staleTime: 5 * 60_000,
});
const filtered = useMemo(() => (events ?? []).slice(0, limit), [events, limit]);
if (isLoading) {
return (
<div className="space-y-3 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-3.5 w-full" />
</div>
))}
</div>
);
}
if (filtered.length === 0) {
return <p className="text-sm text-muted-foreground p-1">{emptyMessage}</p>;
}
return (
<div className="space-y-0.5">
{filtered.map((event) => (
<CompactEventCard key={event.id} event={event} />
))}
<div className="pt-1 px-2">
<Link to={feedPath} className="text-xs text-primary hover:underline">{feedLabel}</Link>
</div>
</div>
);
}
/** Minimal event card for sidebar widgets. */
function CompactEventCard({ event }: { event: NostrEvent }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(event.pubkey);
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
// Try to get a title from tags (articles, events, etc.)
const title = event.tags.find(([t]) => t === 'title')?.[1];
// Build a snippet from content
const snippet = useMemo(() => {
if (title) return title;
const clean = event.content.replace(/https?:\/\/\S+/g, '').trim();
if (clean.length > 100) return clean.slice(0, 100) + '...';
return clean || '(media)';
}, [event.content, title]);
return (
<Link
to={`/${encodedId}`}
className="block hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
>
<div className="flex items-center gap-1.5 mb-0.5">
<Avatar shape={avatarShape} className="size-4">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-xs font-semibold truncate">{displayName}</span>
<span className="text-xs text-muted-foreground shrink-0">&middot; {timeAgo(event.created_at)}</span>
</div>
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
</Link>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUserStatus } from '@/hooks/useUserStatus';
import { usePublishStatus } from '@/hooks/usePublishStatus';
import { useToast } from '@/hooks/useToast';
/** Compact status widget — shows current NIP-38 status, click to edit inline. */
export function StatusWidget() {
const { user } = useCurrentUser();
const userStatus = useUserStatus(user?.pubkey);
const publishStatus = usePublishStatus();
const { toast } = useToast();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
if (!user) {
return (
<p className="text-sm text-muted-foreground px-1">Log in to set a status.</p>
);
}
if (editing) {
return (
<div className="space-y-2 px-1">
<Input
value={draft}
onChange={(e) => setDraft(e.target.value.slice(0, 80))}
placeholder="What are you up to?"
className="h-8 text-base md:text-sm"
maxLength={80}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const text = draft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setEditing(false);
setDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
} else if (e.key === 'Escape') {
setEditing(false);
setDraft('');
}
}}
/>
<div className="flex items-center gap-1.5">
<button
onClick={() => {
const text = draft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setEditing(false);
setDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
>
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
</button>
{userStatus.status && (
<button
onClick={() => {
publishStatus.mutateAsync({ status: '' }).then(() => {
setEditing(false);
setDraft('');
toast({ title: 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
>
Clear
</button>
)}
<button
onClick={() => { setEditing(false); setDraft(''); }}
className="text-xs text-muted-foreground hover:underline ml-auto"
>
Cancel
</button>
</div>
</div>
);
}
return (
<button
onClick={() => {
setEditing(true);
setDraft(userStatus.status ?? '');
}}
className="flex items-center w-full px-1 py-1 text-sm hover:bg-secondary/40 rounded-lg transition-colors text-left"
>
{userStatus.status ? (
<span className="truncate text-muted-foreground italic text-xs">{userStatus.status}</span>
) : (
<span className="text-muted-foreground text-xs">Click to set a status...</span>
)}
</button>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { TrendSparkline } from '@/components/RightSidebar';
import { useTrendingTags, useTagSparklines } from '@/hooks/useTrending';
import { formatNumber } from '@/lib/formatNumber';
/** Compact trending tags widget for the right sidebar. */
export function TrendingWidget() {
const { data: trendingTagsResult, isLoading: tagsLoading } = useTrendingTags(true);
const trendingTags = trendingTagsResult?.tags;
const labelCreatedAt = trendingTagsResult?.labelCreatedAt ?? 0;
const visibleTags = useMemo(() => (trendingTags ?? []).slice(0, 5).map((t) => t.tag), [trendingTags]);
const { data: sparklineData, isLoading: sparklinesLoading } = useTagSparklines(visibleTags, labelCreatedAt, visibleTags.length > 0);
if (tagsLoading) {
return (
<div className="space-y-4 p-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex justify-between items-center">
<div className="space-y-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-8 w-12" />
</div>
))}
</div>
);
}
if (!trendingTags || trendingTags.length === 0) {
return <p className="text-sm text-muted-foreground p-1">No trends available.</p>;
}
return (
<div className="space-y-1">
{trendingTags.slice(0, 5).map((item) => (
<Link
key={item.tag}
to={`/t/${item.tag}`}
className="flex items-center justify-between group hover:bg-secondary/40 px-2 py-1.5 rounded-lg transition-colors"
>
<div>
<div className="font-bold text-sm">#{item.tag}</div>
{item.accounts > 0 && (
<div className="text-xs text-muted-foreground">
<span className="text-primary font-semibold">{formatNumber(item.accounts)}</span> people talking
</div>
)}
</div>
{sparklinesLoading ? (
<Skeleton className="h-[35px] w-[50px] rounded" />
) : (
<TrendSparkline data={sparklineData?.get(item.tag) ?? []} />
)}
</Link>
))}
<div className="pt-1 px-2">
<Link to="/trends" className="text-xs text-primary hover:underline">View all trends</Link>
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { Star, ExternalLink } from 'lucide-react';
import { useWikipediaFeatured } from '@/hooks/useWikipediaFeatured';
import { Skeleton } from '@/components/ui/skeleton';
/** Wikipedia widget showing today's featured article. */
export function WikipediaWidget() {
const { data: feed, isLoading } = useWikipediaFeatured();
if (isLoading) {
return (
<div className="space-y-3 p-1">
<Skeleton className="w-full aspect-[16/9] rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
</div>
);
}
const tfa = feed?.tfa;
if (!tfa) {
return <p className="text-sm text-muted-foreground p-1">No featured article today.</p>;
}
const imageUrl = tfa.originalimage?.source ?? tfa.thumbnail?.source;
const excerpt = tfa.extract.length > 200 ? tfa.extract.slice(0, 200) + '...' : tfa.extract;
return (
<a
href={tfa.content_urls.desktop.page}
target="_blank"
rel="noopener noreferrer"
className="block group"
>
{/* Image */}
{imageUrl && (
<div className="relative aspect-[16/9] rounded-lg overflow-hidden bg-gradient-to-br from-amber-500/10 to-orange-500/10 mb-2">
<img
src={imageUrl}
alt={tfa.title}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
)}
{/* Content */}
<div className="space-y-1 px-0.5">
<div className="flex items-start gap-1.5">
<Star className="size-3 text-amber-500 shrink-0 mt-0.5" />
<h3 className="text-sm font-bold leading-snug group-hover:text-primary transition-colors line-clamp-2">
{tfa.title}
</h3>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
{excerpt}
</p>
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70 pt-0.5">
<ExternalLink className="size-2.5" />
<span>Wikipedia</span>
</div>
</div>
</a>
);
}
+12
View File
@@ -245,6 +245,18 @@ export interface AppConfig {
curatorPubkey?: string;
/** Wildcard domain used for iframe sandboxing (e.g. "iframe.diy"). Default: "iframe.diy". */
sandboxDomain: string;
/** Ordered list of right sidebar widget configs. Each entry is a widget type ID with optional display settings. */
sidebarWidgets: WidgetConfig[];
}
/** Configuration for a single widget in the right sidebar. */
export interface WidgetConfig {
/** Widget type identifier (e.g. "trends", "blobbi", "wikipedia", "bluesky"). */
id: string;
/** Whether the widget is collapsed by the user. */
collapsed?: boolean;
/** User-configured height in pixels. Overrides the widget's default height. */
height?: number;
}
export interface AppContextType {
+14
View File
@@ -0,0 +1,14 @@
import { createContext, useContext } from 'react';
/**
* Context that signals a component is being rendered inside a sidebar widget.
* Pages check `useIsWidget()` to skip PageHeader and useLayoutOptions calls.
*/
const WidgetContext = createContext(false);
/** Returns true when the calling component is rendered inside a sidebar widget. */
export function useIsWidget(): boolean {
return useContext(WidgetContext);
}
export { WidgetContext };
+3 -1
View File
@@ -5,7 +5,7 @@ import type { NostrFilter } from '@nostrify/nostrify';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from './useCurrentUser';
import type { Theme, FeedSettings, ContentWarningPolicy, SavedFeed } from '@/contexts/AppContext';
import type { Theme, FeedSettings, ContentWarningPolicy, SavedFeed, WidgetConfig } from '@/contexts/AppContext';
import type { ThemeConfig } from '@/themes';
import type { ContentFilter } from './useContentFilters';
import type { LetterPreferences } from '@/lib/letterTypes';
@@ -78,6 +78,8 @@ export interface EncryptedSettings {
lastSync?: number;
/** Ordered list of sidebar item IDs (built-in + extra-kind) */
sidebarOrder?: string[];
/** Ordered list of right sidebar widget configs. */
sidebarWidgets?: WidgetConfig[];
/** Sidebar item ID to display on the homepage ("/") */
homePage?: string;
/** Whether the Global feed tab is shown */
+10
View File
@@ -247,6 +247,11 @@ export const AppConfigSchema = z.object({
imageQuality: z.enum(['compressed', 'original']),
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
sandboxDomain: z.string().optional(),
sidebarWidgets: z.array(z.object({
id: z.string(),
collapsed: z.boolean().optional(),
height: z.number().optional(),
})).optional(),
});
// ─── DittoConfigSchema (build-time ditto.json) ───────────────────────
@@ -316,6 +321,11 @@ export const EncryptedSettingsSchema = z.looseObject({
}).optional(),
lastSync: z.number().optional(),
sidebarOrder: z.array(z.string()).optional(),
sidebarWidgets: z.array(z.object({
id: z.string(),
collapsed: z.boolean().optional(),
height: z.number().optional(),
})).optional(),
homePage: z.string().optional(),
showGlobalFeed: z.boolean().optional(),
showCommunityFeed: z.boolean().optional(),
+171
View File
@@ -0,0 +1,171 @@
import type { ComponentType } from 'react';
import {
TrendingUp,
Egg,
SmilePlus,
Bot,
BookOpen,
Camera,
Music,
CalendarDays,
ScrollText,
} from 'lucide-react';
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
import { BlueskyIcon } from '@/components/icons/BlueskyIcon';
// ── Types ─────────────────────────────────────────────────────────────────────
type IconComponent = ComponentType<{ className?: string }>;
/** Metadata for a widget type that can be added to the right sidebar. */
export interface WidgetDefinition {
/** Unique identifier matching WidgetConfig.id */
id: string;
/** Display label shown in the widget header and picker. */
label: string;
/** Short description for the widget picker. */
description: string;
/** Icon component for the widget header and picker. */
icon: IconComponent;
/** Default height in pixels. */
defaultHeight: number;
/** Minimum height in pixels. */
minHeight: number;
/** Maximum height in pixels. */
maxHeight: number;
/** Category for grouping in the picker. */
category: 'personal' | 'content' | 'discovery';
}
// ── Registry ──────────────────────────────────────────────────────────────────
/** All available widget definitions. */
export const WIDGET_DEFINITIONS: WidgetDefinition[] = [
// Discovery
{
id: 'trends',
label: 'Trending',
description: 'Top trending hashtags with sparkline charts',
icon: TrendingUp,
defaultHeight: 320,
minHeight: 200,
maxHeight: 600,
category: 'discovery',
},
{
id: 'wikipedia',
label: 'Wikipedia',
description: "Today's featured article from Wikipedia",
icon: WikipediaIcon,
defaultHeight: 350,
minHeight: 200,
maxHeight: 600,
category: 'discovery',
},
{
id: 'bluesky',
label: 'Bluesky',
description: 'Trending posts from Bluesky',
icon: BlueskyIcon,
defaultHeight: 400,
minHeight: 250,
maxHeight: 700,
category: 'discovery',
},
// Personal
{
id: 'blobbi',
label: 'Blobbi',
description: 'Your virtual pet companion',
icon: Egg,
defaultHeight: 280,
minHeight: 200,
maxHeight: 500,
category: 'personal',
},
{
id: 'status',
label: 'Status',
description: 'Your current status, editable inline',
icon: SmilePlus,
defaultHeight: 80,
minHeight: 60,
maxHeight: 120,
category: 'personal',
},
{
id: 'ai-chat',
label: 'AI Chat',
description: 'Chat with Shakespeare AI',
icon: Bot,
defaultHeight: 400,
minHeight: 250,
maxHeight: 700,
category: 'personal',
},
// Content feeds
{
id: 'feed:photos',
label: 'Photos',
description: 'Recent photos from your feed',
icon: Camera,
defaultHeight: 400,
minHeight: 250,
maxHeight: 700,
category: 'content',
},
{
id: 'feed:music',
label: 'Music',
description: 'Music tracks from your feed',
icon: Music,
defaultHeight: 350,
minHeight: 250,
maxHeight: 700,
category: 'content',
},
{
id: 'feed:articles',
label: 'Articles',
description: 'Long-form articles from your feed',
icon: ScrollText,
defaultHeight: 350,
minHeight: 250,
maxHeight: 700,
category: 'content',
},
{
id: 'feed:events',
label: 'Events',
description: 'Upcoming calendar events',
icon: CalendarDays,
defaultHeight: 300,
minHeight: 200,
maxHeight: 600,
category: 'content',
},
{
id: 'feed:books',
label: 'Books',
description: 'Book reviews and recommendations',
icon: BookOpen,
defaultHeight: 350,
minHeight: 250,
maxHeight: 700,
category: 'content',
},
];
/** Lookup a widget definition by ID. */
export function getWidgetDefinition(id: string): WidgetDefinition | undefined {
return WIDGET_DEFINITIONS.find((w) => w.id === id);
}
/** Category labels for display in the picker. */
export const WIDGET_CATEGORIES: Record<string, string> = {
personal: 'Personal',
content: 'Content',
discovery: 'Discovery',
};
+1
View File
@@ -112,6 +112,7 @@ export function TestApp({ children }: TestAppProps) {
savedFeeds: [],
imageQuality: 'compressed',
sandboxDomain: 'iframe.diy',
sidebarWidgets: [],
};
return (