diff --git a/src/App.tsx b/src/App.tsx index ed958194..092ecfa5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -152,6 +152,10 @@ const hardcodedConfig: AppConfig = { imageQuality: 'compressed', curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d', sandboxDomain: 'iframe.diy', + sidebarWidgets: [ + { id: 'trends' }, + { id: 'blobbi' }, + ], }; /** diff --git a/src/components/MainLayout.tsx b/src/components/MainLayout.tsx index ba97987c..6afda81b 100644 --- a/src/components/MainLayout.tsx +++ b/src/components/MainLayout.tsx @@ -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() { )} - {/* Right sidebar — render page-provided sidebar, or an empty placeholder to preserve layout width */} - {rightSidebar ??
} + {/* Right sidebar — render page-provided sidebar, or the widget sidebar */} + {rightSidebar ?? }>}
diff --git a/src/components/WidgetCard.tsx b/src/components/WidgetCard.tsx new file mode 100644 index 00000000..36b8b212 --- /dev/null +++ b/src/components/WidgetCard.tsx @@ -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; + 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 ( +
+ {/* Header */} +
+ {/* Drag handle */} + + + {/* Icon + label */} + + {definition.label} + + {/* Collapse toggle */} + + + {/* Remove */} + +
+ + {/* Content */} + {!collapsed && ( + + +
+ {children} +
+
+ + {/* Resize handle */} +
+
+
+ + )} +
+ ); +} diff --git a/src/components/WidgetPickerDialog.tsx b/src/components/WidgetPickerDialog.tsx new file mode 100644 index 00000000..d95eca8e --- /dev/null +++ b/src/components/WidgetPickerDialog.tsx @@ -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 = {}; + for (const w of WIDGET_DEFINITIONS) { + (groups[w.category] ??= []).push(w); + } + return groups; + }, []); + + return ( + + + + Add Widget + + + +
+ {Object.entries(grouped).map(([category, widgets]) => ( +
+

+ {WIDGET_CATEGORIES[category] ?? category} +

+
+ {widgets.map((widget) => { + const isActive = activeIds.has(widget.id); + const Icon = widget.icon; + return ( + + ); + })} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/WidgetSidebar.tsx b/src/components/WidgetSidebar.tsx new file mode 100644 index 00000000..c85013c6 --- /dev/null +++ b/src/components/WidgetSidebar.tsx @@ -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 ; + case 'blobbi': + return ; + case 'status': + return ; + case 'ai-chat': + return ; + case 'wikipedia': + return ; + case 'bluesky': + return ; + case 'feed:photos': + return ; + case 'feed:music': + return ; + case 'feed:articles': + return ; + case 'feed:events': + return ; + case 'feed:books': + return ; + default: + return

Unknown widget.

; + } +} + +/** Fallback while a widget component is loading. */ +function WidgetSkeleton() { + return ( +
+ + + +
+ ); +} + +// ── 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 ( +
+ onToggleCollapse(config.id)} + onRemove={() => onRemove(config.id)} + onHeightChange={(h) => onHeightChange(config.id, h)} + isDragging={isDragging} + dragHandleProps={listeners} + > + }> + + + +
+ ); +} + +// ── 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 ( + + ); +} diff --git a/src/components/widgets/AIChatWidget.tsx b/src/components/widgets/AIChatWidget.tsx new file mode 100644 index 00000000..282bb377 --- /dev/null +++ b/src/components/widgets/AIChatWidget.tsx @@ -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([]); + const [input, setInput] = useState(''); + const [streamingContent, setStreamingContent] = useState(''); + const scrollRef = useRef(null); + const inputRef = useRef(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 ( +
+ +

Log in to chat with AI

+
+ ); + } + + return ( +
+ {/* Messages area */} + +
+ {messages.length === 0 && !streamingContent && ( +
+ +

Ask me anything...

+
+ )} + {messages.map((msg, i) => ( + + ))} + {streamingContent && ( + + )} + {isLoading && !streamingContent && ( +
+
+ +
+
+ )} +
+
+ + {/* Input area */} +
+
+