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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 “{debouncedQuery}”
|
||||
</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 && <> · {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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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">·</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 “{debouncedQuery}”
|
||||
</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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 “{debouncedQuery}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
{' '}·{' '}
|
||||
<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 />;
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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 … 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 “{debouncedQuery}”
|
||||
</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'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't load today'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user