dev #2
@@ -12,7 +12,7 @@ import {
|
||||
getPublicKey,
|
||||
signEvent,
|
||||
publishEvent,
|
||||
shortenPubkey,
|
||||
shortenNpub,
|
||||
fetchNostrProfile,
|
||||
fetchLongformFromRelays,
|
||||
fetchEventFromRelays,
|
||||
@@ -23,6 +23,7 @@ import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { markdownComponents } from "./markdownComponents";
|
||||
import { remarkNostr } from "./remarkNostr";
|
||||
import { NostrAuthor } from "./NostrEmbeds";
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
@@ -30,6 +31,7 @@ interface Post {
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt?: string;
|
||||
image?: string;
|
||||
authorName?: string;
|
||||
authorPubkey?: string;
|
||||
publishedAt?: string;
|
||||
@@ -59,6 +61,7 @@ function postFromEvent(event: any, slug: string): Post {
|
||||
title: tag("title") || "Untitled",
|
||||
content: event.content || "",
|
||||
excerpt: tag("summary"),
|
||||
image: tag("image"),
|
||||
authorPubkey: event.pubkey,
|
||||
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
||||
nostrEventId: event.id,
|
||||
@@ -117,6 +120,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
||||
const [liveContent, setLiveContent] = useState<string | null>(null);
|
||||
const [liveImage, setLiveImage] = useState<string | null>(null);
|
||||
const [loadingContent, setLoadingContent] = useState(false);
|
||||
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
|
||||
const [contentError, setContentError] = useState(false);
|
||||
@@ -134,6 +138,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLiveContent(null);
|
||||
setLiveImage(null);
|
||||
setPost(null);
|
||||
setAuthorProfile(null);
|
||||
setContentError(false);
|
||||
@@ -159,8 +164,14 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
fetchPromise
|
||||
.then((event) => {
|
||||
if (cancelled) return;
|
||||
if (event?.content) setLiveContent(event.content);
|
||||
else setContentError(true); // not found / timed out
|
||||
if (event?.content) {
|
||||
setLiveContent(event.content);
|
||||
// Pull the longform header image (`image` tag) for display.
|
||||
const img = event.tags?.find((t: string[]) => t[0] === "image")?.[1];
|
||||
if (img) setLiveImage(img);
|
||||
} else {
|
||||
setContentError(true); // not found / timed out
|
||||
}
|
||||
})
|
||||
.catch(() => !cancelled && setContentError(true))
|
||||
.finally(() => !cancelled && setLoadingContent(false));
|
||||
@@ -258,6 +269,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
}, [comment, post, hasNostr]);
|
||||
|
||||
const categories = post?.categories?.map((c) => c.category) || [];
|
||||
const headerImage = post?.image || liveImage;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -321,7 +333,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium text-on-surface-variant">
|
||||
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
|
||||
{authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -338,6 +350,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{headerImage && (
|
||||
<img
|
||||
src={headerImage}
|
||||
alt={post.title}
|
||||
className="w-full rounded-xl mb-12 object-cover max-h-[28rem]"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<article className="mb-16">
|
||||
{loadingContent ? (
|
||||
<RelayLoading />
|
||||
@@ -424,9 +447,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
className="bg-surface-container-high rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 mb-2">
|
||||
<span className="font-semibold text-xs font-mono text-on-surface-variant/70">
|
||||
{shortenPubkey(r.pubkey)}
|
||||
</span>
|
||||
<NostrAuthor pubkey={r.pubkey} />
|
||||
<span className="text-on-surface-variant/30">·</span>
|
||||
<span className="text-xs text-on-surface-variant/50">
|
||||
{formatDate(new Date(r.created_at * 1000))}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { nip19 } from "nostr-tools";
|
||||
import {
|
||||
loadNostrProfile,
|
||||
resolveEventFromRelays,
|
||||
shortenPubkey,
|
||||
shortenNpub,
|
||||
type NostrProfile,
|
||||
} from "@/lib/nostr";
|
||||
|
||||
@@ -39,11 +39,8 @@ export function NostrMention({ bech32 }: { bech32: string }) {
|
||||
}, [pubkey]);
|
||||
|
||||
// Show the username; fall back to a shortened npub only when no profile.
|
||||
const npub = useMemo(() => {
|
||||
if (bech32.startsWith("npub")) return bech32;
|
||||
return pubkey ? nip19.npubEncode(pubkey) : bech32;
|
||||
}, [bech32, pubkey]);
|
||||
const name = profile?.name || profile?.displayName || shortenPubkey(npub);
|
||||
const name =
|
||||
profile?.name || profile?.displayName || shortenNpub(pubkey || bech32);
|
||||
|
||||
return (
|
||||
<a
|
||||
@@ -67,8 +64,10 @@ export function NostrMention({ bech32 }: { bech32: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Compact author line used inside an embedded note.
|
||||
function NoteAuthor({ pubkey }: { pubkey: string }) {
|
||||
// Reusable author line (avatar + username) resolved from the author's kind:0
|
||||
// profile. Falls back to a shortened npub — never the raw hex pubkey. Shared by
|
||||
// embedded notes and the blog comment list.
|
||||
export function NostrAuthor({ pubkey }: { pubkey: string }) {
|
||||
const [profile, setProfile] = useState<NostrProfile | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -79,21 +78,14 @@ function NoteAuthor({ pubkey }: { pubkey: string }) {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pubkey]);
|
||||
const fallback = useMemo(() => {
|
||||
try {
|
||||
return shortenPubkey(nip19.npubEncode(pubkey));
|
||||
} catch {
|
||||
return shortenPubkey(pubkey);
|
||||
}
|
||||
}, [pubkey]);
|
||||
const name = profile?.name || profile?.displayName || fallback;
|
||||
const name = profile?.name || profile?.displayName || shortenNpub(pubkey);
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{profile?.picture && (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt=""
|
||||
className="w-6 h-6 rounded-full object-cover bg-surface-container-high"
|
||||
className="w-6 h-6 rounded-full object-cover bg-surface-container-high shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
@@ -151,7 +143,7 @@ export function NostrNote({ bech32 }: { bech32: string }) {
|
||||
{state === "done" && event && (
|
||||
<span className="block">
|
||||
<span className="flex items-center justify-between mb-3">
|
||||
<NoteAuthor pubkey={event.pubkey} />
|
||||
<NostrAuthor pubkey={event.pubkey} />
|
||||
<a
|
||||
href={njump(bech32)}
|
||||
target="_blank"
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MapPin, Clock, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
||||
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
|
||||
|
||||
// Initial number of meetups shown before "Load more". Mobile is enforced via
|
||||
// CSS (3) so there's no SSR/viewport mismatch; desktop shows up to 6.
|
||||
const INITIAL_MOBILE = 3;
|
||||
const INITIAL_DESKTOP = 6;
|
||||
|
||||
interface MeetupData {
|
||||
id?: string;
|
||||
title: string;
|
||||
@@ -19,6 +27,26 @@ interface MeetupsSectionProps {
|
||||
}
|
||||
|
||||
export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
||||
// Number of extra batches revealed via "Load more". Each batch adds 3 on
|
||||
// mobile and 6 on desktop, so visible counts are 3*(pages+1) / 6*(pages+1).
|
||||
const [pages, setPages] = useState(0);
|
||||
|
||||
const mobileVisible = INITIAL_MOBILE * (pages + 1);
|
||||
const desktopVisible = INITIAL_DESKTOP * (pages + 1);
|
||||
|
||||
// Visibility per card. Enforced with CSS so the server-rendered markup matches
|
||||
// both viewports (desktopVisible >= mobileVisible, so "mobile-only" never occurs).
|
||||
const cardVisibilityClass = (i: number) => {
|
||||
if (i < mobileVisible) return "flex";
|
||||
if (i < desktopVisible) return "hidden md:flex";
|
||||
return "hidden";
|
||||
};
|
||||
|
||||
// Show the button only when content is still hidden at the given breakpoint.
|
||||
let loadMoreClass = "hidden";
|
||||
if (meetups.length > desktopVisible) loadMoreClass = "flex";
|
||||
else if (meetups.length > mobileVisible) loadMoreClass = "flex md:hidden";
|
||||
|
||||
return (
|
||||
<section className="py-24 px-8 border-t border-zinc-800/50">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
@@ -59,7 +87,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
||||
<Link
|
||||
key={meetup.id ?? i}
|
||||
href={href}
|
||||
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
|
||||
className={`group ${cardVisibilityClass(i)} flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200`}
|
||||
>
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="bg-zinc-800 rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px]">
|
||||
@@ -113,6 +141,16 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${loadMoreClass} justify-center mt-10`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPages((p) => p + 1)}
|
||||
className="flex items-center gap-2 rounded-lg border border-zinc-700 px-6 py-2.5 text-sm font-semibold text-on-surface hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden flex flex-col items-center gap-3 mt-8">
|
||||
<Link
|
||||
href="/events"
|
||||
|
||||
@@ -82,6 +82,20 @@ export function shortenPubkey(pubkey: string): string {
|
||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
|
||||
}
|
||||
|
||||
// Shortened npub for display when no profile is available. Accepts a hex pubkey
|
||||
// or an existing npub/nprofile and always renders bech32 (never raw hex).
|
||||
export function shortenNpub(pubkeyOrBech32: string): string {
|
||||
if (!pubkeyOrBech32) return "";
|
||||
try {
|
||||
const npub = pubkeyOrBech32.startsWith("npub")
|
||||
? pubkeyOrBech32
|
||||
: nip19.npubEncode(toHexPubkey(pubkeyOrBech32) || pubkeyOrBech32);
|
||||
return shortenPubkey(npub);
|
||||
} catch {
|
||||
return shortenPubkey(pubkeyOrBech32);
|
||||
}
|
||||
}
|
||||
|
||||
export interface NostrProfile {
|
||||
name?: string;
|
||||
picture?: string;
|
||||
|
||||
Reference in New Issue
Block a user