mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-08 06:27:17 +02:00
wip: skeletons
This commit is contained in:
@@ -15,20 +15,8 @@ import { CommunityNIPDetailRenderer } from "./nostr/kinds/CommunityNIPDetailRend
|
||||
import { RepositoryDetailRenderer } from "./nostr/kinds/RepositoryDetailRenderer";
|
||||
import { JsonViewer } from "./JsonViewer";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
import {
|
||||
Copy,
|
||||
CopyCheck,
|
||||
FileJson,
|
||||
Wifi,
|
||||
Loader2,
|
||||
WifiOff,
|
||||
XCircle,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldX,
|
||||
ShieldQuestion,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { EventDetailSkeleton } from "@/components/ui/skeleton";
|
||||
import { Copy, CopyCheck, FileJson, Wifi } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -41,76 +29,12 @@ import { useCopy } from "../hooks/useCopy";
|
||||
import { getSeenRelays } from "applesauce-core/helpers/relays";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import type { RelayState } from "@/types/relay-state";
|
||||
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
||||
|
||||
export interface EventDetailViewerProps {
|
||||
pointer: EventPointer | AddressPointer;
|
||||
}
|
||||
|
||||
// Helper functions for relay status icons (from ReqViewer)
|
||||
function getConnectionIcon(relay: RelayState | undefined) {
|
||||
if (!relay) {
|
||||
return {
|
||||
icon: <WifiOff className="size-3 text-muted-foreground" />,
|
||||
label: "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
connected: {
|
||||
icon: <Wifi className="size-3 text-green-500" />,
|
||||
label: "Connected",
|
||||
},
|
||||
connecting: {
|
||||
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
|
||||
label: "Connecting",
|
||||
},
|
||||
disconnected: {
|
||||
icon: <WifiOff className="size-3 text-muted-foreground" />,
|
||||
label: "Disconnected",
|
||||
},
|
||||
error: {
|
||||
icon: <XCircle className="size-3 text-red-500" />,
|
||||
label: "Connection Error",
|
||||
},
|
||||
};
|
||||
return iconMap[relay.connectionState];
|
||||
}
|
||||
|
||||
function getAuthIcon(relay: RelayState | undefined) {
|
||||
if (!relay || relay.authStatus === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
authenticated: {
|
||||
icon: <ShieldCheck className="size-3 text-green-500" />,
|
||||
label: "Authenticated",
|
||||
},
|
||||
challenge_received: {
|
||||
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
|
||||
label: "Challenge Received",
|
||||
},
|
||||
authenticating: {
|
||||
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
|
||||
label: "Authenticating",
|
||||
},
|
||||
failed: {
|
||||
icon: <ShieldX className="size-3 text-red-500" />,
|
||||
label: "Authentication Failed",
|
||||
},
|
||||
rejected: {
|
||||
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
|
||||
label: "Authentication Rejected",
|
||||
},
|
||||
none: {
|
||||
icon: <Shield className="size-3 text-muted-foreground" />,
|
||||
label: "No Authentication",
|
||||
},
|
||||
};
|
||||
return iconMap[relay.authStatus] || iconMap.none;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventDetailViewer - Detailed view for a single event
|
||||
* Shows compact metadata header and rendered content
|
||||
@@ -124,8 +48,8 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
|
||||
// Loading state
|
||||
if (!event) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-muted-foreground">
|
||||
<div className="text-sm">Loading event...</div>
|
||||
<div className="flex flex-col h-full p-8">
|
||||
<EventDetailSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -149,15 +73,6 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
|
||||
relays: relays,
|
||||
});
|
||||
|
||||
// Format timestamp - compact format
|
||||
// const timestamp = new Date(event.created_at * 1000).toLocaleString("en-US", {
|
||||
// month: "2-digit",
|
||||
// day: "2-digit",
|
||||
// year: "numeric",
|
||||
// hour: "2-digit",
|
||||
// minute: "2-digit",
|
||||
// });
|
||||
|
||||
// Get relay state for each relay
|
||||
const relayStatesForEvent = relays
|
||||
? relays.map((url) => ({
|
||||
|
||||
@@ -38,7 +38,7 @@ function AuthToast({
|
||||
showInboxOutbox={false}
|
||||
variant="prompt"
|
||||
iconClassname="size-4"
|
||||
urlClassname="text-sm"
|
||||
urlClassname="break-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -154,13 +154,7 @@ export function GlobalAuthPrompt() {
|
||||
relayUrl={challenge.relayUrl}
|
||||
challenge={challenge.challenge}
|
||||
onAuthenticate={async (remember) => {
|
||||
console.log(
|
||||
`[AuthPrompt] Authenticate with remember=${remember}`,
|
||||
);
|
||||
if (remember) {
|
||||
console.log(
|
||||
`[AuthPrompt] Setting preference to "always" for ${challenge.relayUrl}`,
|
||||
);
|
||||
await setAuthPreference(challenge.relayUrl, "always");
|
||||
}
|
||||
|
||||
@@ -189,11 +183,7 @@ export function GlobalAuthPrompt() {
|
||||
}
|
||||
}}
|
||||
onReject={async (remember) => {
|
||||
console.log(`[AuthPrompt] Reject with remember=${remember}`);
|
||||
if (remember) {
|
||||
console.log(
|
||||
`[AuthPrompt] Setting preference to "never" for ${challenge.relayUrl}`,
|
||||
);
|
||||
await setAuthPreference(challenge.relayUrl, "never");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import Nip05 from "./nostr/nip05";
|
||||
import { ProfileCardSkeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Copy,
|
||||
CopyCheck,
|
||||
@@ -8,14 +9,6 @@ import {
|
||||
Inbox,
|
||||
Send,
|
||||
Wifi,
|
||||
Loader2,
|
||||
WifiOff,
|
||||
XCircle,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldX,
|
||||
ShieldQuestion,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { kinds, nip19 } from "nostr-tools";
|
||||
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
|
||||
@@ -31,76 +24,12 @@ import {
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import type { RelayState } from "@/types/relay-state";
|
||||
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
||||
|
||||
export interface ProfileViewerProps {
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
// Helper functions for relay status icons (from EventDetailViewer)
|
||||
function getConnectionIcon(relay: RelayState | undefined) {
|
||||
if (!relay) {
|
||||
return {
|
||||
icon: <WifiOff className="size-3 text-muted-foreground" />,
|
||||
label: "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
connected: {
|
||||
icon: <Wifi className="size-3 text-green-500" />,
|
||||
label: "Connected",
|
||||
},
|
||||
connecting: {
|
||||
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
|
||||
label: "Connecting",
|
||||
},
|
||||
disconnected: {
|
||||
icon: <WifiOff className="size-3 text-muted-foreground" />,
|
||||
label: "Disconnected",
|
||||
},
|
||||
error: {
|
||||
icon: <XCircle className="size-3 text-red-500" />,
|
||||
label: "Connection Error",
|
||||
},
|
||||
};
|
||||
return iconMap[relay.connectionState];
|
||||
}
|
||||
|
||||
function getAuthIcon(relay: RelayState | undefined) {
|
||||
if (!relay || relay.authStatus === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
authenticated: {
|
||||
icon: <ShieldCheck className="size-3 text-green-500" />,
|
||||
label: "Authenticated",
|
||||
},
|
||||
challenge_received: {
|
||||
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
|
||||
label: "Challenge Received",
|
||||
},
|
||||
authenticating: {
|
||||
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
|
||||
label: "Authenticating",
|
||||
},
|
||||
failed: {
|
||||
icon: <ShieldX className="size-3 text-red-500" />,
|
||||
label: "Authentication Failed",
|
||||
},
|
||||
rejected: {
|
||||
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
|
||||
label: "Authentication Rejected",
|
||||
},
|
||||
none: {
|
||||
icon: <Shield className="size-3 text-muted-foreground" />,
|
||||
label: "No Authentication",
|
||||
},
|
||||
};
|
||||
return iconMap[relay.authStatus] || iconMap.none;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileViewer - Detailed view for a user profile
|
||||
* Shows profile metadata, inbox/outbox relays, and raw JSON
|
||||
@@ -262,11 +191,7 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
|
||||
|
||||
{/* Profile Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{!profile && !profileEvent && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
Loading profile...
|
||||
</div>
|
||||
)}
|
||||
{!profile && !profileEvent && <ProfileCardSkeleton variant="full" />}
|
||||
|
||||
{!profile && profileEvent && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
|
||||
@@ -5,14 +5,6 @@ import {
|
||||
Radio,
|
||||
FileText,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Loader2,
|
||||
XCircle,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldX,
|
||||
ShieldQuestion,
|
||||
Shield,
|
||||
Filter as FilterIcon,
|
||||
Download,
|
||||
Clock,
|
||||
@@ -20,6 +12,7 @@ import {
|
||||
Hash,
|
||||
Search,
|
||||
Code,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { useReqTimeline } from "@/hooks/useReqTimeline";
|
||||
@@ -28,6 +21,7 @@ import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { FeedEvent } from "./nostr/Feed";
|
||||
import { KindBadge } from "./KindBadge";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { TimelineSkeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -58,7 +52,6 @@ import {
|
||||
} from "./ui/accordion";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
import type { NostrFilter } from "@/types/nostr";
|
||||
import type { RelayState } from "@/types/relay-state";
|
||||
import {
|
||||
formatEventIds,
|
||||
formatDTags,
|
||||
@@ -70,6 +63,7 @@ import { sanitizeFilename } from "@/lib/filename-utils";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { CodeCopyButton } from "@/components/CodeCopyButton";
|
||||
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
|
||||
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
||||
|
||||
// Memoized FeedEvent to prevent unnecessary re-renders during scroll
|
||||
const MemoizedFeedEvent = memo(
|
||||
@@ -77,70 +71,6 @@ const MemoizedFeedEvent = memo(
|
||||
(prev, next) => prev.event.id === next.event.id,
|
||||
);
|
||||
|
||||
// Helper functions for relay status icons
|
||||
function getConnectionIcon(relay: RelayState | undefined) {
|
||||
if (!relay) {
|
||||
return {
|
||||
icon: <WifiOff className="size-3 text-muted-foreground" />,
|
||||
label: "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
connected: {
|
||||
icon: <Wifi className="size-3 text-green-500" />,
|
||||
label: "Connected",
|
||||
},
|
||||
connecting: {
|
||||
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
|
||||
label: "Connecting",
|
||||
},
|
||||
disconnected: {
|
||||
icon: <WifiOff className="size-3 text-muted-foreground" />,
|
||||
label: "Disconnected",
|
||||
},
|
||||
error: {
|
||||
icon: <XCircle className="size-3 text-red-500" />,
|
||||
label: "Connection Error",
|
||||
},
|
||||
};
|
||||
return iconMap[relay.connectionState];
|
||||
}
|
||||
|
||||
function getAuthIcon(relay: RelayState | undefined) {
|
||||
if (!relay || relay.authStatus === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
authenticated: {
|
||||
icon: <ShieldCheck className="size-3 text-green-500" />,
|
||||
label: "Authenticated",
|
||||
},
|
||||
challenge_received: {
|
||||
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
|
||||
label: "Challenge Received",
|
||||
},
|
||||
authenticating: {
|
||||
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
|
||||
label: "Authenticating",
|
||||
},
|
||||
failed: {
|
||||
icon: <ShieldX className="size-3 text-red-500" />,
|
||||
label: "Authentication Failed",
|
||||
},
|
||||
rejected: {
|
||||
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
|
||||
label: "Authentication Rejected",
|
||||
},
|
||||
none: {
|
||||
icon: <Shield className="size-3 text-muted-foreground" />,
|
||||
label: "No Authentication",
|
||||
},
|
||||
};
|
||||
return iconMap[relay.authStatus] || iconMap.none;
|
||||
}
|
||||
|
||||
interface ReqViewerProps {
|
||||
filter: NostrFilter;
|
||||
relays?: string[];
|
||||
@@ -972,8 +902,8 @@ export default function ReqViewer({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Loading: Before EOSE received */}
|
||||
{loading && events.length === 0 && !eoseReceived && (
|
||||
<div className="text-center text-muted-foreground font-mono text-sm p-4">
|
||||
Loading events...
|
||||
<div className="p-4">
|
||||
<TimelineSkeleton count={5} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { KindRenderer } from "./kinds";
|
||||
import { EventCardSkeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface EmbeddedEventProps {
|
||||
/** Event ID string for regular events */
|
||||
@@ -70,10 +71,10 @@ export function EmbeddedEvent({
|
||||
);
|
||||
}
|
||||
|
||||
// No onOpen handler - just show loading text
|
||||
// No onOpen handler - show skeleton
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
Loading event...
|
||||
</span>
|
||||
<div className={className}>
|
||||
<EventCardSkeleton variant="compact" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Zoom from "react-medium-image-zoom";
|
||||
import "react-medium-image-zoom/dist/styles.css";
|
||||
import { Music, AlertCircle } from "lucide-react";
|
||||
import { Music, AlertCircle, Play, RotateCw } from "lucide-react";
|
||||
import {
|
||||
isImageURL,
|
||||
isVideoURL,
|
||||
@@ -22,6 +22,13 @@ interface MediaEmbedProps {
|
||||
// Video/Audio-specific
|
||||
showControls?: boolean; // default: true
|
||||
onAudioClick?: () => void; // open dialog for audio
|
||||
|
||||
// Loading & Performance
|
||||
aspectRatio?: string; // "16/9", "4/3", "1/1", "auto" - defaults based on media type
|
||||
showPlaceholder?: boolean; // default: true
|
||||
fadeIn?: boolean; // default: true
|
||||
onLoad?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
const PRESETS = {
|
||||
@@ -47,11 +54,55 @@ const PRESETS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get default aspect ratio based on media type and preset
|
||||
*/
|
||||
const getDefaultAspectRatio = (
|
||||
mediaType: string,
|
||||
preset: string,
|
||||
): string | undefined => {
|
||||
if (preset === "thumbnail") return "1/1";
|
||||
if (mediaType === "video") return "16/9";
|
||||
return undefined; // auto for images
|
||||
};
|
||||
|
||||
/**
|
||||
* Skeleton placeholder component with shimmer effect
|
||||
*/
|
||||
const SkeletonPlaceholder = ({
|
||||
aspectRatio,
|
||||
rounded,
|
||||
children,
|
||||
}: {
|
||||
aspectRatio?: string;
|
||||
rounded: string;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-muted/20 animate-pulse flex items-center justify-center",
|
||||
rounded,
|
||||
)}
|
||||
style={aspectRatio ? { aspectRatio } : undefined}
|
||||
aria-busy="true"
|
||||
aria-label="Loading media"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* MediaEmbed component for displaying images, videos, and audio with constraints
|
||||
* - Images: Use react-medium-image-zoom for inline zoom
|
||||
* - Videos: Show preview with play button, can trigger dialog
|
||||
* - Audio: Show audio player
|
||||
*
|
||||
* Features:
|
||||
* - Loading placeholders with skeleton shimmer
|
||||
* - Aspect ratio preservation to prevent layout shift
|
||||
* - Smooth fade-in animations
|
||||
* - Error handling with retry mechanism
|
||||
* - Performance optimized with CSS containment
|
||||
*/
|
||||
export function MediaEmbed({
|
||||
url,
|
||||
@@ -62,8 +113,16 @@ export function MediaEmbed({
|
||||
enableZoom = true,
|
||||
showControls = true,
|
||||
onAudioClick,
|
||||
aspectRatio,
|
||||
showPlaceholder = true,
|
||||
fadeIn = true,
|
||||
onLoad,
|
||||
onError: onErrorCallback,
|
||||
}: MediaEmbedProps) {
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
// Auto-detect media type if not specified
|
||||
const mediaType =
|
||||
@@ -79,72 +138,174 @@ export function MediaEmbed({
|
||||
|
||||
const presetStyles = PRESETS[preset];
|
||||
|
||||
// Determine aspect ratio (user override or default based on type/preset)
|
||||
const effectiveAspectRatio =
|
||||
aspectRatio || getDefaultAspectRatio(mediaType, preset);
|
||||
|
||||
// Reset states when URL changes
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setIsLoaded(false);
|
||||
setError(false);
|
||||
}, [url]);
|
||||
|
||||
const handleError = () => {
|
||||
setIsLoading(false);
|
||||
setError(true);
|
||||
if (onErrorCallback) {
|
||||
const error = new Error("Failed to load media");
|
||||
onErrorCallback(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Error fallback UI
|
||||
const handleLoad = () => {
|
||||
setIsLoading(false);
|
||||
setIsLoaded(true);
|
||||
if (onLoad) onLoad();
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setError(false);
|
||||
setIsLoading(true);
|
||||
setRetryCount((prev) => prev + 1);
|
||||
};
|
||||
|
||||
// Error fallback UI with retry
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 p-4 border border-destructive/50 rounded-lg bg-destructive/10",
|
||||
"flex flex-col items-center gap-3 p-4 border border-destructive/50 rounded-lg bg-destructive/10",
|
||||
className,
|
||||
)}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<AlertCircle className="w-6 h-6 text-destructive" />
|
||||
<p className="text-sm text-destructive">Failed to load media</p>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary underline hover:text-primary/80"
|
||||
>
|
||||
Open in new tab
|
||||
</a>
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
Failed to load media
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{mediaType === "image" && (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors"
|
||||
aria-label="Retry loading media"
|
||||
>
|
||||
<RotateCw className="w-3 h-3" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1 text-xs text-primary underline hover:text-primary/80"
|
||||
>
|
||||
Open in new tab
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image rendering with zoom
|
||||
// Image rendering with zoom, placeholder, and fade-in
|
||||
if (mediaType === "image") {
|
||||
const imageElement = (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt || "Image"}
|
||||
loading="lazy"
|
||||
className={cn(
|
||||
"w-full h-auto object-contain",
|
||||
presetStyles.rounded,
|
||||
enableZoom && "cursor-zoom-in",
|
||||
className,
|
||||
const imageContent = (
|
||||
<div
|
||||
className={cn("relative overflow-hidden", presetStyles.rounded)}
|
||||
style={
|
||||
effectiveAspectRatio
|
||||
? {
|
||||
aspectRatio: effectiveAspectRatio,
|
||||
maxHeight: presetStyles.maxHeight,
|
||||
maxWidth:
|
||||
preset === "thumbnail" ? presetStyles.maxWidth : "100%",
|
||||
contain: "content", // Performance optimization
|
||||
}
|
||||
: {
|
||||
maxHeight: presetStyles.maxHeight,
|
||||
maxWidth:
|
||||
preset === "thumbnail" ? presetStyles.maxWidth : "100%",
|
||||
}
|
||||
}
|
||||
>
|
||||
{/* Skeleton placeholder */}
|
||||
{showPlaceholder && isLoading && (
|
||||
<SkeletonPlaceholder
|
||||
aspectRatio={effectiveAspectRatio}
|
||||
rounded={presetStyles.rounded}
|
||||
/>
|
||||
)}
|
||||
style={{
|
||||
maxHeight: presetStyles.maxHeight,
|
||||
maxWidth: preset === "thumbnail" ? presetStyles.maxWidth : "100%",
|
||||
}}
|
||||
onError={handleError}
|
||||
/>
|
||||
|
||||
{/* Image with fade-in */}
|
||||
<img
|
||||
key={retryCount} // Force remount on retry
|
||||
src={url}
|
||||
alt={alt || "Image"}
|
||||
loading="lazy"
|
||||
className={cn(
|
||||
"w-full h-full object-contain",
|
||||
presetStyles.rounded,
|
||||
enableZoom && "cursor-zoom-in",
|
||||
fadeIn && "transition-opacity duration-300",
|
||||
isLoaded ? "opacity-100" : "opacity-0",
|
||||
className,
|
||||
)}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
aria-label={alt || "Image"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return enableZoom ? (
|
||||
<Zoom zoomMargin={40}>{imageElement}</Zoom>
|
||||
<Zoom zoomMargin={40}>{imageContent}</Zoom>
|
||||
) : (
|
||||
imageElement
|
||||
imageContent
|
||||
);
|
||||
}
|
||||
|
||||
// Video rendering with inline playback
|
||||
// Video rendering with placeholder and loading state
|
||||
if (mediaType === "video") {
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
className={cn("w-full", presetStyles.rounded, className)}
|
||||
style={{ maxHeight: presetStyles.maxHeight }}
|
||||
preload="metadata"
|
||||
controls={showControls}
|
||||
onError={handleError}
|
||||
/>
|
||||
<div
|
||||
className={cn("relative overflow-hidden", presetStyles.rounded)}
|
||||
style={{
|
||||
aspectRatio: effectiveAspectRatio,
|
||||
maxHeight: presetStyles.maxHeight,
|
||||
maxWidth: "100%",
|
||||
contain: "content", // Performance optimization
|
||||
}}
|
||||
>
|
||||
{/* Skeleton placeholder with play icon */}
|
||||
{showPlaceholder && isLoading && (
|
||||
<SkeletonPlaceholder
|
||||
aspectRatio={effectiveAspectRatio}
|
||||
rounded={presetStyles.rounded}
|
||||
>
|
||||
<Play className="w-12 h-12 text-muted-foreground/50" />
|
||||
</SkeletonPlaceholder>
|
||||
)}
|
||||
|
||||
{/* Video with fade-in */}
|
||||
<video
|
||||
key={retryCount} // Force remount on retry
|
||||
src={url}
|
||||
className={cn(
|
||||
"w-full h-full",
|
||||
presetStyles.rounded,
|
||||
fadeIn && "transition-opacity duration-300",
|
||||
isLoaded ? "opacity-100" : "opacity-0",
|
||||
className,
|
||||
)}
|
||||
preload="metadata"
|
||||
controls={showControls}
|
||||
onLoadedMetadata={handleLoad}
|
||||
onError={handleError}
|
||||
aria-label={alt || "Video"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { KindRenderer } from "./kinds";
|
||||
import { UserName } from "./UserName";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CompactQuoteSkeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface QuotedEventProps {
|
||||
/** Event ID string for regular events */
|
||||
@@ -65,11 +66,7 @@ export function QuotedEvent({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
Loading event...
|
||||
</span>
|
||||
);
|
||||
return <CompactQuoteSkeleton className={className} />;
|
||||
}
|
||||
|
||||
// For depth 0-1: Show full content inline by default
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MessageCircle } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { getTagValues } from "@/lib/nostr-utils";
|
||||
import { isValidHexEventId } from "@/lib/nostr-validation";
|
||||
import { InlineReplySkeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 9 - Chat Message (NIP-C7)
|
||||
@@ -36,7 +37,12 @@ export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) {
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
{/* Show quoted parent message if this is a reply */}
|
||||
{/* Show quoted message loading state */}
|
||||
{quotedEventId && !parentEvent && (
|
||||
<InlineReplySkeleton icon={<MessageCircle />} />
|
||||
)}
|
||||
|
||||
{/* Show quoted parent message once loaded (only if it's a chat message) */}
|
||||
{quotedEventId && parentEvent && parentEvent.kind === 9 && (
|
||||
<div
|
||||
onClick={handleQuoteClick}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { UserName } from "../UserName";
|
||||
import { Reply } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { InlineReplySkeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1 - Short Text Note
|
||||
@@ -30,6 +31,10 @@ export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) {
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
{/* Show parent message loading state */}
|
||||
{pointer && !parentEvent && <InlineReplySkeleton icon={<Reply />} />}
|
||||
|
||||
{/* Show parent message once loaded */}
|
||||
{pointer && parentEvent && (
|
||||
<div
|
||||
onClick={handleReplyClick}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { useMemo } from "react";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { KindRenderer } from "./index";
|
||||
import { EventCardSkeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 7 - Reactions
|
||||
@@ -133,8 +134,8 @@ export function Kind7Renderer({ event }: BaseEventProps) {
|
||||
|
||||
{/* Loading state */}
|
||||
{reactedEventId && !reactedEvent && (
|
||||
<div className="border border-muted p-2 text-xs text-muted-foreground">
|
||||
Loading referenced event...
|
||||
<div className="border border-muted p-2">
|
||||
<EventCardSkeleton variant="compact" showActions={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
32
src/components/ui/skeleton/CompactQuoteSkeleton.tsx
Normal file
32
src/components/ui/skeleton/CompactQuoteSkeleton.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { Skeleton } from "./Skeleton";
|
||||
|
||||
export type CompactQuoteSkeletonProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const CompactQuoteSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
CompactQuoteSkeletonProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`border-l-2 border-muted pl-3 space-y-2 ${className || ""}`}
|
||||
role="status"
|
||||
aria-label="Loading quoted event..."
|
||||
{...props}
|
||||
>
|
||||
{/* Author name */}
|
||||
<Skeleton variant="text" width="6rem" height={14} />
|
||||
|
||||
{/* Quote Content: 2 lines */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton variant="text" width="100%" height={12} />
|
||||
<Skeleton variant="text" width="80%" height={12} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CompactQuoteSkeleton.displayName = "CompactQuoteSkeleton";
|
||||
|
||||
export { CompactQuoteSkeleton };
|
||||
95
src/components/ui/skeleton/EventCardSkeleton.tsx
Normal file
95
src/components/ui/skeleton/EventCardSkeleton.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Skeleton } from "./Skeleton";
|
||||
|
||||
const eventCardSkeletonVariants = cva("flex flex-col gap-2", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
compact: "",
|
||||
detailed: "",
|
||||
},
|
||||
timeline: {
|
||||
true: "p-3 border-b border-border/50",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
timeline: false,
|
||||
},
|
||||
});
|
||||
|
||||
export interface EventCardSkeletonProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof eventCardSkeletonVariants> {
|
||||
showActions?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const EventCardSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
EventCardSkeletonProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = "default",
|
||||
timeline = false,
|
||||
showActions = true,
|
||||
isLast = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Content line count based on variant
|
||||
const contentLines =
|
||||
variant === "compact" ? 2 : variant === "detailed" ? 6 : 4;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${eventCardSkeletonVariants({ variant, timeline })} ${timeline && isLast ? "border-0" : ""} ${className || ""}`}
|
||||
role="status"
|
||||
aria-label="Loading event..."
|
||||
{...props}
|
||||
>
|
||||
{/* Header: Name + Timestamp (matches BaseEventContainer) */}
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row gap-2 items-baseline">
|
||||
<Skeleton variant="text" width="8rem" height={16} />
|
||||
<Skeleton variant="text" width="4rem" height={12} />
|
||||
</div>
|
||||
{/* Menu placeholder */}
|
||||
<Skeleton variant="rectangle" width={20} height={20} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: contentLines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="text"
|
||||
width={i === contentLines - 1 ? "70%" : "100%"}
|
||||
height={14}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer: Action buttons */}
|
||||
{showActions && (
|
||||
<div className="flex gap-4">
|
||||
<Skeleton variant="text" width="3rem" height={12} />
|
||||
<Skeleton variant="text" width="3rem" height={12} />
|
||||
<Skeleton variant="text" width="3rem" height={12} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
EventCardSkeleton.displayName = "EventCardSkeleton";
|
||||
|
||||
export { EventCardSkeleton, eventCardSkeletonVariants };
|
||||
54
src/components/ui/skeleton/EventDetailSkeleton.tsx
Normal file
54
src/components/ui/skeleton/EventDetailSkeleton.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as React from "react";
|
||||
import { Skeleton } from "./Skeleton";
|
||||
|
||||
export type EventDetailSkeletonProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const EventDetailSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
EventDetailSkeletonProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label="Loading event details..."
|
||||
{...props}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Header: Name + timestamp (matches BaseEventContainer) */}
|
||||
<div className="flex flex-row gap-2 items-baseline">
|
||||
<Skeleton variant="text" width="10rem" height={18} />
|
||||
<Skeleton variant="text" width="6rem" height={14} />
|
||||
</div>
|
||||
|
||||
{/* Event Content: 5 lines */}
|
||||
<div className="space-y-3">
|
||||
<Skeleton variant="text" width="100%" height={16} />
|
||||
<Skeleton variant="text" width="100%" height={16} />
|
||||
<Skeleton variant="text" width="95%" height={16} />
|
||||
<Skeleton variant="text" width="98%" height={16} />
|
||||
<Skeleton variant="text" width="85%" height={16} />
|
||||
</div>
|
||||
|
||||
{/* Metadata Section */}
|
||||
<div className="space-y-2 pt-4 border-t border-border">
|
||||
<Skeleton variant="text" width="8rem" height={12} />
|
||||
<Skeleton variant="text" width="10rem" height={12} />
|
||||
<Skeleton variant="text" width="6rem" height={12} />
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Skeleton variant="rectangle" width="5rem" height={36} />
|
||||
<Skeleton variant="rectangle" width="5rem" height={36} />
|
||||
<Skeleton variant="rectangle" width="5rem" height={36} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EventDetailSkeleton.displayName = "EventDetailSkeleton";
|
||||
|
||||
export { EventDetailSkeleton };
|
||||
40
src/components/ui/skeleton/InlineReplySkeleton.tsx
Normal file
40
src/components/ui/skeleton/InlineReplySkeleton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import { Skeleton } from "./Skeleton";
|
||||
|
||||
export interface InlineReplySkeletonProps {
|
||||
/** Icon to show on the left (Reply, MessageCircle, etc.) */
|
||||
icon: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline skeleton for loading parent message previews
|
||||
* Matches the exact structure and spacing of the reply box
|
||||
*/
|
||||
const InlineReplySkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
InlineReplySkeletonProps
|
||||
>(({ icon, className }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex items-start gap-2 p-1 bg-muted/20 text-xs text-muted-foreground rounded ${className || ""}`}
|
||||
role="status"
|
||||
aria-label="Loading parent message..."
|
||||
>
|
||||
{/* Icon - visible during loading */}
|
||||
{React.isValidElement(icon) ? (
|
||||
<div className="size-3 flex-shrink-0 mt-0.5">{icon}</div>
|
||||
) : null}
|
||||
|
||||
{/* Content placeholder - matches text line-height */}
|
||||
<div className="flex-1 min-w-0 py-0.5">
|
||||
<Skeleton variant="text" width="70%" className="h-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InlineReplySkeleton.displayName = "InlineReplySkeleton";
|
||||
|
||||
export { InlineReplySkeleton };
|
||||
74
src/components/ui/skeleton/ProfileCardSkeleton.tsx
Normal file
74
src/components/ui/skeleton/ProfileCardSkeleton.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Skeleton } from "./Skeleton";
|
||||
|
||||
const profileCardSkeletonVariants = cva("flex flex-col gap-4", {
|
||||
variants: {
|
||||
variant: {
|
||||
full: "",
|
||||
compact: "",
|
||||
inline: "flex-row items-baseline gap-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "full",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ProfileCardSkeletonProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof profileCardSkeletonVariants> {
|
||||
showBio?: boolean;
|
||||
showNip05?: boolean;
|
||||
}
|
||||
|
||||
const ProfileCardSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ProfileCardSkeletonProps
|
||||
>(
|
||||
(
|
||||
{ className, variant = "full", showBio = true, showNip05 = true, ...props },
|
||||
ref,
|
||||
) => {
|
||||
// Name height based on variant (ProfileViewer uses 2xl font)
|
||||
const nameHeight = variant === "full" ? 32 : 16;
|
||||
const nameWidth = variant === "full" ? "16rem" : "8rem";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={profileCardSkeletonVariants({ variant, className })}
|
||||
role="status"
|
||||
aria-label="Loading profile..."
|
||||
{...props}
|
||||
>
|
||||
{/* Name */}
|
||||
<Skeleton variant="text" width={nameWidth} height={nameHeight} />
|
||||
|
||||
{/* NIP-05 (optional, only for full variant) */}
|
||||
{showNip05 && variant === "full" && (
|
||||
<Skeleton variant="text" width="12rem" height={12} />
|
||||
)}
|
||||
|
||||
{/* Bio (only for full and compact variants) */}
|
||||
{showBio && variant !== "inline" && (
|
||||
<div className="space-y-2">
|
||||
{variant === "full" && (
|
||||
<Skeleton variant="text" width="3rem" height={10} />
|
||||
)}
|
||||
<Skeleton variant="text" width="100%" height={14} />
|
||||
<Skeleton variant="text" width="95%" height={14} />
|
||||
{variant === "full" && (
|
||||
<Skeleton variant="text" width="85%" height={14} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ProfileCardSkeleton.displayName = "ProfileCardSkeleton";
|
||||
|
||||
export { ProfileCardSkeleton, profileCardSkeletonVariants };
|
||||
53
src/components/ui/skeleton/Skeleton.tsx
Normal file
53
src/components/ui/skeleton/Skeleton.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
const skeletonVariants = cva("animate-skeleton-pulse bg-muted rounded", {
|
||||
variants: {
|
||||
variant: {
|
||||
circle: "rounded-full",
|
||||
rectangle: "rounded",
|
||||
text: "rounded h-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "rectangle",
|
||||
},
|
||||
});
|
||||
|
||||
export interface SkeletonProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof skeletonVariants> {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
|
||||
({ className, variant, width, height, style, ...props }, ref) => {
|
||||
const inlineStyle: React.CSSProperties = {
|
||||
...style,
|
||||
...(width !== undefined && {
|
||||
width: typeof width === "number" ? `${width}px` : width,
|
||||
}),
|
||||
...(height !== undefined && {
|
||||
height: typeof height === "number" ? `${height}px` : height,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={skeletonVariants({ variant, className })}
|
||||
style={inlineStyle}
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="Loading..."
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Skeleton.displayName = "Skeleton";
|
||||
|
||||
export { Skeleton, skeletonVariants };
|
||||
53
src/components/ui/skeleton/TimelineSkeleton.tsx
Normal file
53
src/components/ui/skeleton/TimelineSkeleton.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
EventCardSkeleton,
|
||||
type EventCardSkeletonProps,
|
||||
} from "./EventCardSkeleton";
|
||||
|
||||
export interface TimelineSkeletonProps extends Omit<
|
||||
EventCardSkeletonProps,
|
||||
"className" | "isLast" | "timeline"
|
||||
> {
|
||||
count?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TimelineSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
TimelineSkeletonProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
count = 3,
|
||||
variant = "compact",
|
||||
showActions = false,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label="Loading timeline..."
|
||||
>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<EventCardSkeleton
|
||||
key={i}
|
||||
variant={variant}
|
||||
showActions={showActions}
|
||||
timeline={true}
|
||||
isLast={i === count - 1}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TimelineSkeleton.displayName = "TimelineSkeleton";
|
||||
|
||||
export { TimelineSkeleton };
|
||||
26
src/components/ui/skeleton/index.ts
Normal file
26
src/components/ui/skeleton/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export { Skeleton, skeletonVariants } from "./Skeleton";
|
||||
export type { SkeletonProps } from "./Skeleton";
|
||||
|
||||
export {
|
||||
ProfileCardSkeleton,
|
||||
profileCardSkeletonVariants,
|
||||
} from "./ProfileCardSkeleton";
|
||||
export type { ProfileCardSkeletonProps } from "./ProfileCardSkeleton";
|
||||
|
||||
export {
|
||||
EventCardSkeleton,
|
||||
eventCardSkeletonVariants,
|
||||
} from "./EventCardSkeleton";
|
||||
export type { EventCardSkeletonProps } from "./EventCardSkeleton";
|
||||
|
||||
export { TimelineSkeleton } from "./TimelineSkeleton";
|
||||
export type { TimelineSkeletonProps } from "./TimelineSkeleton";
|
||||
|
||||
export { EventDetailSkeleton } from "./EventDetailSkeleton";
|
||||
export type { EventDetailSkeletonProps } from "./EventDetailSkeleton";
|
||||
|
||||
export { CompactQuoteSkeleton } from "./CompactQuoteSkeleton";
|
||||
export type { CompactQuoteSkeletonProps } from "./CompactQuoteSkeleton";
|
||||
|
||||
export { InlineReplySkeleton } from "./InlineReplySkeleton";
|
||||
export type { InlineReplySkeletonProps } from "./InlineReplySkeleton";
|
||||
82
src/lib/relay-status-utils.tsx
Normal file
82
src/lib/relay-status-utils.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Loader2,
|
||||
XCircle,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldX,
|
||||
ShieldQuestion,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import type { RelayState } from "@/types/relay-state";
|
||||
|
||||
/**
|
||||
* Get connection icon and label for a relay state
|
||||
*/
|
||||
export function getConnectionIcon(relay: RelayState | undefined) {
|
||||
if (!relay) {
|
||||
return {
|
||||
icon: <WifiOff className="size-3 text-muted-foreground" />,
|
||||
label: "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
connected: {
|
||||
icon: <Wifi className="size-3 text-green-500" />,
|
||||
label: "Connected",
|
||||
},
|
||||
connecting: {
|
||||
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
|
||||
label: "Connecting",
|
||||
},
|
||||
disconnected: {
|
||||
icon: <WifiOff className="size-3 text-muted-foreground" />,
|
||||
label: "Disconnected",
|
||||
},
|
||||
error: {
|
||||
icon: <XCircle className="size-3 text-red-500" />,
|
||||
label: "Connection Error",
|
||||
},
|
||||
};
|
||||
return iconMap[relay.connectionState];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication icon and label for a relay state
|
||||
* Returns null if no authentication is required
|
||||
*/
|
||||
export function getAuthIcon(relay: RelayState | undefined) {
|
||||
if (!relay || relay.authStatus === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
authenticated: {
|
||||
icon: <ShieldCheck className="size-3 text-green-500" />,
|
||||
label: "Authenticated",
|
||||
},
|
||||
challenge_received: {
|
||||
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
|
||||
label: "Challenge Received",
|
||||
},
|
||||
authenticating: {
|
||||
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
|
||||
label: "Authenticating",
|
||||
},
|
||||
failed: {
|
||||
icon: <ShieldX className="size-3 text-red-500" />,
|
||||
label: "Authentication Failed",
|
||||
},
|
||||
rejected: {
|
||||
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
|
||||
label: "Authentication Rejected",
|
||||
},
|
||||
none: {
|
||||
icon: <Shield className="size-3 text-muted-foreground" />,
|
||||
label: "No Authentication",
|
||||
},
|
||||
};
|
||||
return iconMap[relay.authStatus] || iconMap.none;
|
||||
}
|
||||
@@ -23,11 +23,16 @@ export default {
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' }
|
||||
},
|
||||
'skeleton-pulse': {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.5' }
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out'
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'skeleton-pulse': 'skeleton-pulse 1.5s ease-in-out infinite'
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
|
||||
Reference in New Issue
Block a user