Merge branch 'main' of gitlab.com:soapbox-pub/ditto

This commit is contained in:
Alex Gleason
2026-03-24 22:24:37 -05:00
+89 -51
View File
@@ -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>
);