chore: delete unreachable page files

Removes 34 page components no longer routed from AppRouter:
content-type feeds (Photos, Videos, Music, Vines, Podcasts,
Books, Treasures, Webxdc, World, Badges, Verified, Archive,
Bluesky, Wikipedia), social/utility pages (Messages, Trends,
UserLists, Bookmarks, AIChat, Follow, Receive), content
creation (CreateEvent, ArticleEditor, LetterCompose), settings
(ContentSettings, LetterPreferences), letter system pages,
relay/domain feeds, and the now-orphaned KindFeedPage base.
This commit is contained in:
Alex Gleason
2026-05-23 12:31:45 -05:00
parent 73bb2a1707
commit b975e55794
34 changed files with 0 additions and 10218 deletions
-534
View File
@@ -1,534 +0,0 @@
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
import { useSeoMeta } from '@unhead/react';
import Markdown from 'react-markdown';
import rehypeSanitize from 'rehype-sanitize';
import { Bot, Loader2, Send, Square, Trash2 } from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useAIChatSession } from '@/hooks/useAIChatSession';
import { LoginArea } from '@/components/auth/LoginArea';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import type { DisplayMessage, ToolCall } from '@/lib/aiChatTools';
// ─── Slash Commands ───
const SLASH_COMMANDS = [
{ command: '/clear', description: 'Clear conversation history' },
{ command: '/new', description: 'Start a new conversation' },
{ command: '/tools', description: 'List available tools' },
];
// ─── Page Component ───
export function AIChatPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
useSeoMeta({
title: `Agent | ${config.appName}`,
description: 'Chat with your AI agent',
});
useLayoutOptions({ noOverscroll: true });
if (!user) {
return (
<main className="flex flex-col items-center justify-center p-6 gap-6">
<div className="flex flex-col items-center gap-4 text-center max-w-sm">
<Bot className="size-12 text-primary" />
<div className="space-y-2">
<h1 className="text-2xl font-bold">Agent</h1>
<p className="text-muted-foreground">Log in with your Nostr account to start using the Agent.</p>
</div>
<LoginArea className="mt-2" />
</div>
</main>
);
}
return <AgentChatView />;
}
// ─── Chat View ───
function AgentChatView() {
const {
messages,
input,
setInput,
isStreaming,
streamingText,
selectedModel,
apiLoading,
apiError,
messagesEndRef,
capacity,
lastPromptTokens,
contextWindow,
storageBytes,
maxStorageBytes,
handleSend,
handleStop,
handleKeyDown,
handleClear,
} = useAIChatSession();
return (
<main className="flex flex-col ai-chat-height sidebar:h-dvh overflow-hidden">
{/* Header */}
<PageHeader titleContent={
<div className="hidden sidebar:flex items-center gap-2 flex-1 min-w-0">
<Bot className="size-5" />
<h1 className="text-xl font-bold truncate">Agent</h1>
</div>
}>
<div className="flex items-center gap-2 ml-auto">
<CapacityRing
capacity={capacity}
promptTokens={lastPromptTokens}
contextWindow={contextWindow}
storageBytes={storageBytes}
maxStorageBytes={maxStorageBytes}
/>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={handleClear}
disabled={messages.length === 0}
title="Clear conversation"
>
<Trash2 className="size-4" />
</Button>
</div>
</PageHeader>
{/* Messages Area */}
{messages.length === 0 && !streamingText ? (
<div className="flex-1 flex items-center justify-center px-4">
<EmptyState onSuggestion={handleSend} />
</div>
) : (
<ScrollArea className="flex-1">
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{messages.map((msg) => (
msg.role !== 'tool_result' && <MessageBubble key={msg.id} message={msg} />
))}
{/* Streaming text */}
{streamingText && (
<div className="flex items-start">
<div className="flex flex-col gap-1 max-w-[85%] min-w-0">
<div className="rounded-2xl px-4 py-2.5 text-sm bg-secondary/60 border border-border rounded-tl-md">
<div className="prose prose-sm max-w-none overflow-wrap-anywhere text-foreground prose-headings:text-foreground prose-strong:text-foreground prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2 prose-code:text-xs prose-code:text-primary prose-code:font-medium prose-a:text-primary">
<Markdown rehypePlugins={[rehypeSanitize]}>
{streamingText}
</Markdown>
</div>
</div>
</div>
</div>
)}
{/* Loading indicator */}
{(isStreaming || apiLoading) && !streamingText && <ThinkingIndicator />}
{/* Error display */}
{apiError && (
apiError.includes('run out of credits') ? (
<ErrorBanner
heading="Agent is temporarily unavailable."
body="Please try again in a moment."
/>
) : apiError.includes('Rate limited') ? (
<ErrorBanner
heading="Rate limited."
body="You're sending messages too fast. Please wait a moment and try again."
/>
) : null
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
)}
{/* Input Area */}
<div className="shrink-0 px-4 pt-2 pb-4 sidebar:pb-3">
<div className="max-w-2xl mx-auto flex items-end gap-2">
<SlashCommandInput
value={input}
onChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
placeholder={!selectedModel ? 'Loading...' : 'Send a message...'}
disabled={!selectedModel || (isStreaming && !streamingText)}
/>
{isStreaming ? (
<Button
onClick={handleStop}
size="icon"
variant="outline"
className="size-11 shrink-0 rounded-xl"
title="Stop generating"
>
<Square className="size-4" />
</Button>
) : (
<Button
onClick={() => handleSend()}
disabled={!input.trim() || !selectedModel}
size="icon"
className="size-11 shrink-0 rounded-xl"
>
<Send className="size-4" />
</Button>
)}
</div>
</div>
</main>
);
}
// ─── Sub-Components ───
function ThinkingIndicator() {
return (
<div className="flex items-start">
<div className="inline-flex items-center gap-2 rounded-2xl rounded-tl-md border border-border bg-secondary/60 px-4 py-3 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin text-primary" />
<span>Thinking...</span>
</div>
</div>
);
}
function ErrorBanner({ heading, body }: { heading: string; body: string }) {
return (
<div className="rounded-2xl bg-secondary/60 border border-border px-4 py-4 text-sm space-y-2">
<p className="font-medium text-foreground">{heading}</p>
<p className="text-muted-foreground">{body}</p>
</div>
);
}
const AGENT_GREETINGS = [
"How can I help you today?",
"What would you like to know?",
"Ready when you are.",
];
const SUGGESTIONS = [
"What are my friends talking about?",
"What's happening in the world?",
];
function EmptyState({ onSuggestion }: { onSuggestion: (text: string) => void }) {
const greeting = useMemo(() => AGENT_GREETINGS[Math.floor(Math.random() * AGENT_GREETINGS.length)], []);
return (
<div className="flex flex-col items-center justify-center gap-8 text-center select-none animate-in fade-in duration-500">
<Bot className="size-12 text-primary" />
<div className="space-y-2">
<h2 className="text-base font-semibold tracking-tight text-foreground">Agent</h2>
<p className="text-sm text-muted-foreground">{greeting}</p>
</div>
<div className="flex flex-wrap justify-center gap-2 max-w-md">
{SUGGESTIONS.map((s) => (
<button
key={s}
onClick={() => onSuggestion(s)}
className="px-4 py-2 rounded-full text-sm border border-border bg-secondary/40 hover:bg-secondary/80 text-foreground transition-colors"
>
{s}
</button>
))}
</div>
</div>
);
}
function MessageBubble({ message }: { message: DisplayMessage }) {
const isUser = message.role === 'user';
// System notices — info vs error styling
if (message.noticeVariant) {
const isError = message.noticeVariant === 'error';
return (
<div className="flex items-start">
<div className="max-w-[85%] min-w-0">
<div className={cn(
'rounded-2xl px-4 py-2.5 text-sm rounded-tl-md border',
isError
? 'bg-red-500/15 border-red-500/25 text-red-700 dark:text-red-400'
: 'bg-primary/15 border-primary/25 text-primary',
)}>
<div className={cn(
'prose prose-sm max-w-none overflow-wrap-anywhere prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-code:text-xs',
isError
? 'text-red-700 dark:text-red-400 prose-strong:text-red-800 dark:prose-strong:text-red-300 prose-code:text-red-600 dark:prose-code:text-red-400 marker:text-red-700 dark:marker:text-red-400'
: 'text-primary prose-strong:text-primary prose-code:text-primary/80 marker:text-primary',
)}>
<Markdown rehypePlugins={[rehypeSanitize]}>
{message.content}
</Markdown>
</div>
</div>
</div>
</div>
);
}
return (
<div className={cn('flex items-start', isUser && 'justify-end')}>
<div className={cn('flex flex-col gap-1 max-w-[85%] min-w-0', isUser && 'items-end')}>
{/* Hide the bubble entirely when the assistant message is empty (tool-only turn) */}
{(isUser || message.content.trim()) && (
<div
className={cn(
'rounded-2xl px-4 py-2.5 text-sm',
isUser
? 'bg-primary text-primary-foreground rounded-tr-md'
: 'bg-secondary/60 border border-border rounded-tl-md',
)}
>
{isUser ? (
<p className="whitespace-pre-wrap overflow-wrap-anywhere">{message.content}</p>
) : (
<div className="prose prose-sm max-w-none overflow-wrap-anywhere text-foreground prose-headings:text-foreground prose-strong:text-foreground prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2 prose-code:text-xs prose-code:text-primary prose-code:font-medium prose-a:text-primary">
<Markdown rehypePlugins={[rehypeSanitize]}>
{message.content}
</Markdown>
</div>
)}
</div>
)}
{/* Tool call indicators */}
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{message.toolCalls.map((tc) => (
<ToolCallBadge key={tc.id} toolCall={tc} />
))}
</div>
)}
<span className="text-[10px] text-muted-foreground/60 px-1">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
);
}
/** Text input with a slash-command autocomplete dropdown. */
function SlashCommandInput({ value, onChange, onKeyDown, onSend, placeholder, disabled }: {
value: string;
onChange: (v: string) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onSend: (override?: string) => void;
placeholder?: string;
disabled?: boolean;
}) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [menuDismissed, setMenuDismissed] = useState(false);
// Filter commands based on input
const matches = useMemo(() => {
if (!value.startsWith('/') || menuDismissed) return [];
const typed = value.toLowerCase();
return SLASH_COMMANDS.filter((c) => c.command.startsWith(typed));
}, [value, menuDismissed]);
const showMenu = matches.length > 0 && !disabled;
// Reset selection when matches change
useEffect(() => {
setSelectedIndex(0);
}, [matches.length]);
// Un-dismiss when input stops being a slash command or is cleared
useEffect(() => {
if (!value.startsWith('/')) setMenuDismissed(false);
}, [value]);
// Close menu on outside click
useEffect(() => {
if (!showMenu) return;
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setMenuDismissed(true);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showMenu]);
const selectCommand = useCallback((cmd: string) => {
onChange(cmd);
setMenuDismissed(true);
// Auto-send slash commands immediately
onSend(cmd);
}, [onChange, onSend]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMenu) {
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => (i - 1 + matches.length) % matches.length);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => (i + 1) % matches.length);
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
e.preventDefault();
selectCommand(matches[selectedIndex].command);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setMenuDismissed(true);
return;
}
}
// Fall through to parent handler (Enter → send, etc.)
onKeyDown(e);
}, [showMenu, matches, selectedIndex, selectCommand, onKeyDown]);
return (
<div ref={wrapperRef} className="relative flex-1 min-w-0">
{/* Autocomplete menu */}
{showMenu && (
<div className="absolute bottom-full left-0 right-0 mb-1.5 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 slide-in-from-bottom-2 duration-150 z-10">
{matches.map((cmd, i) => (
<button
key={cmd.command}
className={cn(
'w-full flex items-center gap-3 px-3.5 py-2.5 text-left text-sm transition-colors',
i === selectedIndex ? 'bg-secondary' : 'hover:bg-secondary/50',
)}
onMouseEnter={() => setSelectedIndex(i)}
onMouseDown={(e) => {
e.preventDefault(); // Keep textarea focus
selectCommand(cmd.command);
}}
>
<span className="font-mono text-xs font-semibold text-foreground">{cmd.command}</span>
<span className="text-muted-foreground text-xs">{cmd.description}</span>
</button>
))}
</div>
)}
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className="min-h-[44px] max-h-40 resize-none bg-secondary/50 border-border focus-visible:ring-1"
rows={1}
/>
</div>
);
}
/** Conversation capacity ring — appears at ≥75% usage. */
function CapacityRing({ capacity, promptTokens, contextWindow, storageBytes, maxStorageBytes }: {
capacity: number;
promptTokens: number;
contextWindow: number;
storageBytes: number;
maxStorageBytes: number;
}) {
if (capacity < 0.75) return null;
const pct = Math.min(capacity * 100, 100);
const size = 20;
const strokeWidth = 2;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (pct / 100) * circumference;
// Color: amber at 75-89%, red at 90%+
const ringColor = pct >= 90 ? 'text-destructive' : 'text-amber-500';
const tokenPct = contextWindow > 0 ? ((promptTokens / contextWindow) * 100).toFixed(0) : '\u2014';
const storageMB = (storageBytes / (1024 * 1024)).toFixed(1);
const maxMB = (maxStorageBytes / (1024 * 1024)).toFixed(0);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help shrink-0">
<svg width={size} height={size} className="transform -rotate-90" viewBox={`0 0 ${size} ${size}`}>
<circle
cx={size / 2} cy={size / 2} r={radius}
fill="none" stroke="currentColor" strokeWidth={strokeWidth}
className="text-muted/30"
/>
<circle
cx={size / 2} cy={size / 2} r={radius}
fill="none" stroke="currentColor" strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
className={`${ringColor} transition-all duration-300 ease-in-out`}
/>
</svg>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">
Tokens: {promptTokens.toLocaleString()} / {contextWindow.toLocaleString()} ({tokenPct}%)
<br />
Storage: {storageMB} / {maxMB} MB
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function ToolCallBadge({ toolCall }: { toolCall: ToolCall }) {
let resultParsed: { success?: boolean; error?: string } = {};
try {
resultParsed = JSON.parse(toolCall.result || '{}');
} catch {
// ignore
}
const isSuccess = resultParsed.success === true || !resultParsed.error;
const TOOL_LABELS: Record<string, string> = {
get_feed: 'Read feed',
search_users: 'Search users',
search_follow_packs: 'Search follow packs',
fetch_page: 'Fetch page',
fetch_event: 'Fetch event',
};
return (
<span className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium',
isSuccess
? 'bg-green-500/10 text-green-700 dark:text-green-400 border border-green-500/20'
: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border border-orange-500/20',
)}>
{resultParsed.error || TOOL_LABELS[toolCall.name] || toolCall.name}
</span>
);
}
-741
View File
@@ -1,741 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Archive, ArrowLeft, Gamepad2, Film, Mic, Monitor, Sparkles, Play, ExternalLink, Clock, Search, X, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppContext } from '@/hooks/useAppContext';
import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ArchiveItem {
/** archive.org item identifier */
identifier: string;
/** Display title */
title: string;
/** Year (optional) */
year?: string;
/** Brief one-liner */
tagline: string;
/** Category for filtering */
category: Category;
}
type Category = 'games' | 'films' | 'audio' | 'software' | 'animation' | 'tv';
interface CategoryMeta {
label: string;
icon: React.ReactNode;
gradient: string;
accent: string;
}
// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------
const CATEGORIES: Record<Category, CategoryMeta> = {
games: {
label: 'Classic Games',
icon: <Gamepad2 className="size-4" />,
gradient: 'from-violet-500/20 to-fuchsia-500/20',
accent: 'text-violet-500 dark:text-violet-400',
},
films: {
label: 'Public Domain Films',
icon: <Film className="size-4" />,
gradient: 'from-amber-500/20 to-orange-500/20',
accent: 'text-amber-600 dark:text-amber-400',
},
audio: {
label: 'Audio Treasures',
icon: <Mic className="size-4" />,
gradient: 'from-emerald-500/20 to-teal-500/20',
accent: 'text-emerald-600 dark:text-emerald-400',
},
tv: {
label: 'Classic Television',
icon: <Monitor className="size-4" />,
gradient: 'from-sky-500/20 to-cyan-500/20',
accent: 'text-sky-600 dark:text-sky-400',
},
animation: {
label: 'Classic Cartoons',
icon: <Sparkles className="size-4" />,
gradient: 'from-pink-500/20 to-rose-500/20',
accent: 'text-pink-500 dark:text-pink-400',
},
software: {
label: 'Software History',
icon: <Archive className="size-4" />,
gradient: 'from-blue-500/20 to-indigo-500/20',
accent: 'text-blue-600 dark:text-blue-400',
},
};
const ITEMS: ArchiveItem[] = [
// ── Classic Games ────────────────────────────────────────────
{
identifier: 'msdos_Oregon_Trail_The_1990',
title: 'The Oregon Trail',
year: '1990',
tagline: 'You have died of dysentery. The game that traumatized a generation.',
category: 'games',
},
{
identifier: 'msdos_Prince_of_Persia_1990',
title: 'Prince of Persia',
year: '1990',
tagline: 'Rotoscoped beauty. 60 minutes to save the princess.',
category: 'games',
},
{
identifier: 'msdos_Wolfenstein_3D_1992',
title: 'Wolfenstein 3D',
year: '1992',
tagline: 'The grandfather of first-person shooters.',
category: 'games',
},
{
identifier: 'msdos_SimCity_1989',
title: 'SimCity',
year: '1989',
tagline: 'Build your dream city. Watch it get destroyed by Godzilla.',
category: 'games',
},
{
identifier: 'msdos_Pac-Man_1983',
title: 'Pac-Man',
year: '1983',
tagline: 'Waka waka waka waka waka.',
category: 'games',
},
{
identifier: 'Doom-2',
title: 'Doom II',
year: '1994',
tagline: 'Rip and tear, until it is done.',
category: 'games',
},
{
identifier: 'msdos_Donkey_Kong_1983',
title: 'Donkey Kong',
year: '1983',
tagline: 'The game that introduced the world to Mario.',
category: 'games',
},
{
identifier: 'msdos_Where_in_the_World_is_Carmen_Sandiego_Enhanced_1989',
title: 'Where in the World is Carmen Sandiego?',
year: '1989',
tagline: 'Geography class never felt this cool.',
category: 'games',
},
{
identifier: 'msdos_Golden_Axe_1990',
title: 'Golden Axe',
year: '1990',
tagline: 'Hack, slash, and ride dragons through a fantasy realm.',
category: 'games',
},
{
identifier: 'msdos_Scorched_Earth_1991',
title: 'Scorched Earth',
year: '1991',
tagline: 'The mother of all games. Nuclear tanks on pixel hills.',
category: 'games',
},
{
identifier: 'msdos_Dune_2_-_The_Building_of_a_Dynasty_1992',
title: 'Dune II',
year: '1992',
tagline: 'The game that invented real-time strategy.',
category: 'games',
},
{
identifier: 'msdos_Leisure_Suit_Larry_1_-_Land_of_the_Lounge_Lizards_1987',
title: 'Leisure Suit Larry',
year: '1987',
tagline: 'The most awkward adventure in gaming history.',
category: 'games',
},
// ── Flash Games ──────────────────────────────────────────────
{
identifier: 'stick-rpg-complete',
title: 'Stick RPG Complete',
tagline: 'The Flash game that consumed entire afternoons.',
category: 'games',
},
{
identifier: 'the-binding-of-isaac_202111',
title: 'The Binding of Isaac (Flash)',
tagline: 'Edmund McMillen\'s dark roguelike masterpiece, original Flash version.',
category: 'games',
},
// ── Public Domain Films ──────────────────────────────────────
{
identifier: 'Nosferatu_most_complete_version_93_mins.',
title: 'Nosferatu',
year: '1922',
tagline: 'The unauthorized Dracula adaptation that became an immortal classic.',
category: 'films',
},
{
identifier: 'Night.Of.The.Living.Dead_1080p',
title: 'Night of the Living Dead',
year: '1968',
tagline: 'George Romero invented an entire genre in one night.',
category: 'films',
},
{
identifier: 'his_girl_friday',
title: 'His Girl Friday',
year: '1940',
tagline: 'Rapid-fire dialogue. Cary Grant at his most charming.',
category: 'films',
},
{
identifier: 'house_on_haunted_hill_ipod',
title: 'House on Haunted Hill',
year: '1959',
tagline: 'Vincent Price offers $10,000 to anyone who survives the night.',
category: 'films',
},
{
identifier: 'Sita_Sings_the_Blues',
title: 'Sita Sings the Blues',
year: '2008',
tagline: 'Ancient Indian epic meets 1920s jazz. A creative commons triumph.',
category: 'films',
},
{
identifier: '774-plan-9-from-outer-space',
title: 'Plan 9 from Outer Space',
year: '1957',
tagline: 'The "worst movie ever made" is the best movie ever made.',
category: 'films',
},
// ── Classic TV ───────────────────────────────────────────────
{
identifier: 'theloneranger_201705',
title: 'The Lone Ranger',
tagline: 'Hi-yo, Silver! Away! Justice rides on horseback.',
category: 'tv',
},
{
identifier: 'get-smart',
title: 'Get Smart',
tagline: 'Would you believe... the funniest spy show ever made?',
category: 'tv',
},
{
identifier: 'GreenAcresCompleteSeries',
title: 'Green Acres',
tagline: 'A New York lawyer moves to the country. Chaos follows.',
category: 'tv',
},
// ── Audio Treasures ──────────────────────────────────────────
{
identifier: 'gd77-05-08.sbd.hicks.4982.sbeok.shnf',
title: 'Grateful Dead - Cornell \'77',
year: '1977',
tagline: 'The greatest live concert recording of all time. No debate.',
category: 'audio',
},
{
identifier: 'alice_in_wonderland_librivox',
title: 'Alice\'s Adventures in Wonderland',
tagline: 'Lewis Carroll\'s masterpiece, read aloud for free. Down the rabbit hole.',
category: 'audio',
},
{
identifier: 'art_of_war_librivox',
title: 'The Art of War',
tagline: 'Sun Tzu\'s timeless strategy treatise. The most downloaded audiobook on the internet.',
category: 'audio',
},
{
identifier: 'ird059',
title: 'The Conet Project',
tagline: 'Recordings of mysterious shortwave numbers stations. Pure Cold War eeriness.',
category: 'audio',
},
{
identifier: 'adventures_holmes',
title: 'The Adventures of Sherlock Holmes',
tagline: 'Elementary, my dear Watson. 12 stories of deduction.',
category: 'audio',
},
// ── Classic Cartoons ─────────────────────────────────────────
{
identifier: 'BettyBoopCartoons',
title: 'Betty Boop Cartoons',
tagline: 'Boop-Oop-a-Doop! Pre-code animation at its most daring.',
category: 'animation',
},
{
identifier: 'superman_the_mechanical_monsters',
title: 'Superman: The Mechanical Monsters',
year: '1941',
tagline: 'Fleischer Studios\' gorgeous Art Deco Superman. Still jaw-dropping.',
category: 'animation',
},
{
identifier: 'popeye_patriotic_popeye',
title: 'Patriotic Popeye',
tagline: 'I yam what I yam! Spinach-fueled heroics.',
category: 'animation',
},
{
identifier: 'bb_minnie_the_moocher',
title: 'Betty Boop: Minnie the Moocher',
tagline: 'Cab Calloway rotoscoped into a ghost walrus. Peak surrealism.',
category: 'animation',
},
{
identifier: 'woody_woodpecker_pantry_panic',
title: 'Woody Woodpecker: Pantry Panic',
tagline: 'Ha-ha-ha-HA-ha! The bird who drove everyone insane.',
category: 'animation',
},
// ── Flash Animations ─────────────────────────────────────────
{
identifier: 'flash_badger',
title: 'Badger Badger Badger',
tagline: 'Mushroom! MUSHROOM! A snake! Peak early internet.',
category: 'animation',
},
{
identifier: 'peanut-butter-jelly-time',
title: 'Peanut Butter Jelly Time',
tagline: 'A dancing banana changed the internet forever.',
category: 'animation',
},
// ── Prelinger Archives / Educational Films ───────────────────
{
identifier: 'DuckandC1951',
title: 'Duck and Cover',
year: '1951',
tagline: 'Bert the Turtle taught kids to survive nuclear war. (Spoiler: no.)',
category: 'films',
},
// ── Software History ─────────────────────────────────────────
{
identifier: 'win95_in_dosbox',
title: 'Windows 95 in Your Browser',
tagline: 'The startup sound that changed personal computing. Press Start.',
category: 'software',
},
{
identifier: 'win3_stock',
title: 'Windows 3.11',
tagline: 'Program Manager, File Manager, Solitaire. The holy trinity.',
category: 'software',
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function thumbnailUrl(identifier: string): string {
return `https://archive.org/services/img/${identifier}`;
}
function archiveUrl(identifier: string): string {
return `https://archive.org/details/${identifier}`;
}
function dittoUrl(identifier: string): string {
return `/i/${encodeURIComponent(archiveUrl(identifier))}`;
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
function CategoryPill({ category, active, onClick }: {
category: Category | 'all';
active: boolean;
onClick: () => void;
}) {
const meta = category === 'all'
? { label: 'All', icon: <Sparkles className="size-3.5" />, accent: 'text-primary' }
: CATEGORIES[category];
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap shrink-0',
active
? 'bg-primary text-primary-foreground shadow-sm'
: 'bg-secondary/60 text-muted-foreground hover:bg-secondary hover:text-foreground',
)}
>
{meta.icon}
{meta.label}
</button>
);
}
function ArchiveCard({ item }: { item: ArchiveItem }) {
const meta = CATEGORIES[item.category];
return (
<Link
to={dittoUrl(item.identifier)}
className="group block rounded-2xl border border-border overflow-hidden bg-card hover:border-primary/30 transition-all duration-300 hover:shadow-lg hover:shadow-primary/5"
>
{/* Thumbnail */}
<div className={cn('relative aspect-[4/3] overflow-hidden bg-gradient-to-br', meta.gradient)}>
<img
src={thumbnailUrl(item.identifier)}
alt={item.title}
loading="lazy"
className="absolute inset-0 w-full h-full object-contain group-hover:scale-105 transition-transform duration-500"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
{/* Hover play overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors duration-300 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-primary/90 rounded-full p-3 shadow-lg">
<Play className="size-5 text-primary-foreground ml-0.5" fill="currentColor" />
</div>
</div>
{/* Year badge */}
{item.year && (
<div className="absolute top-2 right-2 px-2 py-0.5 rounded-md bg-black/60 backdrop-blur-sm text-white text-xs font-medium flex items-center gap-1">
<Clock className="size-3" />
{item.year}
</div>
)}
{/* Category badge */}
<div className={cn(
'absolute bottom-2 left-2 px-2 py-0.5 rounded-md bg-black/60 backdrop-blur-sm text-xs font-medium flex items-center gap-1 text-white',
)}>
{meta.icon}
{meta.label}
</div>
</div>
{/* Content */}
<div className="p-3 space-y-1">
<h3 className="font-semibold text-sm leading-tight group-hover:text-primary transition-colors line-clamp-1">
{item.title}
</h3>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{item.tagline}
</p>
</div>
</Link>
);
}
// ---------------------------------------------------------------------------
// Search bar
// ---------------------------------------------------------------------------
/** Maps archive.org mediatype to a human-friendly label. */
function mediatypeLabel(mediatype: string): string {
switch (mediatype) {
case 'software': return 'Software';
case 'movies': return 'Video';
case 'audio': return 'Audio';
case 'etree': return 'Live Music';
case 'texts': return 'Text';
default: return mediatype;
}
}
function ArchiveSearchBar() {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const { data: results, isFetching } = useArchiveSearch(debouncedQuery);
// 400ms debounce (slightly longer than book search since archive.org can be slower)
const handleChange = useCallback((value: string) => {
setQuery(value);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedQuery(value.trim());
}, 400);
}, []);
useEffect(() => {
return () => clearTimeout(debounceRef.current);
}, []);
// Open dropdown when we have results
useEffect(() => {
if (debouncedQuery.length >= 2 && results && results.length > 0) {
setDropdownOpen(true);
} else if (debouncedQuery.length >= 2 && results && results.length === 0 && !isFetching) {
setDropdownOpen(true);
}
}, [debouncedQuery, results, isFetching]);
// Close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = useCallback((identifier: string) => {
setQuery('');
setDebouncedQuery('');
setDropdownOpen(false);
inputRef.current?.blur();
navigate(dittoUrl(identifier));
}, [navigate]);
const handleClear = useCallback(() => {
setQuery('');
setDebouncedQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setDropdownOpen(false);
inputRef.current?.blur();
}
if (e.key === 'Enter' && results && results.length > 0) {
e.preventDefault();
handleSelect(results[0].identifier);
}
}, [results, handleSelect]);
return (
<div ref={containerRef} className="relative px-4 pb-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
type="text"
placeholder="Search the Internet Archive..."
value={query}
onChange={(e) => handleChange(e.target.value)}
onFocus={() => {
if (debouncedQuery.length >= 2) setDropdownOpen(true);
}}
onKeyDown={handleKeyDown}
className="pl-9 pr-9 h-9 text-base md:text-sm"
/>
{query ? (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
<X className="size-3.5" />
</button>
) : null}
</div>
{/* Search results dropdown */}
{dropdownOpen && debouncedQuery.length >= 2 && (
<div className="absolute left-4 right-4 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg overflow-hidden">
{isFetching && (!results || results.length === 0) ? (
<div className="divide-y divide-border">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="w-10 h-10 rounded shrink-0" />
<div className="flex-1 min-w-0 space-y-1">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
))}
</div>
) : results && results.length > 0 ? (
<div className="divide-y divide-border max-h-80 overflow-y-auto">
{results.map((result) => (
<ArchiveSearchResultItem
key={result.identifier}
result={result}
onSelect={handleSelect}
/>
))}
</div>
) : (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
No results found for &ldquo;{debouncedQuery}&rdquo;
</div>
)}
{/* Loading indicator when results exist but we're refetching */}
{isFetching && results && results.length > 0 && (
<div className="flex justify-center py-2 border-t border-border">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
);
}
function ArchiveSearchResultItem({ result, onSelect }: { result: ArchiveSearchResult; onSelect: (id: string) => void }) {
return (
<button
type="button"
className="flex items-center gap-3 px-3 py-2.5 w-full text-left hover:bg-secondary/60 transition-colors"
onClick={() => onSelect(result.identifier)}
>
<img
src={thumbnailUrl(result.identifier)}
alt=""
className="w-10 h-10 rounded object-cover bg-secondary shrink-0"
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLElement).style.display = 'none'; }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{result.title}</p>
<p className="text-xs text-muted-foreground truncate">
{mediatypeLabel(result.mediatype)}
{result.downloads > 0 && <> &middot; {formatDownloads(result.downloads)} downloads</>}
</p>
</div>
</button>
);
}
/** Format a download count into a compact human-readable string. */
function formatDownloads(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return String(n);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
const CATEGORY_ORDER: (Category | 'all')[] = ['all', 'games', 'films', 'animation', 'audio', 'tv', 'software'];
export function ArchivePage() {
const { config } = useAppContext();
const [activeCategory, setActiveCategory] = useState<Category | 'all'>('all');
useSeoMeta({
title: `Archive | ${config.appName}`,
description: 'Explore the best of the Internet Archive — classic games, films, music, and more.',
});
const filtered = useMemo(() => {
if (activeCategory === 'all') return ITEMS;
return ITEMS.filter((item) => item.category === activeCategory);
}, [activeCategory]);
return (
<main className="pb-16 sidebar:pb-0">
{/* Header */}
<div className="flex items-center gap-4 px-4 pt-4 pb-2">
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<div className="size-8 rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
<Archive className="size-4 text-primary" />
</div>
<div>
<h1 className="text-xl font-bold leading-tight">Archive</h1>
<p className="text-xs text-muted-foreground">Treasures from the Internet Archive</p>
</div>
</div>
<a
href="https://archive.org"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-full hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
title="Visit archive.org"
>
<ExternalLink className="size-4" />
</a>
</div>
{/* Search bar */}
<ArchiveSearchBar />
{/* Category filter pills */}
<div className="sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10 border-b border-border">
<div className="flex gap-2 px-4 py-2.5 overflow-x-auto">
{CATEGORY_ORDER.map((cat) => (
<CategoryPill
key={cat}
category={cat}
active={activeCategory === cat}
onClick={() => setActiveCategory(cat)}
/>
))}
</div>
</div>
{/* Grid of items */}
<div className="px-4 pt-4 pb-4">
<div className="grid grid-cols-2 gap-3 sidebar:grid-cols-3">
{filtered.map((item) => (
<ArchiveCard key={item.identifier} item={item} />
))}
</div>
{filtered.length === 0 && (
<div className="py-16 text-center">
<Sparkles className="size-10 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">No items in this category yet.</p>
</div>
)}
</div>
{/* Attribution footer */}
<div className="px-4 pb-8">
<div className="rounded-xl border border-dashed border-border bg-secondary/30 px-4 py-3 text-center">
<p className="text-xs text-muted-foreground">
Content provided by the{' '}
<a
href="https://archive.org"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
Internet Archive
</a>
, a non-profit digital library. All items are in the public domain or freely available.
</p>
</div>
</div>
</main>
);
}
-136
View File
@@ -1,136 +0,0 @@
import { useEffect, useState } from 'react';
import { useSearchParams, useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { nip19 } from 'nostr-tools';
import type { AddressPointer } from 'nostr-tools/nip19';
import { Loader2 } from 'lucide-react';
import { ArticleEditor, type ArticleData } from '@/components/articles/ArticleEditor';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getLocalDrafts } from '@/lib/localDrafts';
import { parseArticleEvent } from '@/lib/articleHelpers';
/** Thin page wrapper for /articles/new and /articles/edit/:naddr */
export function ArticleEditorPage() {
useLayoutOptions({ showFAB: false, hasSubHeader: true });
const [searchParams] = useSearchParams();
const { naddr: naddrParam } = useParams<{ naddr: string }>();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const draftSlug = searchParams.get('draft');
const [initialData, setInitialData] = useState<(Partial<ArticleData> & { publishedAt?: number }) | undefined>(undefined);
const [editMode, setEditMode] = useState(false);
const [loading, setLoading] = useState(!!naddrParam || !!draftSlug);
// Load draft from relay (NIP-37 kind 31234, encrypted) or localStorage if ?draft=<slug>
useEffect(() => {
if (!draftSlug) return;
const loadDraft = async () => {
if (user?.signer.nip44) {
try {
const events = await nostr.query([
{ kinds: [31234], authors: [user.pubkey], '#d': [draftSlug], limit: 1 },
]);
if (events.length > 0 && events[0].content.trim()) {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, events[0].content);
const inner = JSON.parse(decrypted) as Record<string, unknown>;
const tags = (inner.tags ?? []) as string[][];
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
setInitialData({
title: getTag('title'),
summary: getTag('summary'),
content: (inner.content as string) || '',
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
});
setLoading(false);
return;
}
} catch {
// Fall through to localStorage
}
}
// Fallback to localStorage
const drafts = getLocalDrafts();
const draft = drafts.find((d) => d.slug === draftSlug);
if (draft) {
setInitialData({
title: draft.title,
summary: draft.summary,
content: draft.content,
image: draft.image,
tags: draft.tags,
slug: draft.slug,
});
}
setLoading(false);
};
loadDraft();
}, [draftSlug, user, nostr]);
// Load existing article for editing if /articles/edit/:naddr
useEffect(() => {
if (!naddrParam) return;
let decoded: { type: string; data: AddressPointer };
try {
decoded = nip19.decode(naddrParam) as { type: 'naddr'; data: AddressPointer };
if (decoded.type !== 'naddr') {
setLoading(false);
return;
}
} catch {
setLoading(false);
return;
}
const addr = decoded.data;
// Only allow editing your own articles
if (user && addr.pubkey !== user.pubkey) {
setLoading(false);
return;
}
nostr
.query([
{
kinds: [addr.kind],
authors: [addr.pubkey],
'#d': [addr.identifier],
limit: 1,
},
])
.then((events) => {
if (events.length > 0) {
setInitialData(parseArticleEvent(events[0]));
setEditMode(true);
}
})
.catch((err) => {
console.error('Failed to load article for editing:', err);
})
.finally(() => {
setLoading(false);
});
}, [naddrParam, nostr, user]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return <ArticleEditor initialData={initialData} editMode={editMode} />;
}
File diff suppressed because it is too large Load Diff
-629
View File
@@ -1,629 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useInView } from 'react-intersection-observer';
import {
ArrowLeft,
ExternalLink,
FlameKindling,
Info,
Loader2,
MessageCircle,
Repeat2,
Search,
Share2,
X,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
import { FeedCard } from '@/components/FeedCard';
import { useAppContext } from '@/hooks/useAppContext';
import { useBlueskyTrending, type BlueskyPost } from '@/hooks/useBlueskyTrending';
import { useBlueskyActorSearch, type BlueskyActorResult } from '@/hooks/useBlueskyActorSearch';
import { BlueskyIcon } from '@/components/icons/BlueskyIcon';
import { shareOrCopy } from '@/lib/share';
import { parseExternalUri } from '@/lib/externalContent';
import { useShareOrigin } from '@/hooks/useShareOrigin';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Convert an AT URI (at://did/collection/rkey) into a bsky.app web URL. */
function postWebUrl(uri: string, handle: string): string {
const parts = uri.split('/');
const rkey = parts[parts.length - 1];
return `https://bsky.app/profile/${handle}/post/${rkey}`;
}
/** Convert a bsky.app post URL into our /i/ route. */
function dittoUrl(url: string): string {
return `/i/${encodeURIComponent(url)}`;
}
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function timeAgo(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = now - then;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d`;
const months = Math.floor(days / 30);
return `${months}mo`;
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Post card — feed-style (vertical, like NoteCard)
// ---------------------------------------------------------------------------
function BlueskyFeedPost({ post }: { post: BlueskyPost }) {
const navigate = useNavigate();
const { toast } = useToast();
const shareOrigin = useShareOrigin();
const webUrl = postWebUrl(post.uri, post.author.handle);
const internalUrl = dittoUrl(webUrl);
const profileUrl = dittoUrl(`https://bsky.app/profile/${post.author.handle}`);
const images = post.embed?.$type === 'app.bsky.embed.images#view' ? (post.embed.images ?? []) : [];
const externalEmbed = post.embed?.$type === 'app.bsky.embed.external#view' ? post.embed.external : undefined;
// NIP-73 external content for the reaction button
const externalContent = useMemo(() => parseExternalUri(webUrl), [webUrl]);
const [shareOpen, setShareOpen] = useState(false);
const [commentOpen, setCommentOpen] = useState(false);
const handleComment = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setCommentOpen(true);
}, []);
const handleRepost = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setShareOpen(true);
}, []);
const handleShare = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
const fullUrl = `${shareOrigin}${internalUrl}`;
const result = await shareOrCopy(fullUrl);
if (result === 'copied') {
toast({ title: 'Link copied' });
}
}, [internalUrl, toast, shareOrigin]);
const handleCardClick = useCallback(() => {
navigate(internalUrl);
}, [navigate, internalUrl]);
return (
<>
<article
onClick={handleCardClick}
className="px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer"
>
<div className="flex gap-3">
{/* Avatar */}
<Link to={profileUrl} onClick={(e) => e.stopPropagation()} className="shrink-0">
{post.author.avatar ? (
<img
src={post.author.avatar}
alt=""
className="size-11 rounded-full object-cover"
loading="lazy"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<div className="size-11 rounded-full bg-gradient-to-br from-sky-400 to-blue-500 flex items-center justify-center text-white text-sm font-bold">
{(post.author.displayName ?? post.author.handle).charAt(0).toUpperCase()}
</div>
)}
</Link>
{/* Body */}
<div className="flex-1 min-w-0">
{/* Author info */}
<div className="flex items-center gap-1.5 min-w-0">
<Link to={profileUrl} onClick={(e) => e.stopPropagation()} className="font-semibold text-[15px] truncate leading-tight hover:underline">
{post.author.displayName ?? post.author.handle}
</Link>
<Link to={profileUrl} onClick={(e) => e.stopPropagation()} className="text-muted-foreground text-sm truncate leading-tight hover:underline">
@{post.author.handle}
</Link>
<span className="text-muted-foreground text-sm shrink-0">&middot;</span>
<span className="text-muted-foreground text-sm shrink-0">
{timeAgo(post.record.createdAt)}
</span>
</div>
{/* Post text */}
<p className="mt-1 text-[15px] leading-relaxed whitespace-pre-wrap break-words">
{post.record.text}
</p>
{/* Image embeds */}
{images.length > 0 && (
<div
className={cn(
'mt-3 rounded-xl overflow-hidden border border-border',
images.length === 1 && 'grid grid-cols-1',
images.length === 2 && 'grid grid-cols-2 gap-0.5',
images.length === 3 && 'grid grid-cols-2 gap-0.5',
images.length >= 4 && 'grid grid-cols-2 gap-0.5',
)}
>
{images.slice(0, 4).map((img, i) => (
<div
key={i}
className={cn(
'relative overflow-hidden bg-secondary',
images.length === 1 ? 'aspect-video' : 'aspect-square',
images.length === 3 && i === 0 && 'row-span-2 aspect-auto',
)}
>
<img
src={img.thumb}
alt={img.alt || ''}
loading="lazy"
className="absolute inset-0 w-full h-full object-cover"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
</div>
))}
</div>
)}
{/* External link embed */}
{externalEmbed && (
<div className="mt-3 rounded-xl border border-border overflow-hidden bg-secondary/30">
{externalEmbed.thumb && (
<div className="aspect-[2/1] overflow-hidden bg-secondary">
<img
src={externalEmbed.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
</div>
)}
<div className="px-3 py-2.5 space-y-0.5">
<p className="text-xs text-muted-foreground truncate">
{(() => { try { return new URL(externalEmbed.uri).hostname; } catch { return externalEmbed.uri; } })()}
</p>
{externalEmbed.title && (
<p className="text-sm font-semibold leading-tight line-clamp-2">{externalEmbed.title}</p>
)}
{externalEmbed.description && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">{externalEmbed.description}</p>
)}
</div>
</div>
)}
{/* Action buttons */}
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mt-3">
<button
type="button"
onClick={handleComment}
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 transition-colors"
title="Comment"
>
<MessageCircle className="size-[18px]" />
{post.replyCount > 0 ? (
<span className="tabular-nums">{formatCount(post.replyCount)}</span>
) : (
<span className="hidden sm:inline">Comment</span>
)}
</button>
<button
type="button"
onClick={handleRepost}
className="inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
title="Share to feed"
>
<Repeat2 className="size-[18px]" />
{post.repostCount > 0 ? (
<span className="tabular-nums">{formatCount(post.repostCount)}</span>
) : (
<span className="hidden sm:inline">Repost</span>
)}
</button>
<ExternalReactionButton content={externalContent} count={post.likeCount} variant="chip" />
<div className="flex-1" />
<button
type="button"
onClick={handleShare}
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Share link"
>
<Share2 className="size-[18px]" />
</button>
</div>
</div>
</div>
</article>
{/* Comment compose modal */}
{commentOpen && (
<ReplyComposeModal
open={commentOpen}
onOpenChange={setCommentOpen}
event={new URL(webUrl)}
/>
)}
{/* Share compose modal */}
{shareOpen && (
<ReplyComposeModal
open={shareOpen}
onOpenChange={setShareOpen}
initialContent={webUrl}
title="Share to feed"
/>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Search bar
// ---------------------------------------------------------------------------
function BlueskySearchBar() {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const { data: results, isFetching } = useBlueskyActorSearch(debouncedQuery);
const handleChange = useCallback((value: string) => {
setQuery(value);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedQuery(value.trim());
}, 300);
}, []);
useEffect(() => {
return () => clearTimeout(debounceRef.current);
}, []);
useEffect(() => {
if (debouncedQuery.length >= 1 && results && results.length > 0) {
setDropdownOpen(true);
} else if (debouncedQuery.length >= 1 && results && results.length === 0 && !isFetching) {
setDropdownOpen(true);
}
}, [debouncedQuery, results, isFetching]);
// Close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = useCallback((result: BlueskyActorResult) => {
setQuery('');
setDebouncedQuery('');
setDropdownOpen(false);
inputRef.current?.blur();
navigate(dittoUrl(result.url));
}, [navigate]);
const handleClear = useCallback(() => {
setQuery('');
setDebouncedQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setDropdownOpen(false);
inputRef.current?.blur();
}
if (e.key === 'Enter' && results && results.length > 0) {
e.preventDefault();
handleSelect(results[0]);
}
}, [results, handleSelect]);
return (
<div ref={containerRef} className="relative px-4 pb-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
type="text"
placeholder="Search Bluesky users..."
value={query}
onChange={(e) => handleChange(e.target.value)}
onFocus={() => {
if (debouncedQuery.length >= 1) setDropdownOpen(true);
}}
onKeyDown={handleKeyDown}
className="pl-9 pr-9 h-9 text-base md:text-sm"
/>
{query ? (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
<X className="size-3.5" />
</button>
) : null}
</div>
{/* Search results dropdown */}
{dropdownOpen && debouncedQuery.length >= 1 && (
<div className="absolute left-4 right-4 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg overflow-hidden">
{isFetching && (!results || results.length === 0) ? (
<div className="divide-y divide-border">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-1">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : results && results.length > 0 ? (
<div className="divide-y divide-border max-h-80 overflow-y-auto">
{results.map((result) => (
<button
key={result.did}
type="button"
className="flex items-center gap-3 px-3 py-2.5 w-full text-left hover:bg-secondary/60 transition-colors"
onClick={() => handleSelect(result)}
>
{result.avatar ? (
<img
src={result.avatar}
alt=""
className="size-10 rounded-full object-cover bg-secondary shrink-0"
loading="lazy"
/>
) : (
<div className="size-10 rounded-full bg-gradient-to-br from-sky-500/20 to-blue-500/20 flex items-center justify-center shrink-0">
<BlueskyIcon className="size-4 text-muted-foreground/50" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{result.displayName || result.handle}
</p>
<p className="text-xs text-muted-foreground truncate">
@{result.handle}
</p>
</div>
</button>
))}
</div>
) : (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
No users found for &ldquo;{debouncedQuery}&rdquo;
</div>
)}
{/* Loading indicator when results exist but we're refetching */}
{isFetching && results && results.length > 0 && (
<div className="flex justify-center py-2 border-t border-border">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Loading skeleton (feed-style)
// ---------------------------------------------------------------------------
function BlueskyLoadingSkeleton() {
return (
<FeedCard className="mt-2 divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3.5 w-20" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-40 w-full rounded-xl" />
<div className="flex gap-6 pt-1">
<Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-10" />
</div>
</div>
</div>
</div>
))}
</FeedCard>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function BlueskyPage() {
const { config } = useAppContext();
const {
data,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useBlueskyTrending();
const { ref: loadMoreRef, inView } = useInView();
useSeoMeta({
title: `Bluesky | ${config.appName}`,
description: 'Explore popular posts from Bluesky \u2014 trending discussions, images, and links.',
});
// Flatten pages, deduplicate by URI
const allPosts = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
return data.pages.flatMap((page) => page.posts).filter((post) => {
if (seen.has(post.uri)) return false;
seen.add(post.uri);
return true;
});
}, [data?.pages]);
// Trigger next page fetch when sentinel is in view
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<main className="pb-16 sidebar:pb-0">
{/* Header */}
<div className="flex items-center gap-4 px-4 pt-4 pb-2">
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<div className="size-8 rounded-lg bg-gradient-to-br from-sky-500/20 to-blue-500/10 flex items-center justify-center">
<BlueskyIcon className="size-4 text-sky-500 dark:text-sky-400" />
</div>
<div>
<h1 className="text-xl font-bold leading-tight">Bluesky</h1>
<p className="text-xs text-muted-foreground">Popular posts from the ATmosphere</p>
</div>
</div>
<div className="flex items-center gap-1">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="p-2 rounded-full hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
title="About"
>
<Info className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-72 text-xs text-muted-foreground">
Content provided by{' '}
<a
href="https://bsky.app"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
Bluesky
</a>
, a decentralized social network built on the AT Protocol. All posts are public and belong to their respective authors.
</PopoverContent>
</Popover>
<a
href="https://bsky.app"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-full hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
title="Visit Bluesky"
>
<ExternalLink className="size-4" />
</a>
</div>
</div>
{/* Search bar */}
<BlueskySearchBar />
{/* Content */}
{isLoading ? (
<BlueskyLoadingSkeleton />
) : isError ? (
<div className="px-4 pt-8 pb-16 text-center">
<FlameKindling className="size-10 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">
Couldn&apos;t load Bluesky posts. Try again later.
</p>
</div>
) : allPosts.length === 0 ? (
<div className="py-16 text-center">
<BlueskyIcon className="size-10 mx-auto mb-3 text-muted-foreground/20" />
<p className="text-muted-foreground text-sm">No posts found.</p>
</div>
) : (
<>
<FeedCard className="mt-2">
{allPosts.map((post) => (
<BlueskyFeedPost key={post.uri} post={post} />
))}
</FeedCard>
{/* Infinite scroll sentinel */}
{hasNextPage && (
<div ref={loadMoreRef} className="py-6">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</>
)}
</main>
);
}
-101
View File
@@ -1,101 +0,0 @@
import { useSeoMeta } from '@unhead/react';
import { Bookmark } from 'lucide-react';
import { NoteCard } from '@/components/NoteCard';
import { FeedCard } from '@/components/FeedCard';
import { PageHeader } from '@/components/PageHeader';
import { PullToRefresh } from '@/components/PullToRefresh';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppContext } from '@/hooks/useAppContext';
import { useBookmarks } from '@/hooks/useBookmarks';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { LoginArea } from '@/components/auth/LoginArea';
export function BookmarksPage() {
const { config } = useAppContext();
useSeoMeta({
title: `Bookmarks | ${config.appName}`,
description: 'Your saved bookmarks on Nostr.',
});
const { user } = useCurrentUser();
const { events, isLoading, isLoadingEvents, bookmarkedIds } = useBookmarks();
const handleRefresh = usePageRefresh(['bookmarks']);
return (
<main className="">
<PageHeader title="Bookmarks" icon={<Bookmark className="size-5" />} />
<PullToRefresh onRefresh={handleRefresh}>
{/* Content */}
{!user ? (
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Bookmark className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">Save posts for later</h2>
<p className="text-muted-foreground text-sm">
Log in to bookmark posts and find them here anytime.
</p>
</div>
<LoginArea className="max-w-60" />
</div>
) : isLoading || (bookmarkedIds.length > 0 && isLoadingEvents) ? (
<FeedCard className="mt-2 divide-y divide-border">
{Array.from({ length: 4 }).map((_, i) => (
<BookmarkSkeleton key={i} />
))}
</FeedCard>
) : events.length > 0 ? (
<FeedCard className="mt-2">
{events.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</FeedCard>
) : (
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-muted">
<Bookmark className="size-8 text-muted-foreground" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No bookmarks yet</h2>
<p className="text-muted-foreground text-sm">
When you bookmark a post, it will show up here. Tap the bookmark icon on any post to save it.
</p>
</div>
</div>
)}
</PullToRefresh>
</main>
);
}
function BookmarkSkeleton() {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-8" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex gap-12 mt-2">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
</div>
</div>
</div>
</div>
);
}
-348
View File
@@ -1,348 +0,0 @@
import { useSeoMeta } from "@unhead/react";
import { BookMarked, Loader2, Search, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BookFeedItem, BookFeedItemSkeleton } from "@/components/BookFeedItem";
import { FeedEmptyState } from "@/components/FeedEmptyState";
import { KindInfoButton } from "@/components/KindInfoButton";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SubHeaderBar } from "@/components/SubHeaderBar";
import { TabButton } from "@/components/TabButton";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useLayoutOptions } from "@/contexts/LayoutContext";
import { useAppContext } from "@/hooks/useAppContext";
import { useBookFeed } from "@/hooks/useBookFeed";
import { type BookSearchResult, useBookSearch } from "@/hooks/useBookSearch";
import { usePrefetchBookSummaries } from "@/hooks/useBookSummary";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useFeedTab } from "@/hooks/useFeedTab";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
import { usePageRefresh } from "@/hooks/usePageRefresh";
import { deduplicateEvents } from "@/lib/deduplicateEvents";
import type { ExtraKindDef } from "@/lib/extraKinds";
type FeedTab = "follows" | "global";
const booksDef: ExtraKindDef = {
kind: 31985,
id: "books",
label: "Books",
description: "Book reviews and discussions",
addressable: true,
section: "social",
blurb:
"Discover book reviews, ratings, and discussions from the Nostr community. Track your reading and share your thoughts using the Bookstr protocol.",
sites: [{ url: "https://bookstr.xyz/", name: "Bookstr" }],
};
export function BooksPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const [activeTab, setActiveTab] = useFeedTab<FeedTab>("books", [
"follows",
"global",
]);
useSeoMeta({
title: `Books | ${config.appName}`,
description:
"Book reviews, ratings, and discussions from the Nostr community",
});
useLayoutOptions({ hasSubHeader: !!user });
const feedQuery = useBookFeed(activeTab);
const {
data: rawData,
isPending,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = feedQuery;
const { scrollRef } = useInfiniteScroll({
hasNextPage,
isFetchingNextPage,
fetchNextPage,
pageCount: rawData?.pages?.length,
});
const handleRefresh = usePageRefresh(["book-feed", activeTab]);
const events = deduplicateEvents(rawData?.pages);
// Batch-prefetch book metadata for all visible ISBNs (4 concurrent requests)
usePrefetchBookSummaries(events);
const showSkeleton = isPending || (isLoading && !rawData);
return (
<main className="pb-16 sidebar:pb-0">
<PageHeader title="Books" icon={<BookMarked className="size-5" />}>
<KindInfoButton
kindDef={booksDef}
icon={<BookMarked className="size-10" />}
/>
</PageHeader>
{/* Follows / Global tabs */}
{user && (
<SubHeaderBar>
<TabButton
label="Follows"
active={activeTab === "follows"}
onClick={() => setActiveTab("follows")}
/>
<TabButton
label="Global"
active={activeTab === "global"}
onClick={() => setActiveTab("global")}
/>
</SubHeaderBar>
)}
{/* Book search bar */}
<BookSearchBar />
<PullToRefresh onRefresh={handleRefresh}>
{showSkeleton ? (
<div>
{Array.from({ length: 6 }).map((_, i) => (
<BookFeedItemSkeleton key={i} />
))}
</div>
) : events.length > 0 ? (
<div>
{events.map((event) => (
<BookFeedItem key={event.id} event={event} />
))}
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
) : (
<FeedEmptyState
message={
activeTab === "follows"
? "No book posts from people you follow yet."
: "No book posts or reviews found. Book-related posts tagged with #bookstr or referencing ISBNs will appear here."
}
onSwitchToGlobal={
activeTab === "follows" ? () => setActiveTab("global") : undefined
}
/>
)}
</PullToRefresh>
</main>
);
}
// ---------------------------------------------------------------------------
// Book Search Bar
// ---------------------------------------------------------------------------
function BookSearchBar() {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const { data: results, isFetching } = useBookSearch(debouncedQuery);
// 300ms debounce
const handleChange = useCallback((value: string) => {
setQuery(value);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedQuery(value.trim());
}, 300);
}, []);
// Cleanup debounce timer
useEffect(() => {
return () => clearTimeout(debounceRef.current);
}, []);
// Open dropdown when we have results and input is focused
useEffect(() => {
if (debouncedQuery.length >= 2 && results && results.length > 0) {
setDropdownOpen(true);
} else if (
debouncedQuery.length >= 2 &&
results &&
results.length === 0 &&
!isFetching
) {
setDropdownOpen(true); // show "no results"
}
}, [debouncedQuery, results, isFetching]);
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelect = useCallback(
(isbn: string) => {
setQuery("");
setDebouncedQuery("");
setDropdownOpen(false);
inputRef.current?.blur();
navigate(`/i/isbn:${isbn}`);
},
[navigate],
);
const handleClear = useCallback(() => {
setQuery("");
setDebouncedQuery("");
setDropdownOpen(false);
inputRef.current?.focus();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setDropdownOpen(false);
inputRef.current?.blur();
}
if (e.key === "Enter" && results && results.length > 0) {
e.preventDefault();
handleSelect(results[0].isbn);
}
},
[results, handleSelect],
);
return (
<div ref={containerRef} className="relative px-4 pt-5 pb-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
type="text"
placeholder="Search books by title, author..."
value={query}
onChange={(e) => handleChange(e.target.value)}
onFocus={() => {
if (debouncedQuery.length >= 2) setDropdownOpen(true);
}}
onKeyDown={handleKeyDown}
className="pl-9 pr-9 h-9 text-base md:text-sm"
/>
{query && (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
<X className="size-3.5" />
</button>
)}
</div>
{/* Search results dropdown */}
{dropdownOpen && debouncedQuery.length >= 2 && (
<div className="absolute left-4 right-4 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg overflow-hidden">
{isFetching && (!results || results.length === 0) ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="w-8 h-11 rounded shrink-0" />
<div className="flex-1 min-w-0 space-y-1">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : results && results.length > 0 ? (
<div className="divide-y divide-border max-h-80 overflow-y-auto">
{results.map((book) => (
<BookSearchResultItem
key={book.isbn}
book={book}
onSelect={handleSelect}
/>
))}
</div>
) : (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
No books found for &ldquo;{debouncedQuery}&rdquo;
</div>
)}
</div>
)}
</div>
);
}
function BookSearchResultItem({
book,
onSelect,
}: {
book: BookSearchResult;
onSelect: (isbn: string) => void;
}) {
return (
<button
type="button"
className="flex items-center gap-3 px-3 py-2.5 w-full text-left hover:bg-secondary/60 transition-colors"
onClick={() => onSelect(book.isbn)}
>
{book.coverUrl ? (
<img
src={book.coverUrl}
alt=""
className="w-8 h-11 rounded object-cover shrink-0"
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
) : (
<div className="w-8 h-11 rounded bg-secondary flex items-center justify-center shrink-0">
<BookMarked className="size-3.5 text-muted-foreground/40" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{book.title}</p>
{book.authors.length > 0 && (
<p className="text-xs text-muted-foreground truncate">
{book.authors.join(", ")}
</p>
)}
{book.firstPublishYear && (
<p className="text-xs text-muted-foreground/60">
{book.firstPublishYear}
</p>
)}
</div>
</button>
);
}
-34
View File
@@ -1,34 +0,0 @@
import { useSeoMeta } from '@unhead/react';
import { ContentSettings } from '@/components/ContentSettings';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
export function ContentSettingsPage() {
const { config } = useAppContext();
useSeoMeta({
title: `Home Feed | Settings | ${config.appName}`,
description: 'Choose what types of posts appear in your home feed',
});
return (
<main>
<PageHeader
backTo="/settings"
alwaysShowBack
titleContent={
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold">Home Feed</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Choose what appears in your feed, manage saved searches, and hide content you don't want to see.
</p>
</div>
}
/>
<div className="p-4">
<ContentSettings />
</div>
</main>
);
}
-490
View File
@@ -1,490 +0,0 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { AlertTriangle, ArrowLeft, CalendarDays, Clock, Loader2, Plus } from 'lucide-react';
import { CoverImageField } from '@/components/CoverImageField';
import { CountrySelect } from '@/components/CountrySelect';
import { FormSection } from '@/components/FormSection';
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useToast } from '@/hooks/useToast';
import { getTodayDateInput } from '@/lib/dateInput';
import { COUNTRIES } from '@/lib/countries';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { unixSecondsInTimezone } from '@/lib/timezone';
import { withAgoraTag } from '@/lib/agoraNoteTags';
import { parseContentTagInput } from '@/lib/contentTags';
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function addDays(date: string, days: number): string {
const parsed = new Date(`${date}T00:00:00Z`);
parsed.setUTCDate(parsed.getUTCDate() + days);
return parsed.toISOString().slice(0, 10);
}
export function CreateEventPage() {
useLayoutOptions({ noMaxWidth: true, rightSidebar: null });
const { user } = useCurrentUser();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const { mutateAsync: publishRSVP } = usePublishRSVP();
const { toast } = useToast();
const browserTimezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
[],
);
const minStartDate = useMemo(() => getTodayDateInput(), []);
const pageCountryCode = (searchParams.get('country') || '').toUpperCase();
const initialCountryCode = COUNTRIES[pageCountryCode] ? pageCountryCode : '';
const orgParam = searchParams.get('org');
const orgFromParam = useMemo(() => decodeOrganizationParam(orgParam), [orgParam]);
const { data: manageableOrgs, isLoading: manageableOrgsLoading } = useManageableOrganizations();
const authorizedOrgFromParam = useMemo(() => {
if (!orgFromParam || !manageableOrgs) return null;
return manageableOrgs.find((entry) => entry.community.aTag === orgFromParam.aTag) ?? null;
}, [orgFromParam, manageableOrgs]);
const organizationATag = authorizedOrgFromParam?.community.aTag ?? '';
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [coverImage, setCoverImage] = useState('');
const [coverUploading, setCoverUploading] = useState(false);
const [allDay, setAllDay] = useState(true);
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
const [countryCode, setCountryCode] = useState(initialCountryCode);
const [countryQuery, setCountryQuery] = useState(initialCountryCode ? COUNTRIES[initialCountryCode].name : '');
const [location, setLocation] = useState('');
const [tagInput, setTagInput] = useState('');
const [timezone, setTimezone] = useState(browserTimezone);
const [formError, setFormError] = useState('');
useSeoMeta({
title: 'Create event | Agora',
description: 'Create a calendar event on Agora.',
});
const minEndDate = startDate || minStartDate;
const submitMutation = useMutation({
mutationFn: async () => {
if (!user) throw new Error('You must be logged in to create an event.');
const trimmedTitle = title.trim();
const trimmedDescription = description.trim();
const trimmedLocation = location.trim();
const contentTags = parseContentTagInput(tagInput);
if (!trimmedTitle) throw new Error('Title is required.');
if (!startDate) throw new Error('Start date is required.');
if (startDate < minStartDate) throw new Error('Start date cannot be in the past.');
if (!allDay && !startTime) throw new Error('Start time is required for timed events.');
const dTag = `${slugify(trimmedTitle) || 'event'}-${Date.now()}`;
let kind = 31922;
const tags: string[][] = [
['d', dTag],
['title', trimmedTitle],
['alt', `${organizationATag ? 'Group event' : 'Calendar event'}: ${trimmedTitle}`],
];
if (organizationATag) {
tags.push(...createOrganizationAssociationTags(organizationATag));
}
if (trimmedDescription) {
tags.push(['summary', trimmedDescription]);
}
if (trimmedLocation) {
tags.push(['location', trimmedLocation]);
}
if (countryCode) {
tags.push(['i', createCountryIdentifier(countryCode)]);
}
for (const tag of contentTags) tags.push(['t', tag]);
const trimmedCoverImage = coverImage.trim();
const sanitizedImage = trimmedCoverImage ? sanitizeUrl(trimmedCoverImage) : undefined;
if (trimmedCoverImage && !sanitizedImage) {
throw new Error('Cover image must be a valid https:// URL.');
}
if (sanitizedImage) {
tags.push(['image', sanitizedImage]);
}
if (allDay) {
tags.push(['start', startDate]);
if (endDate) {
if (endDate < startDate) throw new Error('End date must be on or after the start date.');
if (endDate > startDate) tags.push(['end', addDays(endDate, 1)]);
}
} else {
if (endDate && endDate < startDate) throw new Error('End date must be on or after the start date.');
kind = 31923;
const [startYear, startMonth, startDay] = startDate.split('-').map(Number);
const [startHour, startMinute] = startTime.split(':').map(Number);
const startTs = unixSecondsInTimezone(startYear, startMonth, startDay, startHour, startMinute, timezone);
if (!Number.isFinite(startTs) || startTs <= 0) throw new Error('Start date or time is invalid.');
tags.push(['start', String(startTs)]);
tags.push(['D', String(Math.floor(startTs / 86400))]);
tags.push(['start_tzid', timezone]);
if (endDate || endTime) {
const effectiveEndDate = endDate || startDate;
const effectiveEndTime = endTime || startTime;
const [endYear, endMonth, endDay] = effectiveEndDate.split('-').map(Number);
const [endHour, endMinute] = effectiveEndTime.split(':').map(Number);
const endTs = unixSecondsInTimezone(endYear, endMonth, endDay, endHour, endMinute, timezone);
if (!Number.isFinite(endTs) || endTs <= startTs) {
throw new Error('End time must be after the start time.');
}
tags.push(['end', String(endTs)]);
tags.push(['end_tzid', timezone]);
}
}
const publishedEvent = await publishEvent({
kind,
content: trimmedDescription,
tags: withAgoraTag(tags),
});
const eventCoord = `${kind}:${user.pubkey}:${dTag}`;
publishRSVP({
eventCoord,
eventAuthorPubkey: user.pubkey,
status: 'accepted',
}).catch(() => {
// Best-effort: event publishing has already succeeded.
});
queryClient.setQueryData(['addr-event', kind, publishedEvent.pubkey, dTag], publishedEvent);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['feed'] }),
queryClient.invalidateQueries({ queryKey: ['addr-event', kind, publishedEvent.pubkey, dTag] }),
...(organizationATag ? [
queryClient.invalidateQueries({ queryKey: ['community-events', organizationATag] }),
queryClient.invalidateQueries({ queryKey: ['organization-activity', organizationATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(organizationATag);
},
}),
] : []),
]);
return nip19.naddrEncode({
kind,
pubkey: publishedEvent.pubkey,
identifier: dTag,
});
},
onSuccess: (naddr) => {
toast({ title: 'Event created' });
navigate(`/${naddr}`);
},
onError: (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
setFormError(msg);
toast({
title: 'Could not create event',
description: msg,
variant: 'destructive',
});
},
});
if (!user) {
return (
<main className="min-h-screen pb-16">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<Card>
<CardContent className="py-12 px-8 text-center space-y-4">
<CalendarDays className="size-10 text-muted-foreground/60 mx-auto" />
<h2 className="text-xl font-semibold">Log in to create an event</h2>
<p className="text-muted-foreground">
Events are signed Nostr events. You need a Nostr login to publish one.
</p>
<Button asChild>
<Link to="/events">Back to events</Link>
</Button>
</CardContent>
</Card>
</div>
</main>
);
}
const handleAllDayChange = (checked: boolean) => {
setAllDay(checked);
if (checked && startDate && endDate && endDate < startDate) {
setEndDate(startDate);
}
};
const handleStartDateChange = (nextStartDate: string) => {
setStartDate(nextStartDate);
if (nextStartDate && endDate && endDate < nextStartDate) {
setEndDate(nextStartDate);
}
};
const canSubmit =
title.trim().length > 0 &&
startDate.length > 0 &&
(allDay || startTime.length > 0) &&
!coverUploading &&
!submitMutation.isPending;
return (
<main className="min-h-screen pb-16">
<form
className="max-w-3xl mx-auto px-4 sm:px-6 py-8 lg:py-10 space-y-5"
onSubmit={(e) => {
e.preventDefault();
setFormError('');
submitMutation.mutate();
}}
>
<div>
<div className="flex items-center gap-2 -ml-2">
<button
type="button"
onClick={() => navigate(-1)}
className="p-2 rounded-full hover:bg-secondary motion-safe:transition-colors text-muted-foreground hover:text-foreground"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
Create event
</h1>
</div>
<OrganizationContextChip
aTag={organizationATag}
authorizedOrg={authorizedOrgFromParam}
param={orgParam}
paramDecoded={orgFromParam}
manageableLoading={manageableOrgsLoading}
/>
</div>
<div className="rounded-2xl bg-card/50 p-2">
<FormSection title="Title" requirement="Required">
<Input
placeholder="Neighborhood cleanup"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
required
/>
</FormSection>
<FormSection title="Cover image" requirement="Recommended">
<CoverImageField
value={coverImage}
onChange={setCoverImage}
onUploadingChange={setCoverUploading}
/>
</FormSection>
<FormSection title="Description" requirement="Recommended">
<Textarea
placeholder="Tell people what to expect, what to bring, and who should attend..."
rows={7}
className="font-mono text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</FormSection>
<FormSection title="Schedule" requirement="Required">
<div className="flex items-center justify-between gap-4 rounded-xl border border-border px-3 py-3">
<div className="space-y-0.5">
<Label htmlFor="event-all-day">All-day event</Label>
<p className="text-xs text-muted-foreground">Turn off to add start and end times.</p>
</div>
<Switch id="event-all-day" checked={allDay} onCheckedChange={handleAllDayChange} />
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="event-start-date" className="flex items-center gap-2">
Start date
<span className="text-xs font-medium text-muted-foreground">Required</span>
</Label>
<Input
id="event-start-date"
type="date"
min={minStartDate}
className="w-full min-w-0 [color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
value={startDate}
onChange={(e) => handleStartDateChange(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="event-end-date" className="flex items-center gap-2">
End date
<span className="text-xs font-medium text-muted-foreground">Optional</span>
</Label>
<Input
id="event-end-date"
type="date"
min={minEndDate}
className="w-full min-w-0 [color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
{!allDay && (
<div className="space-y-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="event-start-time">Start time *</Label>
<Input
id="event-start-time"
type="time"
className="w-full min-w-0 [color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="event-end-time">End time</Label>
<Input
id="event-end-time"
type="time"
className="w-full min-w-0 [color-scheme:light] dark:[color-scheme:dark] dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-80"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
<div className="bg-muted/30 p-3 rounded-lg border border-border/50 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Clock className="h-4 w-4" /> Timezone
</div>
<TimezoneSwitcher value={timezone} onChange={setTimezone} />
</div>
</div>
)}
</FormSection>
<FormSection title="Country" requirement="Recommended">
<CountrySelect
id="event-country"
query={countryQuery}
selectedCode={countryCode}
onQueryChange={(value) => {
setCountryQuery(value);
const selectedCountry = countryCode ? COUNTRIES[countryCode] : undefined;
if (selectedCountry && value !== selectedCountry.name && value.toUpperCase() !== countryCode) {
setCountryCode('');
}
}}
onSelect={(country) => {
setCountryCode(country.code);
setCountryQuery(country.name);
}}
onClear={() => {
setCountryCode('');
setCountryQuery('');
}}
/>
</FormSection>
<FormSection title="Location details" requirement="Recommended">
<Input
placeholder="Address, venue, or video call link"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</FormSection>
<FormSection title="Tags" requirement="Recommended">
<Input
id="event-tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="mutual-aid, workshop, local-news"
/>
</FormSection>
</div>
{formError && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<div className="pt-1">
<Button type="submit" disabled={!canSubmit} className="w-full">
{submitMutation.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Publishing
</>
) : coverUploading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Uploading cover
</>
) : (
<>
<Plus className="size-4 mr-2" />
Create event
</>
)}
</Button>
</div>
</form>
</main>
);
}
export default CreateEventPage;
-158
View File
@@ -1,158 +0,0 @@
import { useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { NoteCard } from '@/components/NoteCard';
import { FeedCard } from '@/components/FeedCard';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { PullToRefresh } from '@/components/PullToRefresh';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppContext } from '@/hooks/useAppContext';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useMuteList } from '@/hooks/useMuteList';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { isRepostKind } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import { PageHeader } from '@/components/PageHeader';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Fetches a nostr.json URL. Tries direct first, falls back to CORS proxy.
*/
async function fetchNostrJson(url: URL, signal: AbortSignal): Promise<Record<string, unknown> | null> {
try {
const response = await fetch(url, { signal });
if (response.ok) {
return await response.json();
}
} catch {
// fallthrough
}
return null;
}
/**
* Fetches the NIP-05 JSON from a domain's .well-known/nostr.json endpoint
* and returns the pubkeys of all users registered on that domain.
*/
function useDomainPubkeys(domain: string | undefined) {
return useQuery<string[]>({
queryKey: ['domain-pubkeys', domain],
queryFn: async ({ signal }) => {
if (!domain) return [];
const fetchSignal = AbortSignal.any([signal, AbortSignal.timeout(800)]);
const data = await fetchNostrJson(new URL('/.well-known/nostr.json', `https://${domain}`), fetchSignal);
if (!data) throw new Error('Failed to fetch nostr.json');
if (!data.names || typeof data.names !== 'object') return [];
return Object.values(data.names).filter((pk): pk is string => typeof pk === 'string');
},
enabled: !!domain,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});
}
export function DomainFeedPage() {
const { config } = useAppContext();
const { domain } = useParams<{ domain: string }>();
const navigate = useNavigate();
const { nostr } = useNostr();
const { feedSettings } = useFeedSettings();
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
const kindsKey = [...kinds].sort().join(',');
useSeoMeta({
title: domain ? `${domain} | ${config.appName}` : `Domain Feed | ${config.appName}`,
description: domain ? `Posts from users on ${domain}` : 'Domain feed',
});
const { muteItems } = useMuteList();
const refreshQueryKey = useMemo(
() => [['domain-pubkeys', domain], ['domain-feed', domain]],
[domain],
);
const handleRefresh = usePageRefresh(refreshQueryKey);
const { data: pubkeys, isLoading: pubkeysLoading, isError: pubkeysError } = useDomainPubkeys(domain);
const { data: events, isLoading: eventsLoading } = useQuery<NostrEvent[]>({
queryKey: ['domain-feed', domain, pubkeys?.length ?? 0, kindsKey],
queryFn: async ({ signal }) => {
if (!pubkeys || pubkeys.length === 0) return [];
const results = await nostr.query(
[{ kinds, authors: pubkeys, limit: 40 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
return results.sort((a, b) => b.created_at - a.created_at);
},
enabled: !!pubkeys && pubkeys.length > 0,
});
const filteredEvents = useMemo(() => {
if (!events || muteItems.length === 0) return events;
return events.filter((e) => !isEventMuted(e, muteItems));
}, [events, muteItems]);
const isLoading = pubkeysLoading || eventsLoading;
return (
<main className="">
<PageHeader
onBack={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
titleContent={
<div className="flex items-center gap-2 min-w-0">
<ExternalFavicon url={domain ? `https://${domain}` : undefined} size={20} />
<div className="min-w-0">
<h1 className="text-lg font-bold truncate leading-tight">{domain}</h1>
{pubkeys && pubkeys.length > 0 && (
<p className="text-xs text-muted-foreground leading-tight">
{pubkeys.length} user{pubkeys.length !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
}
/>
<PullToRefresh onRefresh={handleRefresh}>
{pubkeysError ? (
<div className="py-16 text-center text-muted-foreground">
<p>Could not fetch users from {domain}.</p>
<p className="text-xs mt-2">Make sure the domain has a valid /.well-known/nostr.json</p>
</div>
) : isLoading ? (
<FeedCard className="mt-2 divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</FeedCard>
) : filteredEvents && filteredEvents.length > 0 ? (
<FeedCard className="mt-2">
{filteredEvents.map((event) => <NoteCard key={event.id} event={event} />)}
</FeedCard>
) : pubkeys && pubkeys.length === 0 ? (
<div className="py-16 text-center text-muted-foreground">
No users found on {domain}.
</div>
) : (
<div className="py-16 text-center text-muted-foreground">
No posts found from users on {domain}.
</div>
)}
</PullToRefresh>
</main>
);
}
-207
View File
@@ -1,207 +0,0 @@
import type { NostrEvent } from "@nostrify/nostrify";
import { useSeoMeta } from "@unhead/react";
import { CalendarDays, Loader2 } from "lucide-react";
import { useMemo } from "react";
import { FeedEmptyState } from "@/components/FeedEmptyState";
import { KindInfoButton } from "@/components/KindInfoButton";
import { NoteCard } from "@/components/NoteCard";
import { FeedCard } from "@/components/FeedCard";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SubHeaderBar } from "@/components/SubHeaderBar";
import { TabButton } from "@/components/TabButton";
import { Skeleton } from "@/components/ui/skeleton";
import { useLayoutOptions } from "@/contexts/LayoutContext";
import { useAppContext } from "@/hooks/useAppContext";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useFeed } from "@/hooks/useFeed";
import { useFeedTab } from "@/hooks/useFeedTab";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
import { useMuteList } from "@/hooks/useMuteList";
import { usePageRefresh } from "@/hooks/usePageRefresh";
import { getExtraKindDef } from "@/lib/extraKinds";
import { isEventMuted } from "@/lib/muteHelpers";
import { sidebarItemIcon } from "@/lib/sidebarItems";
type FeedTab = "follows" | "global";
const eventsDef = getExtraKindDef("events")!;
/** Extract the first value of a tag by name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function getEventStartTimestamp(event: NostrEvent): number {
const start = getTag(event.tags, "start");
if (!start) return 0;
if (event.kind === 31922) {
const timestamp = Date.parse(`${start}T00:00:00Z`);
return Number.isNaN(timestamp) ? 0 : Math.floor(timestamp / 1000);
}
const timestamp = parseInt(start, 10);
return Number.isNaN(timestamp) ? 0 : timestamp;
}
// ─── EventsFeedPage ───────────────────────────────────────────────────────────
export function EventsFeedPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { muteItems } = useMuteList();
const [activeTab, setActiveTab] = useFeedTab<FeedTab>("events", [
"follows",
"global",
]);
useSeoMeta({ title: `Events | ${config.appName}` });
useLayoutOptions({
showFAB: true,
fabHref: "/events/new",
fabIcon: <CalendarDays className="size-5" />,
hasSubHeader: !!user,
});
// Calendar events feed
const feedQuery = useFeed(activeTab, { kinds: [31922, 31923] });
const handleRefresh = usePageRefresh(useMemo(() => ["feed", activeTab], [activeTab]));
const {
data: rawData,
isPending,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = feedQuery;
const { scrollRef } = useInfiniteScroll({
hasNextPage,
isFetchingNextPage,
fetchNextPage,
pageCount: rawData?.pages?.length,
});
// Flatten, deduplicate, filter muted, then sort: future events first
const feedItems = useMemo(() => {
if (!rawData?.pages) return [];
const seen = new Set<string>();
const now = Math.floor(Date.now() / 1000);
const items = (
rawData.pages as { items: { event: NostrEvent; repostedBy?: string }[] }[]
)
.flatMap((page) => page.items)
.filter((item) => {
if (seen.has(item.event.id)) return false;
seen.add(item.event.id);
if (muteItems.length > 0 && isEventMuted(item.event, muteItems))
return false;
return true;
});
return items.sort((a, b) => {
const aStart = getEventStartTimestamp(a.event);
const bStart = getEventStartTimestamp(b.event);
const aFuture = aStart >= now;
const bFuture = bStart >= now;
if (aFuture && !bFuture) return -1;
if (!aFuture && bFuture) return 1;
if (aFuture && bFuture) return aStart - bStart;
return bStart - aStart;
});
}, [rawData?.pages, muteItems]);
const showSkeleton = isPending || (isLoading && !rawData);
return (
<main className="max-w-2xl mx-auto">
<PageHeader title="Events" icon={<CalendarDays className="size-5" />}>
<KindInfoButton
kindDef={eventsDef}
icon={sidebarItemIcon("events", "size-5")}
/>
</PageHeader>
{/* Follows / Global tabs */}
{user && (
<SubHeaderBar>
<TabButton
label="Follows"
active={activeTab === "follows"}
onClick={() => setActiveTab("follows")}
/>
<TabButton
label="Global"
active={activeTab === "global"}
onClick={() => setActiveTab("global")}
/>
</SubHeaderBar>
)}
<PullToRefresh onRefresh={handleRefresh}>
{showSkeleton ? (
<FeedCard className="mt-2 divide-y divide-border">
{Array.from({ length: 4 }).map((_, i) => (
<EventCardSkeleton key={i} />
))}
</FeedCard>
) : feedItems.length > 0 ? (
<>
<FeedCard className="mt-2">
{feedItems.map((item) => (
<NoteCard key={item.event.id} event={item.event} />
))}
</FeedCard>
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</>
) : (
<FeedEmptyState
message={
activeTab === "follows"
? "No events from people you follow yet."
: "No calendar events found. Check your relay connections or try again later."
}
onSwitchToGlobal={
activeTab === "follows" ? () => setActiveTab("global") : undefined
}
/>
)}
</PullToRefresh>
</main>
);
}
// ─── Skeletons ────────────────────────────────────────────────────────────────
function EventCardSkeleton() {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="min-w-0 space-y-1.5 flex-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
</div>
<div className="mt-2 space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
</div>
);
}
-563
View File
@@ -1,563 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useInView } from 'react-intersection-observer';
import { nip19 } from 'nostr-tools';
import { UserPlus, Loader2, CheckCircle2 } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteCard } from '@/components/NoteCard';
import { FeedCard } from '@/components/FeedCard';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { useToast } from '@/hooks/useToast';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useProfileFeed, filterByTab } from '@/hooks/useProfileFeed';
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
import { parsePackEvent } from '@/lib/packUtils';
import { PackFeedTab, MemberCard, MemberCardSkeleton } from '@/components/FollowPackDetailContent';
import { genUserName } from '@/lib/genUserName';
import { Nip05Badge } from '@/components/Nip05Badge';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { TopNav } from '@/components/TopNav';
import AuthDialog from '@/components/auth/AuthDialog';
import type { FeedItem } from '@/lib/feedUtils';
import type { AddressPointer } from 'nostr-tools/nip19';
import NotFound from './NotFound';
// ---------------------------------------------------------------------------
// Profile feed (reuses useProfileFeed + NoteCard)
// ---------------------------------------------------------------------------
function ProfileFeed({ pubkey }: { pubkey: string }) {
const {
data: feedData,
isPending,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProfileFeed(pubkey, 'posts');
const feedItems = useMemo(() => {
if (!feedData?.pages) return [];
const seen = new Set<string>();
const items: FeedItem[] = [];
for (const page of feedData.pages) {
for (const item of page.items) {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (!seen.has(key)) {
seen.add(key);
items.push(item);
}
}
}
return filterByTab(items, 'posts');
}, [feedData?.pages]);
const { ref: scrollRef, inView } = useInView({ threshold: 0 });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isPending) {
return (
<div>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="px-4 py-3 border-b border-border">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
if (feedItems.length === 0) return null;
return (
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
compact
/>
))}
{hasNextPage && (
<div ref={scrollRef} className="flex justify-center py-6">
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main follow view
// ---------------------------------------------------------------------------
function FollowView({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const { isPending, follow } = useFollowActions();
const { toast } = useToast();
const navigate = useNavigate();
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
const bannerUrl = metadata?.banner;
const isOwnProfile = user && user.pubkey === pubkey;
const isAlreadyFollowing = followData?.pubkeys.includes(pubkey) ?? false;
const isLoggedOut = !user;
const [loginOpen, setLoginOpen] = useState(false);
const hasAutoFollowed = useRef(false);
const [followDone, setFollowDone] = useState(false);
useEffect(() => {
if (!user || isOwnProfile || isAlreadyFollowing || hasAutoFollowed.current || isPending) return;
if (!followData) return;
hasAutoFollowed.current = true;
follow(pubkey)
.then(() => {
setFollowDone(true);
toast({ title: 'Followed!', description: `You are now following ${displayName}` });
})
.catch((err) => {
console.error('Auto-follow failed:', err);
hasAutoFollowed.current = false;
toast({ title: 'Something went wrong', variant: 'destructive' });
});
}, [user, isOwnProfile, isAlreadyFollowing, followData, isPending, pubkey, follow, displayName, toast]);
return (
<div className="min-h-screen bg-background/85">
<TopNav />
<main>
{/* Profile header */}
<div className="border-b border-border bg-background/85">
{/* Banner — matches ProfilePage: clean edge, no gradient */}
<div className="h-36 md:h-48 bg-secondary relative">
{author.isLoading ? (
<Skeleton className="w-full h-full rounded-none" />
) : bannerUrl ? (
<img src={bannerUrl} alt="" className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />
)}
</div>
{/* Profile card */}
<div className="flex flex-col items-center px-4 -mt-12 pb-6 md:-mt-16 relative z-10 max-w-2xl mx-auto w-full">
{/* Avatar — matches ProfilePage border treatment */}
<div className="relative">
<Avatar className="size-24 md:size-32 border-4 border-background shadow-lg">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-2xl md:text-3xl">
{displayName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{(followDone || isAlreadyFollowing) && (
<div className="absolute -bottom-1 -right-1 bg-background rounded-full p-0.5 shadow">
<CheckCircle2 className="size-6 text-primary fill-primary/20" />
</div>
)}
</div>
{/* Name + NIP-05 */}
<div className="mt-3 text-center">
<h1 className="text-xl font-bold text-foreground">{displayName}</h1>
{metadata?.nip05 && (
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey} className="justify-center mt-1" />
)}
</div>
{/* CTA — right under the name */}
<div className="mt-4 w-full max-w-xs">
{isLoggedOut ? (
<Button
onClick={() => setLoginOpen(true)}
className="w-full rounded-full py-3 text-base font-semibold"
size="lg"
>
Follow {displayName} on Agora
</Button>
) : isOwnProfile ? (
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-2 text-primary">
<UserPlus className="size-5" />
<p className="font-semibold">
This is your profile follow link
</p>
</div>
<div className="flex flex-col gap-2">
<Link to={profileUrl}>
<Button className="rounded-full w-full">View your profile</Button>
</Link>
</div>
</div>
) : isPending ? (
<div className="flex flex-col items-center space-y-2">
<Loader2 className="size-6 text-primary animate-spin" />
<p className="text-sm text-muted-foreground">Following...</p>
</div>
) : followDone || isAlreadyFollowing ? (
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-2 text-primary">
<UserPlus className="size-5" />
<p className="font-semibold">
{isAlreadyFollowing && !followDone ? 'Already following!' : 'Now following!'}
</p>
</div>
<div className="flex flex-col gap-2">
<Link to={profileUrl}>
<Button className="rounded-full w-full">View profile</Button>
</Link>
<Button variant="secondary" className="rounded-full" onClick={() => navigate('/feed')}>
Go to feed
</Button>
</div>
</div>
) : null}
</div>
</div>
</div>
{/* Feed */}
<div>
<div className="max-w-2xl mx-auto w-full bg-background/85">
<ProfileFeed pubkey={pubkey} />
</div>
</div>
</main>
<AuthDialog
isOpen={loginOpen}
onClose={() => setLoginOpen(false)}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Immersive follow pack/set view
// ---------------------------------------------------------------------------
type PackTab = 'feed' | 'members';
function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[] }) {
const { data: event, isLoading: eventLoading } = useAddrEvent(addr, relays);
const { user } = useCurrentUser();
const { data: followList } = useFollowList();
const { followMany } = useFollowActions();
const { toast } = useToast();
const navigate = useNavigate();
const [loginOpen, setLoginOpen] = useState(false);
const [activeTab, setActiveTab] = useState<PackTab>('feed');
const [isFollowingAll, setIsFollowingAll] = useState(false);
const author = useAuthor(addr.pubkey);
const authorMeta = author.data?.metadata;
const authorName = authorMeta?.name || genUserName(addr.pubkey);
const { title, description, image, pubkeys } = useMemo(
() => (event ? parsePackEvent(event) : { title: 'Loading...', description: '', image: undefined, pubkeys: [] }),
[event],
);
const { data: membersMap, isLoading: membersLoading } = useAuthors(pubkeys);
const followedPubkeys = useMemo(() => new Set(followList?.pubkeys ?? []), [followList]);
const followingCount = useMemo(
() => pubkeys.filter((pk) => followedPubkeys.has(pk)).length,
[pubkeys, followedPubkeys],
);
const allFollowed = pubkeys.length > 0 && followingCount === pubkeys.length;
const newCount = pubkeys.length - followingCount;
const bannerUrl = image || authorMeta?.banner;
/** Follow All using the shared useFollowActions.followMany mutation. */
const handleFollowAll = useCallback(async () => {
if (!user) return;
setIsFollowingAll(true);
try {
const added = await followMany(pubkeys);
toast({
title: allFollowed ? 'Already following all!' : 'Following all!',
description: added > 0
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
: 'You were already following everyone in this pack.',
});
} catch (error) {
console.error('Failed to follow all:', error);
toast({
title: 'Failed to follow',
description: 'There was an error updating your follow list.',
variant: 'destructive',
});
} finally {
setIsFollowingAll(false);
}
}, [user, pubkeys, followMany, toast, allFollowed]);
if (eventLoading) {
return (
<div className="min-h-screen bg-background/85">
<TopNav />
<div>
<div className="h-36 md:h-48 bg-secondary relative">
<Skeleton className="w-full h-full rounded-none" />
</div>
<div className="border-b border-border bg-background/85">
<div className="flex flex-col items-center px-4 pt-6 pb-6 max-w-2xl mx-auto w-full space-y-3">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-64" />
<Skeleton className="h-10 w-56 rounded-full" />
</div>
</div>
</div>
</div>
);
}
if (!event) return <NotFound />;
return (
<div className="min-h-screen bg-background/85">
<TopNav />
{/* Header */}
<div className="border-b border-border bg-background/85">
{/* Banner */}
<div className="h-36 md:h-48 bg-secondary relative">
{bannerUrl ? (
<img src={bannerUrl} alt="" className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />
)}
</div>
{/* Pack info */}
<div className="flex flex-col items-center px-4 -mt-6 pb-4 relative z-10 max-w-2xl mx-auto w-full">
{/* Avatar stack (first 5 members) */}
<div className="flex -space-x-3 mb-3">
{pubkeys.slice(0, 5).map((pk) => {
const member = membersMap?.get(pk);
const name = member?.metadata?.name || genUserName(pk);
return (
<Avatar key={pk} className="size-12 border-2 border-background shadow-md">
<AvatarImage src={member?.metadata?.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
{pubkeys.length > 5 && (
<div className="size-12 rounded-full border-2 border-background bg-secondary flex items-center justify-center shadow-md">
<span className="text-xs font-medium text-muted-foreground">+{pubkeys.length - 5}</span>
</div>
)}
</div>
{/* Title */}
<h1 className="text-xl font-bold text-foreground text-center">{title}</h1>
{/* Author attribution */}
<Link to={`/${nip19.npubEncode(addr.pubkey)}`} className="flex items-center gap-1.5 mt-1.5 hover:underline">
<Avatar className="size-5">
<AvatarImage src={authorMeta?.picture} alt={authorName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{authorName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">by {authorName}</span>
</Link>
{/* Description */}
{description && (
<p className="text-sm text-muted-foreground text-center mt-2 max-w-sm whitespace-pre-wrap">
{description}
</p>
)}
{/* Big CTA button */}
<div className="mt-4 w-full max-w-xs">
{!user ? (
<Button
onClick={() => setLoginOpen(true)}
className="w-full rounded-full py-3 text-base font-semibold gap-2"
size="lg"
>
<UserPlus className="size-5" />
Follow {pubkeys.length} people on Agora
</Button>
) : isFollowingAll ? (
<Button disabled className="w-full rounded-full py-3 text-base font-semibold gap-2" size="lg">
<Loader2 className="size-5 animate-spin" />
Following...
</Button>
) : allFollowed ? (
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-2 text-primary">
<CheckCircle2 className="size-5" />
<p className="font-semibold">Following all {pubkeys.length} people</p>
</div>
<Button variant="secondary" className="rounded-full w-full" onClick={() => navigate('/feed')}>
Go to feed
</Button>
</div>
) : (
<div className="space-y-2">
<Button
onClick={handleFollowAll}
className="w-full rounded-full py-3 text-base font-semibold gap-2"
size="lg"
>
<UserPlus className="size-5" />
Follow All ({pubkeys.length})
</Button>
{followingCount > 0 && (
<p className="text-center text-sm text-muted-foreground">
Already following {followingCount} of {pubkeys.length}
{' '}&middot;{' '}
<span className="text-green-600 dark:text-green-400">{newCount} new</span>
</p>
)}
</div>
)}
</div>
</div>
</div>
{/* Tab bar */}
<SubHeaderBar innerClassName="max-w-2xl mx-auto">
<TabButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
<TabButton label={`Members (${pubkeys.length})`} active={activeTab === 'members'} onClick={() => setActiveTab('members')} />
</SubHeaderBar>
{/* Tab content */}
<div>
<div className="max-w-2xl mx-auto w-full bg-background/85 pt-2">
{activeTab === 'feed' ? (
<PackFeedTab pubkeys={pubkeys} />
) : membersLoading ? (
<FeedCard className="mt-2 divide-y divide-border">
{Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => (
<MemberCardSkeleton key={i} />
))}
</FeedCard>
) : (
<FeedCard className="mt-2 divide-y divide-border">
{pubkeys.map((pk) => {
const member = membersMap?.get(pk);
const isFollowed = followedPubkeys.has(pk);
return (
<MemberCard
key={pk}
pubkey={pk}
metadata={member?.metadata}
isFollowed={isFollowed}
isSelf={pk === user?.pubkey}
/>
);
})}
</FeedCard>
)}
</div>
</div>
<AuthDialog
isOpen={loginOpen}
onClose={() => setLoginOpen(false)}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Route component
// ---------------------------------------------------------------------------
/** Kinds accepted as follow packs/sets at /follow URLs. */
const FOLLOW_PACK_SET_KINDS = new Set([30000, 39089]);
function isNip05Identifier(value: string): boolean {
if (value.includes('@')) return true;
return value.includes('.') && !value.startsWith('npub1') && !value.startsWith('nprofile1');
}
export function FollowPage() {
const { npub } = useParams<{ npub: string }>();
const isNip05 = !!npub && isNip05Identifier(npub);
const { data: nip05Pubkey, isPending: nip05Loading } = useNip05Resolve(isNip05 ? npub : undefined);
if (!npub) return <NotFound />;
if (isNip05) {
if (nip05Loading) {
return (
<div className="min-h-screen flex items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
);
}
return nip05Pubkey ? <FollowView pubkey={nip05Pubkey} /> : <NotFound />;
}
// Try decoding as a NIP-19 identifier
let decoded;
try {
decoded = nip19.decode(npub);
} catch {
return <NotFound />;
}
// Handle npub / nprofile -> individual user follow view
if (decoded.type === 'npub') {
return <FollowView pubkey={decoded.data} />;
}
if (decoded.type === 'nprofile') {
return <FollowView pubkey={decoded.data.pubkey} />;
}
// Handle naddr -> follow pack/set view
if (decoded.type === 'naddr') {
const addr = decoded.data as AddressPointer;
if (!FOLLOW_PACK_SET_KINDS.has(addr.kind)) {
return <NotFound />;
}
return (
<FollowPackView
addr={{ kind: addr.kind, pubkey: addr.pubkey, identifier: addr.identifier }}
relays={addr.relays}
/>
);
}
return <NotFound />;
}
-75
View File
@@ -1,75 +0,0 @@
import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Feed } from '@/components/Feed';
import { KindInfoButton } from '@/components/KindInfoButton';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { EXTRA_KINDS, type ExtraKindDef } from '@/lib/extraKinds';
interface KindFeedPageProps {
kind: number | number[];
title: string;
icon?: React.ReactNode;
emptyMessage?: string;
/** Override the auto-detected ExtraKindDef (useful for pages with sub-kinds like Treasures). */
kindDef?: ExtraKindDef;
/** Override the back button destination (defaults to "/"). */
backTo?: string;
/** Always show the back button, even on desktop (default: only mobile). */
alwaysShowBack?: boolean;
/** If set, the FAB navigates to this URL instead of opening a compose dialog. */
fabHref?: string;
/** Additional tag filters to apply (e.g. `{ '#m': ['application/x-webxdc'] }`). */
tagFilters?: Record<string, string[]>;
/** Unique feed ID for tab persistence. Defaults to lowercase title. */
feedId?: string;
/** Extra content rendered after the feed header (e.g. a custom compose dialog). */
extra?: React.ReactNode;
/** If set, overrides the default FAB click behavior. */
onFabClick?: () => void;
/** Whether to show the FAB (default: true). */
showFAB?: boolean;
}
export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo = '/', alwaysShowBack, fabHref, tagFilters, extra, onFabClick, showFAB = true, feedId }: KindFeedPageProps) {
const { config } = useAppContext();
const { user } = useCurrentUser();
const primaryKind = Array.isArray(kind) ? kind[0] : kind;
const resolvedDef = useMemo(
() => kindDef ?? EXTRA_KINDS.find((def) => def.kind === primaryKind),
[kindDef, primaryKind],
);
const [infoOpen, setInfoOpen] = useState(false);
useSeoMeta({
title: `${title} | ${config.appName}`,
description: `${title} on Nostr`,
});
const fabClick = onFabClick ?? (!fabHref && resolvedDef ? () => setInfoOpen(true) : undefined);
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick, hasSubHeader: !!user });
const kinds = Array.isArray(kind) ? kind : [kind];
return (
<>
<Feed
kinds={kinds}
tagFilters={tagFilters}
hideCompose
feedId={feedId ?? title.toLowerCase()}
emptyMessage={emptyMessage ?? `No ${title.toLowerCase()} yet. Check back soon!`}
header={
<PageHeader title={title} icon={icon} backTo={backTo} alwaysShowBack={alwaysShowBack}>
{resolvedDef && <KindInfoButton kindDef={resolvedDef} icon={icon} open={infoOpen} onOpenChange={setInfoOpen} />}
</PageHeader>
}
/>
{extra}
</>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { ComposeLetterSheet } from '@/components/letter/ComposeLetterSheet';
export function LetterComposePage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const toPubkey = searchParams.get('to') ?? undefined;
useLayoutOptions({ showFAB: false, noOverscroll: true, hasSubHeader: true });
useSeoMeta({ title: 'Write a Letter' });
return (
<main className="relative h-screen overflow-hidden" style={{ touchAction: 'none' }}>
<ComposeLetterSheet
toPubkey={toPubkey}
onClose={() => navigate('/letters')}
/>
</main>
);
}
-15
View File
@@ -1,15 +0,0 @@
import { useSeoMeta } from '@unhead/react';
import { LetterPreferencesSection } from '@/components/letter/LetterPreferencesSection';
export function LetterPreferencesPage() {
useSeoMeta({
title: 'Letter Preferences',
description: 'Customize your default letter stationery, font, and inbox settings',
});
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<LetterPreferencesSection />
</main>
);
}
-200
View File
@@ -1,200 +0,0 @@
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate } from 'react-router-dom';
import { Settings, Loader2 } from 'lucide-react';
import { MailboxIcon } from '@/components/icons/MailboxIcon';
import { InkPenIcon } from '@/components/icons/InkPenIcon';
import { Button } from '@/components/ui/button';
import { FabButton } from '@/components/FabButton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useInbox, useSentLetters } from '@/hooks/useLetters';
import { useLetterPreferences } from '@/hooks/useLetterPreferences';
import { useFollowList } from '@/hooks/useFollowActions';
import { useMutedAuthorFilter } from '@/hooks/useMutedAuthorFilter';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { LoginArea } from '@/components/auth/LoginArea';
import { PageHeader } from '@/components/PageHeader';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { EnvelopeCard } from '@/components/letter/EnvelopeCard';
import { LetterDetailSheet } from '@/components/letter/LetterDetailSheet';
import type { Letter } from '@/lib/letterTypes';
type Tab = 'inbox' | 'sent';
/** Skeleton envelope matching the grid tile shape. */
function EnvelopeSkeleton({ index }: { index: number }) {
return (
<div
className="envelope-skeleton flex flex-col items-center gap-2"
style={{ animationDelay: `${index * 150}ms` }}
>
<div className="w-full rounded-lg bg-muted/60" style={{ aspectRatio: '4 / 3' }} />
<div className="flex flex-col items-center gap-1 w-full">
<div className="h-3 w-14 rounded-full bg-muted/50" />
<div className="h-2 w-10 rounded-full bg-muted/30" />
</div>
</div>
);
}
export function LettersPage() {
const { user } = useCurrentUser();
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>('inbox');
const [selectedLetter, setSelectedLetter] = useState<Letter | null>(null);
useLayoutOptions({ showFAB: false, hasSubHeader: !!user });
const { prefs } = useLetterPreferences();
const followListData = useFollowList();
const followedPubkeys = followListData.data?.pubkeys;
const { excludeMuted } = useMutedAuthorFilter();
// If friendsOnlyInbox is enabled, only show letters from followed users
// (minus any muted pubkeys — viewer's mute list always wins).
const inboxFilter = prefs.friendsOnlyInbox && followedPubkeys
? excludeMuted(followedPubkeys)
: undefined;
const inboxQuery = useInbox(inboxFilter);
const sentQuery = useSentLetters();
const inbox = inboxQuery.data;
const inboxLoading = inboxQuery.isLoading;
const sent = sentQuery.data;
const sentLoading = sentQuery.isLoading;
const activeQuery = tab === 'inbox' ? inboxQuery : sentQuery;
useSeoMeta({ title: 'Letters', description: 'Your private encrypted letters' });
if (!user) {
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<PageHeader title="Letters" icon={<MailboxIcon className="size-5" />} backTo="/" />
<div className="flex flex-col items-center justify-center py-24 gap-6 px-6 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center">
<MailboxIcon className="w-10 h-10 text-primary" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold">Your personal inbox</h2>
<p className="text-muted-foreground text-sm max-w-xs">
Send and receive beautiful encrypted letters with stationery, frames, and stickers.
</p>
</div>
<LoginArea />
</div>
</main>
);
}
const activeLetters = tab === 'inbox' ? inbox : sent;
const isLoading = tab === 'inbox' ? inboxLoading : sentLoading;
return (
<main className="relative min-h-screen pb-16 sidebar:pb-0">
<PageHeader title="Letters" icon={<MailboxIcon className="size-5" />} backTo="/" alwaysShowBack>
<button
onClick={() => navigate('/settings/letters')}
className="p-2 rounded-full text-muted-foreground hover:text-foreground transition-colors"
title="Letter preferences"
>
<Settings className="w-4 h-4" />
</button>
</PageHeader>
{/* Tabs */}
<SubHeaderBar>
<TabButton label="Inbox" active={tab === 'inbox'} onClick={() => setTab('inbox')} />
<TabButton label="Sent" active={tab === 'sent'} onClick={() => setTab('sent')} />
</SubHeaderBar>
{/* Envelope grid */}
<div className="px-4 py-3">
{isLoading && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sidebar:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<EnvelopeSkeleton key={i} index={i} />
))}
</div>
)}
{!isLoading && activeLetters && activeLetters.length === 0 && (
<div className="py-16 text-center space-y-3">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto">
<MailboxIcon className="w-8 h-8 text-muted-foreground opacity-50" />
</div>
<p className="text-muted-foreground text-sm">
{tab === 'inbox'
? prefs.friendsOnlyInbox
? 'no letters from friends yet'
: 'no letters yet'
: 'no sent letters yet'
}
</p>
{tab === 'inbox' && (
<p className="text-xs text-muted-foreground opacity-70">
ask a friend to send you a letter
</p>
)}
</div>
)}
{!isLoading && activeLetters && activeLetters.length > 0 && (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sidebar:grid-cols-4">
{activeLetters.map((letter, i) => (
<EnvelopeCard
key={letter.event.id}
letter={letter}
mode={tab}
index={i}
onClick={() => setSelectedLetter(letter)}
/>
))}
</div>
{activeQuery.hasNextPage && (
<div className="flex justify-center pt-6 pb-2">
<Button
variant="ghost"
size="sm"
onClick={() => activeQuery.fetchNextPage()}
disabled={activeQuery.isFetchingNextPage}
className="gap-2"
>
{activeQuery.isFetchingNextPage && <Loader2 className="size-4 animate-spin" />}
Load more
</Button>
</div>
)}
</>
)}
</div>
{/* Letter detail drawer */}
<LetterDetailSheet
letter={selectedLetter}
onClose={() => setSelectedLetter(null)}
onReply={(npub) => {
setSelectedLetter(null);
navigate(`/letters/compose?to=${npub}`);
}}
/>
{/* Compose FAB */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton onClick={() => navigate('/letters/compose')} icon={<InkPenIcon style={{ width: 18, height: 18 }} strokeWidth={2.5} />} title="Write a letter" />
</div>
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FabButton onClick={() => navigate('/letters/compose')} icon={<InkPenIcon style={{ width: 18, height: 18 }} strokeWidth={2.5} />} title="Write a letter" />
</div>
</div>
</div>
</main>
);
}
-42
View File
@@ -1,42 +0,0 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowUpRight } from 'lucide-react';
import { WhiteNoiseIcon } from '@/components/icons/WhiteNoiseIcon';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { openUrl } from '@/lib/downloadFile';
const WHITENOISE_URL = 'https://www.whitenoise.chat/';
const Messages = () => {
useSeoMeta({
title: 'Messages',
description: 'Private messaging on Nostr',
});
return (
<div className="flex-1 flex items-center justify-center p-6">
<Card className="max-w-md w-full border-dashed">
<CardContent className="py-10 px-8 text-center space-y-6">
<WhiteNoiseIcon className="mx-auto h-14 w-auto text-foreground" />
<div className="space-y-2">
<h2 className="text-xl font-semibold">Private messaging lives elsewhere</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
Agora doesn&apos;t handle direct messages. For end-to-end encrypted Nostr chat with strong metadata protection, we recommend White Noise.
</p>
</div>
<Button
type="button"
size="lg"
className="w-full"
onClick={() => openUrl(WHITENOISE_URL)}
>
Install White Noise
<ArrowUpRight className="ml-2 w-4 h-4" />
</Button>
</CardContent>
</Card>
</div>
);
};
export default Messages;
-18
View File
@@ -1,18 +0,0 @@
import { getExtraKindDef } from '@/lib/extraKinds';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import { KindFeedPage } from './KindFeedPage';
const musicDef = getExtraKindDef('music')!;
export function MusicFeedPage() {
return (
<KindFeedPage
kind={[36787, 34139]}
title="Music"
icon={sidebarItemIcon('music', 'size-5')}
kindDef={musicDef}
emptyMessage="No music yet. Check back soon!"
showFAB={false}
/>
);
}
-77
View File
@@ -1,77 +0,0 @@
import { useState, useCallback } from 'react';
import { Music } from 'lucide-react';
import { useSeoMeta } from '@unhead/react';
import { KindInfoButton } from '@/components/KindInfoButton';
import { PageHeader } from '@/components/PageHeader';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getExtraKindDef } from '@/lib/extraKinds';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import { MusicDiscoverTab } from '@/components/music/MusicDiscoverTab';
import { MusicTracksTab } from '@/components/music/MusicTracksTab';
import { MusicPlaylistsTab } from '@/components/music/MusicPlaylistsTab';
import { MusicArtistsTab } from '@/components/music/MusicArtistsTab';
const musicDef = getExtraKindDef('music')!;
type MusicTab = 'discover' | 'tracks' | 'playlists' | 'artists';
/**
* Dedicated music discovery page.
*
* Replaces the generic KindFeedPage with a tabbed layout:
* - **Discover** (default): Curated showcase with hero, featured, genres, etc.
* - **Tracks**: Infinite-scroll list of all music tracks with genre filter
* - **Playlists**: Grid of music playlists
* - **Artists**: Grid of artist profile cards
*
* All content is global by default. The Discover tab surfaces curated
* content from the curator's kind 30000 `d:music-artists` follow set.
*/
export function MusicPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const [activeTab, setActiveTab] = useState<MusicTab>('discover');
useSeoMeta({
title: `Music | ${config.appName}`,
description: 'Discover music on Nostr',
});
useLayoutOptions({ showFAB: false, hasSubHeader: !!user });
const switchToTracks = useCallback(() => setActiveTab('tracks'), []);
const switchToPlaylists = useCallback(() => setActiveTab('playlists'), []);
const switchToArtists = useCallback(() => setActiveTab('artists'), []);
return (
<main className="flex-1 min-w-0">
<PageHeader title="Music" icon={sidebarItemIcon('music', 'size-5')}>
<KindInfoButton kindDef={musicDef} icon={<Music className="size-5" />} />
</PageHeader>
{/* Tabs */}
<SubHeaderBar>
<TabButton label="Discover" active={activeTab === 'discover'} onClick={() => setActiveTab('discover')} />
<TabButton label="Tracks" active={activeTab === 'tracks'} onClick={() => setActiveTab('tracks')} />
<TabButton label="Playlists" active={activeTab === 'playlists'} onClick={() => setActiveTab('playlists')} />
<TabButton label="Artists" active={activeTab === 'artists'} onClick={() => setActiveTab('artists')} />
</SubHeaderBar>
{/* Tab content */}
{activeTab === 'discover' && (
<MusicDiscoverTab
onSwitchToTracks={switchToTracks}
onSwitchToPlaylists={switchToPlaylists}
onSwitchToArtists={switchToArtists}
/>
)}
{activeTab === 'tracks' && <MusicTracksTab />}
{activeTab === 'playlists' && <MusicPlaylistsTab />}
{activeTab === 'artists' && <MusicArtistsTab />}
</main>
);
}
-179
View File
@@ -1,179 +0,0 @@
/**
* PhotosFeedPage — Instagram-style grid feed for NIP-68 photo events (kind 20).
*
* - Follows tab: useFeed (relay pool, chronological)
* - Global tab: useInfiniteHotFeed (sort:hot via relay.ditto.pub)
* - Infinite-scroll justified collage via the shared MediaCollage component
*/
import type { NostrEvent } from "@nostrify/nostrify";
import { useSeoMeta } from "@unhead/react";
import { Camera } from "lucide-react";
import { lazy, Suspense, useMemo, useCallback, useState } from "react";
import { FeedEmptyState } from "@/components/FeedEmptyState";
import { KindInfoButton } from "@/components/KindInfoButton";
import { eventToMediaItem } from "@/lib/mediaUtils";
import {
MediaCollage,
MediaCollageSkeleton,
} from "@/components/MediaCollage";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { SubHeaderBar } from "@/components/SubHeaderBar";
import { TabButton } from "@/components/TabButton";
import { useLayoutOptions } from "@/contexts/LayoutContext";
import { useAppContext } from "@/hooks/useAppContext";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useFeed } from "@/hooks/useFeed";
import { useFeedTab } from "@/hooks/useFeedTab";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
import { useMuteList } from "@/hooks/useMuteList";
import { usePageRefresh } from "@/hooks/usePageRefresh";
import { useInfiniteHotFeed } from "@/hooks/useTrending";
import { getExtraKindDef } from "@/lib/extraKinds";
import type { FeedItem } from "@/lib/feedUtils";
import { isEventMuted } from "@/lib/muteHelpers";
import { sidebarItemIcon } from "@/lib/sidebarItems";
const PhotoComposeModal = lazy(() => import('@/components/PhotoComposeModal').then(m => ({ default: m.PhotoComposeModal })));
const PHOTO_KIND = 20;
const photosDef = getExtraKindDef("photos")!;
type FeedTab = "follows" | "global";
// ── Page ──────────────────────────────────────────────────────────────────────
export function PhotosFeedPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { muteItems } = useMuteList();
const [composeOpen, setComposeOpen] = useState(false);
const [activeTab, setActiveTab] = useFeedTab<FeedTab>("photos", [
"follows",
"global",
]);
const handleFabClick = useCallback(() => {
setComposeOpen(true);
}, []);
useSeoMeta({
title: `Photos | ${config.appName}`,
description: "Photo posts on Nostr",
});
useLayoutOptions({ showFAB: true, onFabClick: handleFabClick, fabIcon: <Camera className="size-4" strokeWidth={2.5} />, hasSubHeader: true });
// ── Follows feed (chronological) ──
const followsQuery = useFeed("follows", { kinds: [PHOTO_KIND] });
// ── Global feed (sort:hot) ──
const globalQuery = useInfiniteHotFeed([PHOTO_KIND], activeTab === "global");
const activeQuery = activeTab === "follows" ? followsQuery : globalQuery;
const {
data: rawData,
isPending,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = activeQuery;
const { scrollRef } = useInfiniteScroll({
hasNextPage,
isFetchingNextPage,
fetchNextPage,
pageCount: rawData?.pages?.length,
});
const handleRefresh = usePageRefresh(['feed']);
// Flatten — follows returns { items: FeedItem[] }, global returns NostrEvent[]
const photoEvents = useMemo(() => {
if (!rawData?.pages) return [];
const seen = new Set<string>();
const events: NostrEvent[] =
activeTab === "follows"
? (rawData.pages as unknown as { items: FeedItem[] }[])
.flatMap((p) => p.items)
.map((item) => item.event)
: (rawData.pages as unknown as NostrEvent[][]).flat();
return events.filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
if (event.kind !== PHOTO_KIND) return false;
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
return eventToMediaItem(event) !== null;
});
}, [rawData?.pages, muteItems, activeTab]);
const showSkeleton = isPending || (isLoading && !rawData);
return (
<>
<main className="">
<PageHeader title="Photos" icon={<Camera className="size-5" />}>
<KindInfoButton
kindDef={photosDef}
icon={sidebarItemIcon("photos", "size-5")}
/>
</PageHeader>
{/* Tabs */}
<SubHeaderBar>
<TabButton
label="Follows"
active={activeTab === "follows"}
onClick={() => setActiveTab("follows")}
disabled={!user}
/>
<TabButton
label="Global"
active={activeTab === "global"}
onClick={() => setActiveTab("global")}
/>
</SubHeaderBar>
{/* Grid */}
<PullToRefresh onRefresh={handleRefresh}>
{showSkeleton ? (
<MediaCollageSkeleton count={15} />
) : photoEvents.length === 0 ? (
<FeedEmptyState
message={
activeTab === "follows"
? "No photos yet. Follow some photographers to see their photos here."
: "No photos found. Check your relay connections or come back soon."
}
onSwitchToGlobal={
activeTab === "follows" ? () => setActiveTab("global") : undefined
}
/>
) : (
<>
<MediaCollage
events={photoEvents}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onNearEnd={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
/>
<div ref={scrollRef} className="h-px" />
</>
)}
</PullToRefresh>
</main>
{composeOpen && (
<Suspense fallback={null}>
<PhotoComposeModal open={composeOpen} onOpenChange={setComposeOpen} />
</Suspense>
)}
</>
);
}
-27
View File
@@ -1,27 +0,0 @@
import { useSeoMeta } from '@unhead/react';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
interface PlaceholderPageProps {
title: string;
icon?: React.ReactNode;
description?: string;
}
export function PlaceholderPage({ title, icon, description }: PlaceholderPageProps) {
const { config } = useAppContext();
useSeoMeta({
title: `${title} | ${config.appName}`,
description: description || `${title} page`,
});
return (
<main className="">
<PageHeader title={title} icon={icon} backTo="/" />
<div className="py-20 text-center">
<p className="text-muted-foreground text-lg">Coming soon</p>
</div>
</main>
);
}
-18
View File
@@ -1,18 +0,0 @@
import { getExtraKindDef } from '@/lib/extraKinds';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import { KindFeedPage } from './KindFeedPage';
const podcastsDef = getExtraKindDef('podcasts')!;
export function PodcastsFeedPage() {
return (
<KindFeedPage
kind={[30054, 30055]}
title="Podcasts"
icon={sidebarItemIcon('podcasts', 'size-5')}
kindDef={podcastsDef}
emptyMessage="No podcasts yet. Check back soon!"
showFAB={false}
/>
);
}
-180
View File
@@ -1,180 +0,0 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { HandHeart, KeyRound, Sparkles, Wallet, Zap } from 'lucide-react';
import { AgoraLogo } from '@/components/AgoraLogo';
import { LoginArea } from '@/components/auth/LoginArea';
import AuthDialog from '@/components/auth/AuthDialog';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
/**
* Landing page reached from invite links like `https://agora.spot/receive`.
*
* Target audience: someone who has been told "I'm starting a fundraiser for
* you on Agora" but doesn't have a Nostr account yet. The page's job is to
* get them past account creation as fast as possible.
*
* Logged-out: hero + signup CTA (which opens the onboarding flow). A small
* "already have a Nostr account?" footer routes them to the standard login.
*
* Already logged in: confirms they're set up and points them at their
* profile + a link to claim any pending campaigns.
*/
export function ReceivePage() {
const { user } = useCurrentUser();
const { config } = useAppContext();
const [authDialogOpen, setAuthDialogOpen] = useState(false);
const navigate = useNavigate();
useSeoMeta({
title: `Receive donations on ${config.appName}`,
description:
'Create a free account and start receiving Bitcoin donations directly to your wallet. No middleman, no chargebacks.',
});
// Once the user has finished signing up / logging in, drop them at /claim
// so they can see any campaigns that were set up for them in advance.
useEffect(() => {
if (user) {
navigate('/claim', { replace: true });
}
}, [user, navigate]);
return (
<main className="min-h-dvh bg-gradient-to-br from-primary/10 via-background to-secondary/40">
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 lg:py-16">
<header className="flex items-center justify-between mb-10">
<Link to="/" className="flex items-center gap-2">
<AgoraLogo size={36} />
<span className="font-bold text-lg">{config.appName}</span>
</Link>
<LoginArea className="max-w-[220px]" />
</header>
<section className="space-y-6 text-center sm:text-left">
<div className="inline-flex items-center gap-2 rounded-full bg-background/70 backdrop-blur px-3 py-1 border border-border text-xs font-medium">
<Sparkles className="size-3.5 text-primary" />
Someone wants to fundraise for you
</div>
<h1 className="text-3xl sm:text-5xl font-bold tracking-tight leading-[1.1]">
Get paid in Bitcoin,{' '}
<span className="text-primary">straight to your wallet.</span>
</h1>
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl">
Agora is a permissionless fundraising platform built on Nostr and Bitcoin. Create a
free account in under a minute, and donations land directly in your wallet no
middleman, no chargebacks, no platform freezing your funds.
</p>
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button size="lg" onClick={() => setAuthDialogOpen(true)} className="rounded-full">
<KeyRound className="size-4 mr-2" />
Create my account
</Button>
<Button
size="lg"
variant="outline"
asChild
className="rounded-full"
>
<Link to="/claim">I already have a Nostr account</Link>
</Button>
</div>
</section>
<section className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-4">
<FeatureCard
icon={<KeyRound className="size-5 text-primary" />}
title="Own your account"
description="Your Nostr key is yours forever. No company can ban or freeze you."
/>
<FeatureCard
icon={<Wallet className="size-5 text-primary" />}
title="Direct to your wallet"
description="Donations settle on-chain to a Bitcoin address derived from your key."
/>
<FeatureCard
icon={<Zap className="size-5 text-primary" />}
title="Counted on Agora"
description="Each donation publishes a receipt so it shows up on your campaign's progress."
/>
</section>
<section className="mt-10">
<Card>
<CardContent className="py-6 px-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<HandHeart className="size-5 text-primary" />
How it works
</h2>
<ol className="space-y-2.5 text-sm text-muted-foreground list-none">
<Step n={1}>
Create your free Nostr account (your key is generated locally and never leaves
your device).
</Step>
<Step n={2}>
Fill in a name, photo, and short bio so donors recognize you.
</Step>
<Step n={3}>
Visit <code className="font-mono text-xs">/claim</code> to find the campaign
that was started for you and start receiving donations.
</Step>
</ol>
</CardContent>
</Card>
</section>
<footer className="mt-10 text-center text-xs text-muted-foreground">
Already have a Nostr key (npub or nsec)?{' '}
<Link to="/claim" className="text-primary hover:underline">
Sign in to claim your campaign
</Link>
</footer>
</div>
<AuthDialog
isOpen={authDialogOpen}
onClose={() => setAuthDialogOpen(false)}
/>
</main>
);
}
function FeatureCard({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<Card>
<CardContent className="py-5 px-5 space-y-2">
<div className="size-9 rounded-full bg-primary/10 flex items-center justify-center">
{icon}
</div>
<h3 className="font-semibold text-sm">{title}</h3>
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
</CardContent>
</Card>
);
}
function Step({ n, children }: { n: number; children: React.ReactNode }) {
return (
<li className="flex gap-3">
<span className="shrink-0 size-6 rounded-full bg-primary/15 text-primary text-xs font-semibold flex items-center justify-center">
{n}
</span>
<span className="pt-0.5 text-foreground/90">{children}</span>
</li>
);
}
export default ReceivePage;
-291
View File
@@ -1,291 +0,0 @@
import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Globe, Info, Mail, Shield, Zap, Server, Hash } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { NoteCard } from '@/components/NoteCard';
import { FeedCard } from '@/components/FeedCard';
import { PageHeader } from '@/components/PageHeader';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useMuteList } from '@/hooks/useMuteList';
import { useRelayInfo, type RelayInfoDocument } from '@/hooks/useRelayInfo';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { isRepostKind } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
import type { NostrEvent } from '@nostrify/nostrify';
import NotFound from './NotFound';
/** Fetch the latest events from a specific relay, filtered to supported kinds. */
function useRelayFeed(relayUrl: string | undefined, kinds: number[]) {
const { nostr } = useNostr();
const kindsKey = [...kinds].sort().join(',');
return useQuery<NostrEvent[]>({
queryKey: ['relay-feed', relayUrl, kindsKey],
queryFn: async ({ signal }) => {
if (!relayUrl) return [];
const relay = nostr.relay(relayUrl);
return relay.query(
[{ kinds, limit: 15 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
);
},
enabled: !!relayUrl && kinds.length > 0,
});
}
export function RelayPage() {
const { config } = useAppContext();
const { '*': rawParam } = useParams();
const { feedSettings } = useFeedSettings();
const { muteItems } = useMuteList();
const [infoOpen, setInfoOpen] = useState(false);
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
// Support both encoded URLs (/r/wss%3A%2F%2F...) and bare URLs (/r/wss://...).
const relayUrl = useMemo(() => {
if (!rawParam) return undefined;
// If the wildcard param has no "://", it's encoded — decode it.
let decoded: string;
try { decoded = decodeURIComponent(rawParam); } catch { decoded = rawParam; }
const url = rawParam.includes('://') ? rawParam : decoded;
if (url.startsWith('wss://') || url.startsWith('ws://')) {
return url;
}
return `wss://${url}`;
}, [rawParam]);
// Derive a display hostname from the URL
const hostname = useMemo(() => {
if (!relayUrl) return '';
try {
return relayUrl.replace(/^wss?:\/\//, '').replace(/\/$/, '');
} catch {
return relayUrl;
}
}, [relayUrl]);
const { data: info, isLoading: infoLoading, isError: infoError } = useRelayInfo(relayUrl);
const { data: events, isLoading: eventsLoading } = useRelayFeed(relayUrl, kinds);
const filteredEvents = useMemo(() => {
if (!events || muteItems.length === 0) return events;
return events.filter((e) => !isEventMuted(e, muteItems));
}, [events, muteItems]);
useSeoMeta({
title: info?.name
? `${info.name} | ${config.appName}`
: hostname
? `${hostname} | ${config.appName}`
: `Relay | ${config.appName}`,
description: info?.description ?? `Events from ${hostname}`,
});
useLayoutOptions({ hasSubHeader: true });
if (!rawParam) {
return <NotFound />;
}
return (
<main>
<PageHeader title={hostname} icon={<Server className="size-5" />} className="py-2 sidebar:py-4">
<button
onClick={() => setInfoOpen((o) => !o)}
className={`p-2 rounded-full transition-colors ${infoOpen ? 'text-foreground bg-secondary' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}`}
aria-label="Toggle relay info"
>
<Info className="size-4" />
</button>
</PageHeader>
<RelayInfoPanel info={info} infoLoading={infoLoading} infoError={infoError} open={infoOpen} />
<SubHeaderBar>{null}</SubHeaderBar>
{/* Feed section */}
<div>
{eventsLoading ? (
<FeedCard className="mt-2 divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</FeedCard>
) : filteredEvents && filteredEvents.length > 0 ? (
<FeedCard className="mt-2">
{filteredEvents.map((event) => <NoteCard key={event.id} event={event} />)}
</FeedCard>
) : (
<div className="py-16 text-center text-muted-foreground">
No events found on this relay.
</div>
)}
</div>
</main>
);
}
/** Inline expandable panel that displays NIP-11 relay information. */
function RelayInfoPanel({ info, infoLoading, infoError, open }: {
info: RelayInfoDocument | undefined;
infoLoading: boolean;
infoError: boolean;
open: boolean;
}) {
return (
<div
style={{
overflow: 'hidden',
maxHeight: open ? '800px' : '0',
transition: 'max-height 0.3s ease-in-out',
}}
aria-hidden={!open}
>
<div>
{infoLoading ? (
<div className="p-4 space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-1/2" />
</div>
<div className="flex gap-2">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-16 rounded-full" />
</div>
</div>
) : info ? (
<div className="p-4 space-y-4">
{/* Banner */}
{info.banner && (
<div className="w-full h-40 overflow-hidden rounded-lg">
<img src={info.banner} alt="" className="w-full h-full object-cover" />
</div>
)}
{/* Icon + name (when different from hostname) */}
{info.icon && (
<div className="flex items-center gap-2.5">
<img src={info.icon} alt="" className="size-8 rounded-full object-cover ring-1 ring-border" />
{info.name && (
<span className="text-sm font-medium">{info.name}</span>
)}
</div>
)}
{/* Description */}
{info.description && (
<p className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{info.description}
</p>
)}
{/* Badges: payment, auth, writes */}
<div className="flex flex-wrap gap-2">
{info.limitation?.payment_required && (
<Badge variant="secondary" className="gap-1 text-xs">
<Zap className="size-3" />
Paid
</Badge>
)}
{info.limitation?.auth_required && (
<Badge variant="secondary" className="gap-1 text-xs">
<Shield className="size-3" />
Auth required
</Badge>
)}
{info.limitation?.restricted_writes && (
<Badge variant="secondary" className="gap-1 text-xs">
<Shield className="size-3" />
Restricted writes
</Badge>
)}
{info.software && (
<Badge variant="outline" className="gap-1 text-xs">
<Server className="size-3" />
{info.software.replace(/^https?:\/\//, '')}
{info.version ? ` ${info.version}` : ''}
</Badge>
)}
{info.contact && (
<Badge variant="outline" className="gap-1 text-xs">
<Mail className="size-3" />
{info.contact}
</Badge>
)}
</div>
{/* Supported NIPs */}
{info.supported_nips && info.supported_nips.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Hash className="size-3" />
Supported NIPs
</div>
<div className="flex flex-wrap gap-1.5">
{info.supported_nips.sort((a, b) => a - b).map((nip) => (
<a
key={nip}
href={`https://github.com/nostr-protocol/nips/blob/master/${String(nip).padStart(2, '0')}.md`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors"
>
{nip}
</a>
))}
</div>
</div>
)}
{/* Fees */}
{info.fees && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Zap className="size-3" />
Fees
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
{info.fees.admission?.map((fee, i) => (
<span key={`admission-${i}`} className="bg-muted rounded-md px-2 py-0.5">
Admission: {fee.amount / 1000} sats
</span>
))}
{info.fees.subscription?.map((fee, i) => (
<span key={`sub-${i}`} className="bg-muted rounded-md px-2 py-0.5">
Subscription: {fee.amount / 1000} sats{fee.period ? ` / ${Math.round(fee.period / 86400)}d` : ''}
</span>
))}
</div>
</div>
)}
</div>
) : infoError ? (
<div className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="size-4" />
<span>Could not load relay information.</span>
</div>
</div>
) : null}
</div>
</div>
);
}
-230
View File
@@ -1,230 +0,0 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Radio, Users, Clock } from 'lucide-react';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAppContext } from '@/hooks/useAppContext';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';
import { KindInfoButton } from '@/components/KindInfoButton';
import { PageHeader } from '@/components/PageHeader';
import { useAuthor } from '@/hooks/useAuthor';
import { useStreamKind } from '@/hooks/useStreamKind';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { getDisplayName } from '@/lib/getDisplayName';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useOpenPost } from '@/hooks/useOpenPost';
import { getExtraKindDef } from '@/lib/extraKinds';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
const streamsDef = getExtraKindDef('streams')!;
/** Extract the first value of a tag by name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Status badge config. */
function getStatusConfig(status: string | undefined) {
switch (status) {
case 'live':
return { label: 'LIVE', className: 'bg-red-600 hover:bg-red-600 text-white border-red-600' };
case 'ended':
return { label: 'ENDED', className: 'bg-muted text-muted-foreground border-border' };
case 'planned':
return { label: 'PLANNED', className: 'bg-blue-600/90 hover:bg-blue-600/90 text-white border-blue-600' };
default:
return { label: status?.toUpperCase() || 'UNKNOWN', className: 'bg-muted text-muted-foreground border-border' };
}
}
export function StreamsFeedPage() {
const { config } = useAppContext();
useSeoMeta({
title: `Streams | ${config.appName}`,
description: 'Live streams on Nostr',
});
useLayoutOptions({ showFAB: true, fabKind: 30311 });
const { events, isLoading } = useStreamKind(30311);
// Sort: live first, then planned, then ended. Within each group, newest first.
const sorted = useMemo(() => {
const statusOrder: Record<string, number> = { live: 0, planned: 1, ended: 2 };
return [...events].sort((a, b) => {
const aStatus = getTag(a.tags, 'status') || 'ended';
const bStatus = getTag(b.tags, 'status') || 'ended';
const orderDiff = (statusOrder[aStatus] ?? 3) - (statusOrder[bStatus] ?? 3);
if (orderDiff !== 0) return orderDiff;
return b.created_at - a.created_at;
});
}, [events]);
return (
<main className="">
<PageHeader title="Streams" icon={<Radio className="size-5" />}>
<KindInfoButton kindDef={streamsDef} icon={sidebarItemIcon('streams', 'size-5')} />
</PageHeader>
{/* Feed */}
{isLoading && events.length === 0 ? (
<div className="space-y-3 px-4">
{Array.from({ length: 4 }).map((_, i) => (
<StreamCardSkeleton key={i} />
))}
</div>
) : sorted.length === 0 ? (
<div className="px-4">
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-2">
<Radio className="size-8 text-muted-foreground/40 mx-auto" />
<p className="text-muted-foreground">
No streams found. Check your relay connections or wait for new streams to start.
</p>
</div>
</CardContent>
</Card>
</div>
) : (
<div className="space-y-3 px-4 pb-8">
{sorted.map((event) => (
<StreamCard key={event.id} event={event} />
))}
</div>
)}
</main>
);
}
function StreamCard({ event }: { event: NostrEvent }) {
const title = getTag(event.tags, 'title') || 'Untitled Stream';
const summary = getTag(event.tags, 'summary');
const imageUrl = getTag(event.tags, 'image');
const status = getTag(event.tags, 'status');
const currentParticipants = getTag(event.tags, 'current_participants');
const statusConfig = getStatusConfig(status);
const naddrId = useMemo(() => {
const dTag = getTag(event.tags, 'd') || '';
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
}, [event]);
const { onClick, onAuxClick } = useOpenPost(`/${naddrId}`);
return (
<Card
className="overflow-hidden cursor-pointer hover:bg-secondary/30 transition-colors"
onClick={onClick}
onAuxClick={onAuxClick}
>
{/* Thumbnail */}
{imageUrl && (
<div className="relative w-full aspect-video overflow-hidden bg-muted">
<img
src={imageUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
{/* Status badge overlay */}
<div className="absolute top-2 left-2">
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
{status === 'live' && <div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />}
{statusConfig.label}
</Badge>
</div>
{/* Viewer count overlay */}
{currentParticipants && (
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded">
<Users className="size-3" />
{currentParticipants}
</div>
)}
</div>
)}
<CardContent className="p-3 space-y-2">
{/* Author + meta */}
<div className="flex items-start gap-2.5">
<StreamCardAuthor pubkey={event.pubkey} />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm leading-snug line-clamp-2">{title}</h3>
{summary && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">{summary}</p>
)}
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
{!imageUrl && (
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
{status === 'live' && <div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />}
{statusConfig.label}
</Badge>
)}
{!imageUrl && currentParticipants && (
<span className="flex items-center gap-1">
<Users className="size-3" />
{currentParticipants}
</span>
)}
<span className="flex items-center gap-1">
<Clock className="size-3" />
{timeAgo(event.created_at)}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function StreamCardAuthor({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
if (author.isLoading) {
return <Skeleton className="size-9 rounded-full shrink-0" />;
}
return (
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar className="size-9">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
);
}
function StreamCardSkeleton() {
return (
<Card className="overflow-hidden">
<Skeleton className="w-full aspect-video rounded-none" />
<CardContent className="p-3">
<div className="flex items-start gap-2.5">
<Skeleton className="size-9 rounded-full shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
</CardContent>
</Card>
);
}
-26
View File
@@ -1,26 +0,0 @@
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { getExtraKindDef, getPageKinds } from '@/lib/extraKinds';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import { KindFeedPage } from './KindFeedPage';
/** Find the Treasures definition from EXTRA_KINDS. */
const treasuresDef = getExtraKindDef('treasures')!;
export function TreasuresPage() {
const { feedSettings } = useFeedSettings();
const kinds = getPageKinds(treasuresDef, feedSettings);
return (
<KindFeedPage
kind={kinds}
title={treasuresDef.label}
icon={sidebarItemIcon('treasures', 'size-5')}
kindDef={treasuresDef}
emptyMessage={
kinds.length === 0
? 'All treasure types are disabled. Enable treasures or found logs in Settings > Feed.'
: undefined
}
/>
);
}
-236
View File
@@ -1,236 +0,0 @@
import { useSeoMeta } from "@unhead/react";
import { Flame, Loader2, Swords, TrendingUp } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useInView } from "react-intersection-observer";
import { Link } from "react-router-dom";
import { NoteCard } from "@/components/NoteCard";
import { FeedCard } from "@/components/FeedCard";
import { PageHeader } from "@/components/PageHeader";
import { PullToRefresh } from "@/components/PullToRefresh";
import { Skeleton } from "@/components/ui/skeleton";
import { useAppContext } from "@/hooks/useAppContext";
import { useMuteList } from "@/hooks/useMuteList";
import { usePageRefresh } from "@/hooks/usePageRefresh";
import {
type SortMode,
useInfiniteSortedPosts,
useTrendingTags,
} from "@/hooks/useTrending";
import { isEventMuted } from "@/lib/muteHelpers";
import { cn } from "@/lib/utils";
export function TrendsPage() {
const { config } = useAppContext();
useSeoMeta({
title: `Trends | ${config.appName}`,
description: "Trending hashtags and posts on Nostr",
});
const [trendSort, setTrendSort] = useState<SortMode>("hot");
const refreshQueryKey = useMemo(
() => [['trending-tags'], ['infinite-sorted-posts', trendSort]],
[trendSort],
);
const handleRefresh = usePageRefresh(refreshQueryKey);
const { data: trends, isLoading: trendsLoading } = useTrendingTags(true);
const {
data: sortedData,
isPending: sortedPending,
isLoading: sortedLoading,
fetchNextPage: fetchNextSorted,
hasNextPage: hasNextSorted,
isFetchingNextPage: isFetchingNextSorted,
} = useInfiniteSortedPosts(trendSort, true);
const { muteItems } = useMuteList();
// Flatten, deduplicate, and filter muted posts from paginated sorted results
const sortedPosts = useMemo(() => {
const seen = new Set<string>();
return (
sortedData?.pages.flat().filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
if (muteItems.length > 0 && isEventMuted(event, muteItems))
return false;
return true;
}) ?? []
);
}, [sortedData?.pages, muteItems]);
// Intersection observer for infinite scroll on sorted posts
const { ref: sortedScrollRef, inView: sortedInView } = useInView({
threshold: 0,
rootMargin: "400px",
});
useEffect(() => {
if (sortedInView && hasNextSorted && !isFetchingNextSorted) {
fetchNextSorted();
}
}, [sortedInView, hasNextSorted, isFetchingNextSorted, fetchNextSorted]);
return (
<main className="">
<PageHeader title="Trends" icon={<TrendingUp className="size-5" />} />
<PullToRefresh onRefresh={handleRefresh}>
{/* Trending Hashtags */}
<div className="px-4 pt-4 pb-2">
<h3 className="text-lg font-bold text-foreground">Trending Hashtags</h3>
</div>
{trendsLoading ? (
<div className="flex flex-wrap gap-2 px-4 pb-4">
{Array.from({ length: 5 }).map((_, i) => (
<TrendSkeleton key={i} />
))}
</div>
) : trends && trends.tags.length > 0 ? (
<div className="flex flex-wrap gap-2 px-4 pb-4">
{trends.tags.slice(0, 5).map((trend, index) => (
<TrendItem
key={index}
trend={{ tag: trend.tag, count: trend.accounts }}
/>
))}
</div>
) : (
<EmptyState message="No trending hashtags right now." />
)}
{/* Sort sub-tabs */}
<div className="flex border-b border-border">
<SortTabButton
icon={<Flame className="size-4" />}
label="Hot"
active={trendSort === "hot"}
onClick={() => setTrendSort("hot")}
/>
<SortTabButton
icon={<TrendingUp className="size-4" />}
label="Rising"
active={trendSort === "rising"}
onClick={() => setTrendSort("rising")}
/>
<SortTabButton
icon={<Swords className="size-4" />}
label="Controversial"
active={trendSort === "controversial"}
onClick={() => setTrendSort("controversial")}
/>
</div>
{/* Sorted posts — infinite scroll */}
{(sortedPending || sortedLoading) && sortedPosts.length === 0 ? (
<FeedCard className="mt-2 divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<PostSkeleton key={i} />
))}
</FeedCard>
) : sortedPosts.length > 0 ? (
<>
<FeedCard className="mt-2">
{sortedPosts.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</FeedCard>
{hasNextSorted && (
<div ref={sortedScrollRef} className="py-4">
{isFetchingNextSorted && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</>
) : (
<EmptyState message={`No ${trendSort} posts right now.`} />
)}
</PullToRefresh>
</main>
);
}
function SortTabButton({
icon,
label,
active,
onClick,
}: {
icon: React.ReactNode;
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={cn(
"flex-1 py-2.5 flex items-center justify-center gap-1.5 text-sm font-medium transition-colors relative hover:bg-secondary/40",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{icon}
{label}
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-12 h-0.5 rounded-full bg-primary" />
)}
</button>
);
}
function TrendItem({ trend }: { trend: { tag: string; count: number } }) {
return (
<Link
to={`/t/${encodeURIComponent(trend.tag)}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-secondary/50 hover:bg-secondary transition-colors text-sm font-semibold text-foreground"
>
#{trend.tag}
{trend.count > 0 && (
<span className="text-xs text-muted-foreground font-normal">
{trend.count}
</span>
)}
</Link>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="py-16 px-8 text-center">
<p className="text-muted-foreground">{message}</p>
</div>
);
}
function PostSkeleton() {
return (
<div className="px-4 py-3">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
</div>
<div className="mt-2 space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex items-center gap-6 mt-3 -ml-2">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
</div>
</div>
);
}
function TrendSkeleton() {
// Pill-shaped skeleton matching `TrendItem`'s rendered shape so the
// page doesn't visually pop when results arrive.
return <Skeleton className="h-7 w-24 rounded-full" />;
}
-332
View File
@@ -1,332 +0,0 @@
/**
* UserListsPage
*
* Settings sub-page for managing NIP-51 Follow Sets (kind 30000).
*/
import { useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import {
Info, Plus, Trash2, Scroll, Users, Pencil,
Check, X,
} from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger,
} from '@/components/ui/dialog';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUserLists } from '@/hooks/useUserLists';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { toast } from '@/hooks/useToast';
import type { UserList } from '@/hooks/useUserLists';
// ─── Mini Avatar ──────────────────────────────────────────────────────────────
function MiniAvatar({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
return (
<Avatar className="size-7 border-2 border-background shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
}
// ─── List Row ─────────────────────────────────────────────────────────────────
function ListRow({ list, onDelete }: { list: UserList; onDelete: (list: UserList) => void }) {
const navigate = useNavigate();
const { user } = useCurrentUser();
const [editing, setEditing] = useState(false);
const [renameValue, setRenameValue] = useState(list.title);
const { renameList } = useUserLists();
const handleRename = () => {
if (!renameValue.trim() || renameValue.trim() === list.title) {
setEditing(false);
setRenameValue(list.title);
return;
}
renameList.mutate(
{ listId: list.id, title: renameValue },
{
onSuccess: () => { toast({ title: 'List renamed' }); setEditing(false); },
onError: () => { toast({ title: 'Failed to rename', variant: 'destructive' }); setEditing(false); },
},
);
};
const previewPubkeys = list.pubkeys.slice(0, 4);
return (
<>
<div
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/40 transition-colors cursor-pointer group"
onClick={() => {
if (editing || !user) return;
const addr = nip19.naddrEncode({ kind: 30000, pubkey: user.pubkey, identifier: list.id });
navigate(`/${addr}`);
}}
>
{/* Avatar stack */}
<div className="flex -space-x-1.5 shrink-0 w-16 overflow-hidden">
{previewPubkeys.length > 0 ? previewPubkeys.map((pk) => (
<MiniAvatar key={pk} pubkey={pk} />
)) : (
<div className="size-7 rounded-full bg-muted border-2 border-background flex items-center justify-center">
<Users className="size-3 text-muted-foreground" />
</div>
)}
</div>
{/* Label / rename input */}
<div className="flex-1 min-w-0" onClick={(e) => editing && e.stopPropagation()}>
{editing ? (
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename();
if (e.key === 'Escape') { setEditing(false); setRenameValue(list.title); }
}}
className="h-7 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
autoFocus
/>
<Button size="icon" variant="ghost" className="size-7 shrink-0" onClick={handleRename}>
<Check className="size-3.5" />
</Button>
<Button size="icon" variant="ghost" className="size-7 shrink-0" onClick={() => { setEditing(false); setRenameValue(list.title); }}>
<X className="size-3.5" />
</Button>
</div>
) : (
<div>
<span className="text-sm font-medium truncate block">{list.title}</span>
<span className="text-xs text-muted-foreground">
{list.pubkeys.length} {list.pubkeys.length === 1 ? 'person' : 'people'}
</span>
</div>
)}
</div>
{/* Action buttons — visible on hover */}
{!editing && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" onClick={(e) => e.stopPropagation()}>
<Button
size="icon"
variant="ghost"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => { setEditing(true); setRenameValue(list.title); }}
title="Rename"
>
<Pencil className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(list)}
title="Delete"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)}
</div>
</>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function UserListsPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { lists, isLoading, createList, deleteList } = useUserLists();
useSeoMeta({
title: `Lists | Settings | ${config.appName}`,
description: 'Manage your lists on Nostr.',
});
const [newListName, setNewListName] = useState('');
const [deleteTarget, setDeleteTarget] = useState<UserList | null>(null);
if (!user) return <Navigate to="/settings" replace />;
const handleCreate = () => {
if (!newListName.trim() || createList.isPending) return;
createList.mutate(
{ title: newListName.trim() },
{
onSuccess: () => {
toast({ title: `List "${newListName.trim()}" created` });
setNewListName('');
},
onError: () => {
toast({ title: 'Failed to create list', variant: 'destructive' });
},
},
);
};
const handleDeleteConfirm = () => {
if (!deleteTarget) return;
deleteList.mutate(
{ listId: deleteTarget.id },
{
onSuccess: () => {
toast({ title: `List "${deleteTarget.title}" deleted` });
setDeleteTarget(null);
},
onError: () => {
toast({ title: 'Failed to delete list', variant: 'destructive' });
setDeleteTarget(null);
},
},
);
};
return (
<main>
{/* Header */}
<PageHeader title="Lists" icon={<Scroll className="size-5" />}>
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8 rounded-full text-muted-foreground hover:text-foreground">
<Info className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-xs p-6">
<div className="flex flex-col items-center text-center gap-4">
<div className="text-primary [&>svg]:size-10">
<Scroll className="size-10" />
</div>
<DialogTitle className="text-lg">Lists</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
Organize people into lists. Lists are stored on Nostr so they follow you across clients.
</DialogDescription>
</div>
</DialogContent>
</Dialog>
</PageHeader>
<div className="p-4">
{/* Intro block */}
<div className="px-3 pt-2 pb-4">
<h2 className="text-sm font-semibold">Lists</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Group people into named lists. Use any list as the source for a custom feed, or filter searches by list members.
</p>
</div>
{/* Create new list */}
<div className="flex gap-2 mb-4">
<Input
placeholder="New list name…"
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
disabled={createList.isPending}
/>
<Button
onClick={handleCreate}
disabled={!newListName.trim() || createList.isPending}
className="shrink-0 gap-1.5"
>
<Plus className="size-4" />
Create
</Button>
</div>
{/* Your Lists */}
<div>
<div className="relative px-3 py-3.5">
<h2 className="text-base font-semibold">
Your Lists
{!isLoading && lists.length > 0 && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
{lists.length}
</span>
)}
</h2>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</div>
<div className="pt-2 pb-2">
{isLoading ? (
<div className="space-y-1 px-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<div className="flex -space-x-1.5 w-10">
<Skeleton className="size-7 rounded-full" />
<Skeleton className="size-7 rounded-full" />
</div>
<div className="flex-1 space-y-1">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-16" />
</div>
</div>
))}
</div>
) : lists.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">
No lists yet. Create one above, or add users from the&nbsp; menu on notes and profiles.
</p>
) : (
<div className="space-y-0.5 px-1">
{lists.map((list) => (
<ListRow key={list.id} list={list} onDelete={setDeleteTarget} />
))}
</div>
)}
</div>
</div>
<p className="text-xs text-muted-foreground px-3 pt-4 leading-relaxed">
Lists are stored on Nostr (NIP-51) and sync across clients.
</p>
</div>
{/* Delete confirm dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => { if (!o) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete list?</AlertDialogTitle>
<AlertDialogDescription>
This will delete "{deleteTarget?.title}" and its {deleteTarget?.pubkeys.length ?? 0} members. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
);
}
-62
View File
@@ -1,62 +0,0 @@
import { useSeoMeta } from '@unhead/react';
import { BadgeCheck, Users } from 'lucide-react';
import { useAppContext } from '@/hooks/useAppContext';
import { useAddrEvent } from '@/hooks/useEvent';
import { VERIFIED_FOLLOW_PACK } from '@/lib/agoraDefaults';
import { PageHeader } from '@/components/PageHeader';
import { FollowPackDetailContent } from '@/components/FollowPackDetailContent';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function VerifiedPage() {
const { config } = useAppContext();
const { data: event, isLoading, isError } = useAddrEvent(
{
kind: VERIFIED_FOLLOW_PACK.kind,
pubkey: VERIFIED_FOLLOW_PACK.pubkey,
identifier: VERIFIED_FOLLOW_PACK.identifier,
},
VERIFIED_FOLLOW_PACK.relays,
);
useSeoMeta({
title: `Verified | ${config.appName}`,
description: 'Discover and follow verified accounts curated for Agora.',
});
return (
<main>
<PageHeader title="Verified" icon={<BadgeCheck className="size-5 text-primary" />} />
<div className="max-w-2xl mx-auto w-full">
{isLoading ? (
<div className="px-4 py-4 space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
) : isError || !event ? (
<div className="px-4 py-4">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Users className="size-5 text-muted-foreground" />
Verified pack unavailable
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
We could not load the verified follow pack right now. Please try again shortly.
</p>
</CardContent>
</Card>
</div>
) : (
<FollowPackDetailContent event={event} />
)}
</div>
</main>
);
}
export default VerifiedPage;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1,28 +0,0 @@
import { useState, useCallback } from 'react';
import { getExtraKindDef } from '@/lib/extraKinds';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import { KindFeedPage } from '@/pages/KindFeedPage';
import { WebxdcUploadDialog } from '@/components/WebxdcUploadDialog';
const webxdcDef = getExtraKindDef('webxdc')!;
const TAG_FILTERS = { '#m': ['application/x-webxdc'] };
export function WebxdcFeedPage() {
const [uploadOpen, setUploadOpen] = useState(false);
const handleFabClick = useCallback(() => {
setUploadOpen(true);
}, []);
return (
<KindFeedPage
kind={webxdcDef.kind}
title={webxdcDef.label}
icon={sidebarItemIcon('webxdc', 'size-5')}
tagFilters={TAG_FILTERS}
onFabClick={handleFabClick}
emptyMessage="No webxdc apps found yet. Check your relay connections or try again later."
extra={<WebxdcUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} />}
/>
);
}
-786
View File
@@ -1,786 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import {
ArrowLeft,
BookOpen,
Calendar,
ExternalLink,
Eye,
FlameKindling,
Loader2,
Newspaper,
Search,
Star,
TrendingUp,
X,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppContext } from '@/hooks/useAppContext';
import {
useWikipediaFeatured,
type WikiPage,
type OnThisDayEvent,
type NewsItem,
} from '@/hooks/useWikipediaFeatured';
import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikipediaSearch';
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Section = 'featured' | 'mostread' | 'news' | 'onthisday';
interface SectionMeta {
label: string;
icon: React.ReactNode;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SECTIONS: Record<Section, SectionMeta> = {
featured: { label: 'Featured', icon: <Star className="size-3.5" /> },
mostread: { label: 'Trending', icon: <TrendingUp className="size-3.5" /> },
news: { label: 'In the News', icon: <Newspaper className="size-3.5" /> },
onthisday: { label: 'On This Day', icon: <Calendar className="size-3.5" /> },
};
const SECTION_ORDER: Section[] = ['featured', 'mostread', 'news', 'onthisday'];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function wikiPageUrl(page: WikiPage): string {
return page.content_urls?.desktop?.page ?? `https://en.wikipedia.org/wiki/${page.title}`;
}
function dittoUrl(url: string): string {
return `/i/${encodeURIComponent(url)}`;
}
function dittoWikiUrl(page: WikiPage): string {
return dittoUrl(wikiPageUrl(page));
}
function formatViews(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return String(n);
}
function truncateExtract(text: string, maxLen = 150): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen).replace(/\s+\S*$/, '') + '\u2026';
}
// ---------------------------------------------------------------------------
// Scrollspy hook
// ---------------------------------------------------------------------------
function useScrollspy(
sectionRefs: Record<Section, RefObject<HTMLElement | null>>,
navBarRef: RefObject<HTMLElement | null>,
) {
const [active, setActive] = useState<Section>(SECTION_ORDER[0]);
// Guard against scroll-into-view triggering the observer
const isScrollingRef = useRef(false);
useEffect(() => {
const navBarHeight = navBarRef.current?.offsetHeight ?? 48;
// Trigger when a section crosses just below the sticky nav bar
const rootMargin = `-${navBarHeight + 8}px 0px -60% 0px`;
const observer = new IntersectionObserver(
(entries) => {
if (isScrollingRef.current) return;
// Pick the first visible section in DOM order
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible.length > 0) {
const id = visible[0].target.getAttribute('data-section') as Section;
if (id) setActive(id);
}
},
{ rootMargin, threshold: 0 },
);
for (const key of SECTION_ORDER) {
const el = sectionRefs[key].current;
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [sectionRefs, navBarRef]);
const scrollTo = useCallback((section: Section) => {
const el = sectionRefs[section].current;
if (!el) return;
const navBarHeight = navBarRef.current?.offsetHeight ?? 48;
const top = el.getBoundingClientRect().top + window.scrollY - navBarHeight - 8;
isScrollingRef.current = true;
setActive(section);
window.scrollTo({ top, behavior: 'smooth' });
// Release the guard after the smooth scroll finishes
setTimeout(() => { isScrollingRef.current = false; }, 800);
}, [sectionRefs, navBarRef]);
return { active, scrollTo };
}
// ---------------------------------------------------------------------------
// Section pill
// ---------------------------------------------------------------------------
function SectionPill({ section, active, onClick }: {
section: Section;
active: boolean;
onClick: () => void;
}) {
const meta = SECTIONS[section];
const pillRef = useRef<HTMLButtonElement>(null);
// Auto-scroll the pill into view when it becomes active
useEffect(() => {
if (active && pillRef.current) {
pillRef.current.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}, [active]);
return (
<button
ref={pillRef}
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap shrink-0',
active
? 'bg-primary text-primary-foreground shadow-sm'
: 'bg-secondary/60 text-muted-foreground hover:bg-secondary hover:text-foreground',
)}
>
{meta.icon}
{meta.label}
</button>
);
}
// ---------------------------------------------------------------------------
// Article card (used for featured, most-read, on-this-day, news links)
// ---------------------------------------------------------------------------
function ArticleCard({ page, badge, badgeIcon }: {
page: WikiPage;
badge?: string;
badgeIcon?: React.ReactNode;
}) {
return (
<Link
to={dittoWikiUrl(page)}
className="group block rounded-2xl border border-border overflow-hidden bg-card hover:border-primary/30 transition-all duration-300 hover:shadow-lg hover:shadow-primary/5"
>
{/* Thumbnail */}
<div className="relative aspect-[4/3] overflow-hidden bg-gradient-to-br from-blue-500/10 to-indigo-500/10">
{page.thumbnail ? (
<img
src={page.thumbnail.source}
alt={page.normalizedtitle}
loading="lazy"
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<WikipediaIcon className="size-10 text-muted-foreground/20" />
</div>
)}
{/* Top-right badge */}
{badge && (
<div className="absolute top-2 right-2 px-2 py-0.5 rounded-md bg-black/60 backdrop-blur-sm text-white text-xs font-medium flex items-center gap-1">
{badgeIcon}
{badge}
</div>
)}
</div>
{/* Content */}
<div className="p-3 space-y-1">
<h3 className="font-semibold text-sm leading-tight group-hover:text-primary transition-colors line-clamp-1">
{page.normalizedtitle ?? page.titles?.normalized ?? page.title.replace(/_/g, ' ')}
</h3>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{page.description ?? truncateExtract(page.extract)}
</p>
</div>
</Link>
);
}
// ---------------------------------------------------------------------------
// Featured article (hero card)
// ---------------------------------------------------------------------------
function FeaturedArticleCard({ page }: { page: WikiPage }) {
return (
<Link
to={dittoWikiUrl(page)}
className="group block rounded-2xl border border-border overflow-hidden bg-card hover:border-primary/30 transition-all duration-300 hover:shadow-lg hover:shadow-primary/5"
>
<div className="relative aspect-[16/9] overflow-hidden bg-gradient-to-br from-amber-500/10 to-orange-500/10">
{page.thumbnail ? (
<img
src={page.originalimage?.source ?? page.thumbnail.source}
alt={page.normalizedtitle}
loading="lazy"
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<Star className="size-12 text-muted-foreground/20" />
</div>
)}
</div>
<div className="p-4 space-y-2">
<h3 className="font-bold text-base leading-tight group-hover:text-primary transition-colors">
{page.normalizedtitle ?? page.titles?.normalized ?? page.title.replace(/_/g, ' ')}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
{truncateExtract(page.extract, 280)}
</p>
</div>
</Link>
);
}
// ---------------------------------------------------------------------------
// On This Day entry
// ---------------------------------------------------------------------------
function OnThisDayCard({ event }: { event: OnThisDayEvent }) {
const mainPage = event.pages[0];
return (
<div className="rounded-2xl border border-border overflow-hidden bg-card">
<div className="flex items-start gap-3 p-4">
{/* Year pill */}
<div className="shrink-0 px-2.5 py-1 rounded-lg bg-primary/10 text-primary text-xs font-bold tabular-nums">
{event.year}
</div>
<div className="flex-1 min-w-0 space-y-2">
<p className="text-sm leading-relaxed">{event.text}</p>
{mainPage && (
<Link
to={dittoWikiUrl(mainPage)}
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<BookOpen className="size-3" />
{mainPage.normalizedtitle ?? mainPage.title.replace(/_/g, ' ')}
</Link>
)}
</div>
{mainPage?.thumbnail && (
<Link to={dittoWikiUrl(mainPage)} className="shrink-0">
<img
src={mainPage.thumbnail.source}
alt=""
className="w-14 h-14 rounded-lg object-cover"
loading="lazy"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
</Link>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// News card
// ---------------------------------------------------------------------------
function NewsCard({ item }: { item: NewsItem }) {
// Extract clean text from HTML story
const storyText = useMemo(() => {
return item.story
.replace(/<!--.*?-->/g, '') // remove comments
.replace(/<\/?[^>]+(>|$)/g, '') // strip HTML tags
.trim();
}, [item.story]);
const mainLink = item.links[0];
return (
<div className="rounded-2xl border border-border overflow-hidden bg-card">
<div className="flex items-start gap-3 p-4">
<div className="shrink-0 mt-0.5">
<Newspaper className="size-4 text-primary" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<p className="text-sm leading-relaxed">{storyText}</p>
{mainLink && (
<Link
to={dittoWikiUrl(mainLink)}
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<BookOpen className="size-3" />
{mainLink.normalizedtitle ?? mainLink.title.replace(/_/g, ' ')}
</Link>
)}
</div>
{mainLink?.thumbnail && (
<Link to={dittoWikiUrl(mainLink)} className="shrink-0">
<img
src={mainLink.thumbnail.source}
alt=""
className="w-14 h-14 rounded-lg object-cover"
loading="lazy"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
</Link>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Search bar
// ---------------------------------------------------------------------------
function WikipediaSearchBar() {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const { data: results, isFetching } = useWikipediaSearch(debouncedQuery);
const handleChange = useCallback((value: string) => {
setQuery(value);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedQuery(value.trim());
}, 300);
}, []);
useEffect(() => {
return () => clearTimeout(debounceRef.current);
}, []);
useEffect(() => {
if (debouncedQuery.length >= 2 && results && results.length > 0) {
setDropdownOpen(true);
} else if (debouncedQuery.length >= 2 && results && results.length === 0 && !isFetching) {
setDropdownOpen(true);
}
}, [debouncedQuery, results, isFetching]);
// Close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = useCallback((result: WikipediaSearchResult) => {
setQuery('');
setDebouncedQuery('');
setDropdownOpen(false);
inputRef.current?.blur();
navigate(dittoUrl(result.url));
}, [navigate]);
const handleClear = useCallback(() => {
setQuery('');
setDebouncedQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setDropdownOpen(false);
inputRef.current?.blur();
}
if (e.key === 'Enter' && results && results.length > 0) {
e.preventDefault();
handleSelect(results[0]);
}
}, [results, handleSelect]);
return (
<div ref={containerRef} className="relative px-4 pb-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
type="text"
placeholder="Search Wikipedia..."
value={query}
onChange={(e) => handleChange(e.target.value)}
onFocus={() => {
if (debouncedQuery.length >= 2) setDropdownOpen(true);
}}
onKeyDown={handleKeyDown}
className="pl-9 pr-9 h-9 text-base md:text-sm"
/>
{query ? (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
<X className="size-3.5" />
</button>
) : null}
</div>
{/* Search results dropdown */}
{dropdownOpen && debouncedQuery.length >= 2 && (
<div className="absolute left-4 right-4 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg overflow-hidden">
{isFetching && (!results || results.length === 0) ? (
<div className="divide-y divide-border">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="w-10 h-10 rounded shrink-0" />
<div className="flex-1 min-w-0 space-y-1">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : results && results.length > 0 ? (
<div className="divide-y divide-border max-h-80 overflow-y-auto">
{results.map((result) => (
<button
key={result.title}
type="button"
className="flex items-center gap-3 px-3 py-2.5 w-full text-left hover:bg-secondary/60 transition-colors"
onClick={() => handleSelect(result)}
>
{result.thumbnail ? (
<img
src={result.thumbnail}
alt=""
className="w-10 h-10 rounded object-cover bg-secondary shrink-0"
loading="lazy"
/>
) : (
<div className="w-10 h-10 rounded bg-gradient-to-br from-blue-500/10 to-indigo-500/10 flex items-center justify-center shrink-0">
<WikipediaIcon className="size-4 text-muted-foreground/50" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{result.title}</p>
<p className="text-xs text-muted-foreground truncate">{result.description}</p>
</div>
</button>
))}
</div>
) : (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
No results found for &ldquo;{debouncedQuery}&rdquo;
</div>
)}
{isFetching && results && results.length > 0 && (
<div className="flex justify-center py-2 border-t border-border">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Section heading
// ---------------------------------------------------------------------------
function SectionHeading({ icon, title, subtitle }: {
icon: React.ReactNode;
title: string;
subtitle?: string;
}) {
return (
<div className="flex items-center gap-2 mb-3">
<div className="size-7 rounded-lg bg-primary/10 flex items-center justify-center">
{icon}
</div>
<div>
<h2 className="text-sm font-bold leading-tight">{title}</h2>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Loading skeleton
// ---------------------------------------------------------------------------
function WikipediaLoadingSkeleton() {
return (
<div className="px-4 pt-4 pb-4 space-y-6">
{/* Featured skeleton */}
<div className="rounded-2xl border border-border overflow-hidden bg-card">
<Skeleton className="aspect-[16/9] w-full" />
<div className="p-4 space-y-2">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
{/* Grid skeleton */}
<div className="grid grid-cols-2 gap-3 sidebar:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border border-border overflow-hidden bg-card">
<Skeleton className="aspect-[4/3] w-full" />
<div className="p-3 space-y-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
</div>
</div>
))}
</div>
{/* List skeleton */}
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-2xl border border-border p-4">
<div className="flex items-start gap-3">
<Skeleton className="w-10 h-6 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-1/3" />
</div>
<Skeleton className="w-14 h-14 rounded-lg" />
</div>
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function WikipediaPage() {
const { config } = useAppContext();
const { data: feed, isLoading, isError } = useWikipediaFeatured();
useSeoMeta({
title: `Wikipedia | ${config.appName}`,
description: 'Explore today\'s featured Wikipedia content \u2014 trending articles, on this day, in the news, and more.',
});
// Section refs for scrollspy
const navBarRef = useRef<HTMLDivElement>(null);
const featuredRef = useRef<HTMLDivElement>(null);
const mostreadRef = useRef<HTMLDivElement>(null);
const newsRef = useRef<HTMLDivElement>(null);
const onthisdayRef = useRef<HTMLDivElement>(null);
const sectionRefs: Record<Section, RefObject<HTMLElement | null>> = {
featured: featuredRef,
mostread: mostreadRef,
news: newsRef,
onthisday: onthisdayRef,
};
const { active, scrollTo } = useScrollspy(sectionRefs, navBarRef);
// Filter most-read to remove "Main Page" and "Special:" pages
const mostReadArticles = useMemo(() => {
if (!feed?.mostread?.articles) return [];
return feed.mostread.articles
.filter((a) => a.title !== 'Main_Page' && !a.title.startsWith('Special:'))
.slice(0, 12);
}, [feed?.mostread?.articles]);
const onThisDayEvents = useMemo(() => {
if (!feed?.onthisday) return [];
return feed.onthisday.slice(0, 8);
}, [feed?.onthisday]);
const newsItems = useMemo(() => {
return feed?.news ?? [];
}, [feed?.news]);
return (
<main className="pb-16 sidebar:pb-0">
{/* Header */}
<div className="flex items-center gap-4 px-4 pt-4 pb-2">
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<div className="size-8 rounded-lg bg-gradient-to-br from-blue-500/20 to-indigo-500/10 flex items-center justify-center">
<WikipediaIcon className="size-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h1 className="text-xl font-bold leading-tight">Wikipedia</h1>
<p className="text-xs text-muted-foreground">Today&apos;s featured content</p>
</div>
</div>
<a
href="https://en.wikipedia.org"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-full hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
title="Visit Wikipedia"
>
<ExternalLink className="size-4" />
</a>
</div>
{/* Search bar */}
<WikipediaSearchBar />
{/* Scrollspy navigation pills */}
<div
ref={navBarRef}
className="sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10 border-b border-border"
>
<div className="flex gap-2 px-4 py-2.5 overflow-x-auto">
{SECTION_ORDER.map((s) => (
<SectionPill
key={s}
section={s}
active={active === s}
onClick={() => scrollTo(s)}
/>
))}
</div>
</div>
{/* Content */}
{isLoading ? (
<WikipediaLoadingSkeleton />
) : isError ? (
<div className="px-4 pt-8 pb-16 text-center">
<FlameKindling className="size-10 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">
Couldn&apos;t load today&apos;s Wikipedia content. Try again later.
</p>
</div>
) : (
<div className="px-4 pt-4 pb-4 space-y-6">
{/* Today's Featured Article */}
{feed?.tfa && (
<div ref={featuredRef} data-section="featured">
<SectionHeading
icon={<Star className="size-3.5 text-amber-500" />}
title="Today's Featured Article"
/>
<FeaturedArticleCard page={feed.tfa} />
</div>
)}
{/* Most Read */}
{mostReadArticles.length > 0 && (
<div ref={mostreadRef} data-section="mostread">
<SectionHeading
icon={<TrendingUp className="size-3.5 text-primary" />}
title="Trending"
subtitle="Most read articles today"
/>
<div className="grid grid-cols-2 gap-3 sidebar:grid-cols-3">
{mostReadArticles.map((page) => (
<ArticleCard
key={page.pageid}
page={page}
badge={page.views ? formatViews(page.views) : undefined}
badgeIcon={<Eye className="size-3" />}
/>
))}
</div>
</div>
)}
{/* In the News */}
{newsItems.length > 0 && (
<div ref={newsRef} data-section="news">
<SectionHeading
icon={<Newspaper className="size-3.5 text-sky-500" />}
title="In the News"
/>
<div className="space-y-3">
{newsItems.map((item, i) => (
<NewsCard key={i} item={item} />
))}
</div>
</div>
)}
{/* On This Day */}
{onThisDayEvents.length > 0 && (
<div ref={onthisdayRef} data-section="onthisday">
<SectionHeading
icon={<Calendar className="size-3.5 text-violet-500" />}
title="On This Day"
subtitle={new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' })}
/>
<div className="space-y-3">
{onThisDayEvents.map((event, i) => (
<OnThisDayCard key={i} event={event} />
))}
</div>
</div>
)}
</div>
)}
{/* Attribution footer */}
<div className="px-4 pb-8">
<div className="rounded-xl border border-dashed border-border bg-secondary/30 px-4 py-3 text-center">
<p className="text-xs text-muted-foreground">
Content provided by{' '}
<a
href="https://en.wikipedia.org"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
Wikipedia
</a>
, the free encyclopedia. Text is available under the{' '}
<a
href="https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_Creative_Commons_Attribution-ShareAlike_4.0_International_License"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
CC BY-SA 4.0
</a>
{' '}license.
</p>
</div>
</div>
</main>
);
}
-99
View File
@@ -1,99 +0,0 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppContext } from '@/hooks/useAppContext';
import { useGlobalActivity, useTopCountryHashtags } from '@/hooks/useGlobalActivity';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { WorldDiscoveryDrawer } from '@/components/world/WorldDiscoveryDrawer';
import { WorldDiscoveryPanel } from '@/components/world/WorldDiscoveryPanel';
// Lazy-load the map: react-leaflet + leaflet pull in ~150 KB of JS that we
// don't want to ship with the rest of the app shell.
const WorldMap = lazy(() => import('@/components/world/WorldMap'));
/**
* Breakpoint at which the world page has room for a docked right column
* (`WorldDiscoveryPanel`) alongside the left sidebar and a usable map.
* Below this width we fall back to the floating discovery launcher +
* modal so the map isn't crushed.
*
* Matches the `xl` Tailwind breakpoint (1280px) — the same threshold the
* default `WidgetSidebar` uses. The earlier `sidebar` breakpoint (900px)
* left only ~540px of map between the 300px left rail and the 360px
* discovery panel, which was too cramped to be useful.
*/
const SIDEBAR_MEDIA_QUERY = '(min-width: 1280px)';
function useHasSidebar(): boolean {
const [hasSidebar, setHasSidebar] = useState(() =>
typeof window !== 'undefined' && window.matchMedia(SIDEBAR_MEDIA_QUERY).matches,
);
useEffect(() => {
const mq = window.matchMedia(SIDEBAR_MEDIA_QUERY);
const handler = (e: MediaQueryListEvent) => setHasSidebar(e.matches);
mq.addEventListener('change', handler);
setHasSidebar(mq.matches);
return () => mq.removeEventListener('change', handler);
}, []);
return hasSidebar;
}
export function WorldPage() {
const { config } = useAppContext();
const hasSidebar = useHasSidebar();
useSeoMeta({
title: `World | ${config.appName}`,
description: 'Explore community activity around the world',
});
const { data: activities } = useGlobalActivity();
const { data: topHashtags } = useTopCountryHashtags();
// Memoise the activities/hashtags fallbacks too — `new Map()` literals
// produce a fresh reference every render, which causes WorldMap's
// `activityMarkers` useMemo (and downstream popover refs) to invalidate
// even when the underlying data hasn't changed.
const safeActivities = useMemo(() => activities ?? new Map<string, number>(), [activities]);
const safeTopHashtags = useMemo(() => topHashtags ?? new Map<string, string>(), [topHashtags]);
// `fullBleed: true` is the new reusable preset for edge-to-edge pages —
// see `LayoutOptions.fullBleed` for the full list of flags it expands to.
// We override `rightSidebar` with our discovery panel; the panel hides
// itself below the sidebar breakpoint via Tailwind's `hidden sidebar:flex`,
// and the bottom drawer takes over there.
useLayoutOptions({
fullBleed: true,
rightSidebar: <WorldDiscoveryPanel activities={activities} />,
});
return (
// The height must account for the sticky TopNav (h-16 = 4rem) so the
// map fills exactly the remaining viewport. Using `h-dvh` (100dvh)
// would make the page scrollable and let the sticky header overlap the
// top of the map — hiding the Leaflet zoom controls. The calc keeps
// the page at exactly one viewport with no scroll.
<div className="relative w-full h-[calc(100dvh-4rem)] overflow-hidden bg-muted/20">
<Suspense
fallback={
<div className="absolute inset-0">
<Skeleton className="h-full w-full rounded-none" />
</div>
}
>
<div className="absolute inset-0">
<WorldMap
activities={safeActivities}
topHashtags={safeTopHashtags}
/>
</div>
</Suspense>
{/* Below the sidebar breakpoint, surface the discovery experience as
a floating button + modal. Above it, the docked
`WorldDiscoveryPanel` (rendered as the layout's right sidebar)
takes over and this component is unmounted. */}
{!hasSidebar && <WorldDiscoveryDrawer activities={activities} />}
</div>
);
}