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:
@@ -152,6 +152,10 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ id: 'blobbi' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">· {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>
|
||||
);
|
||||
}
|
||||
@@ -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">· {timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -112,6 +112,7 @@ export function TestApp({ children }: TestAppProps) {
|
||||
savedFeeds: [],
|
||||
imageQuality: 'compressed',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
sidebarWidgets: [],
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user