Merge branch 'main' of gitlab.com:soapbox-pub/ditto
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
import type { NostrEvent } from "@nostrify/nostrify";
|
||||
import { ExternalLink, FileText, Globe } from "lucide-react";
|
||||
import { ExternalLink, FileText, Globe, Server } from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { ExternalFavicon } from "@/components/ExternalFavicon";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLinkPreview } from "@/hooks/useLinkPreview";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NsiteCardProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */
|
||||
function hexToBase36(hex: string): string {
|
||||
// Process the hex string in chunks to build a BigInt
|
||||
let n = 0n;
|
||||
for (let i = 0; i < hex.length; i++) {
|
||||
n = n * 16n + BigInt(parseInt(hex[i], 16));
|
||||
@@ -22,17 +26,15 @@ function getNsiteUrl(event: NostrEvent): string {
|
||||
const dTag = event.tags.find(([n]) => n === "d")?.[1];
|
||||
|
||||
if (event.kind === 35128 && dTag) {
|
||||
// Named site: <pubkeyB36><dTag>.nsite.lol
|
||||
const pubkeyB36 = hexToBase36(event.pubkey);
|
||||
return `https://${pubkeyB36}${dTag}.nsite.lol`;
|
||||
}
|
||||
|
||||
// Root site (kind 15128): <npub>.nsite.lol
|
||||
const npub = nip19.npubEncode(event.pubkey);
|
||||
return `https://${npub}.nsite.lol`;
|
||||
}
|
||||
|
||||
/** Renders an nsite deployment card for kind 15128 (root site) or 35128 (named site). */
|
||||
/** Renders an nsite deployment card with a rich link preview. */
|
||||
export function NsiteCard({ event }: NsiteCardProps) {
|
||||
const title = event.tags.find(([n]) => n === "title")?.[1];
|
||||
const description = event.tags.find(([n]) => n === "description")?.[1];
|
||||
@@ -45,29 +47,58 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
const siteUrl = getNsiteUrl(event);
|
||||
const displayName = title || (isNamed ? dTag : "Root Site");
|
||||
|
||||
const { data: preview, isLoading } = useLinkPreview(siteUrl);
|
||||
const image = preview?.thumbnail_url;
|
||||
const previewTitle = preview?.title;
|
||||
|
||||
if (isLoading) {
|
||||
return <NsiteCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-2xl border border-border overflow-hidden">
|
||||
<div className="px-3.5 py-3 space-y-2">
|
||||
{/* Site name + type badge */}
|
||||
<a
|
||||
href={siteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"group block mt-2 rounded-2xl border border-border overflow-hidden",
|
||||
"hover:bg-secondary/40 transition-colors",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Link preview thumbnail */}
|
||||
{image && (
|
||||
<div className="w-full overflow-hidden bg-muted">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full h-[180px] object-cover transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3.5 py-2.5 space-y-1.5">
|
||||
{/* Title with favicon */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Globe className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-semibold text-sm truncate leading-snug">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-border px-2 py-0 text-[11px] text-muted-foreground leading-relaxed">
|
||||
{isNamed ? "Named Site" : "Root Site"}
|
||||
</span>
|
||||
<ExternalFavicon url={siteUrl} size={16} className="shrink-0" />
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">
|
||||
{previewTitle || displayName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-[13px] text-muted-foreground line-clamp-2 leading-relaxed">
|
||||
{description}
|
||||
{/* Description — prefer event description (it's curated), fall back to OEmbed author */}
|
||||
{(description || preview?.author_name) && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{description || preview?.author_name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* File count + server info */}
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||
{/* Deployment stats + source link */}
|
||||
<div className="flex items-center gap-3 pt-0.5 text-[11px] text-muted-foreground">
|
||||
{pathTags.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FileText className="size-3" />
|
||||
@@ -75,40 +106,47 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
</span>
|
||||
)}
|
||||
{serverTags.length > 0 && (
|
||||
<span>
|
||||
{serverTags.length}{" "}
|
||||
{serverTags.length === 1 ? "server" : "servers"}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Server className="size-3" />
|
||||
{serverTags.length} {serverTags.length === 1 ? "server" : "servers"}
|
||||
</span>
|
||||
)}
|
||||
{sourceUrl && (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded-full",
|
||||
"hover:bg-primary/10 hover:text-primary transition-colors",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Globe className="size-3" />
|
||||
<span>Source</span>
|
||||
</a>
|
||||
)}
|
||||
{!sourceUrl && (
|
||||
<span className="ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded-full hover:bg-primary/10 hover:text-primary transition-colors">
|
||||
<ExternalLink className="size-3" />
|
||||
<span>Visit</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-2 pt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(siteUrl, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
View Site
|
||||
</button>
|
||||
{sourceUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary/60"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(sourceUrl, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<Globe className="size-3" />
|
||||
Source
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
function NsiteCardSkeleton() {
|
||||
return (
|
||||
<div className="mt-2 rounded-2xl border border-border overflow-hidden">
|
||||
<Skeleton className="w-full h-[180px] rounded-none" />
|
||||
<div className="px-3.5 py-2.5 space-y-1.5">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user