dev #2
@@ -12,7 +12,7 @@ import {
|
|||||||
getPublicKey,
|
getPublicKey,
|
||||||
signEvent,
|
signEvent,
|
||||||
publishEvent,
|
publishEvent,
|
||||||
shortenPubkey,
|
shortenNpub,
|
||||||
fetchNostrProfile,
|
fetchNostrProfile,
|
||||||
fetchLongformFromRelays,
|
fetchLongformFromRelays,
|
||||||
fetchEventFromRelays,
|
fetchEventFromRelays,
|
||||||
@@ -23,6 +23,7 @@ import { Navbar } from "@/components/public/Navbar";
|
|||||||
import { Footer } from "@/components/public/Footer";
|
import { Footer } from "@/components/public/Footer";
|
||||||
import { markdownComponents } from "./markdownComponents";
|
import { markdownComponents } from "./markdownComponents";
|
||||||
import { remarkNostr } from "./remarkNostr";
|
import { remarkNostr } from "./remarkNostr";
|
||||||
|
import { NostrAuthor } from "./NostrEmbeds";
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,6 +31,7 @@ interface Post {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
|
image?: string;
|
||||||
authorName?: string;
|
authorName?: string;
|
||||||
authorPubkey?: string;
|
authorPubkey?: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
@@ -59,6 +61,7 @@ function postFromEvent(event: any, slug: string): Post {
|
|||||||
title: tag("title") || "Untitled",
|
title: tag("title") || "Untitled",
|
||||||
content: event.content || "",
|
content: event.content || "",
|
||||||
excerpt: tag("summary"),
|
excerpt: tag("summary"),
|
||||||
|
image: tag("image"),
|
||||||
authorPubkey: event.pubkey,
|
authorPubkey: event.pubkey,
|
||||||
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
||||||
nostrEventId: event.id,
|
nostrEventId: event.id,
|
||||||
@@ -117,6 +120,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
||||||
const [liveContent, setLiveContent] = useState<string | null>(null);
|
const [liveContent, setLiveContent] = useState<string | null>(null);
|
||||||
|
const [liveImage, setLiveImage] = useState<string | null>(null);
|
||||||
const [loadingContent, setLoadingContent] = useState(false);
|
const [loadingContent, setLoadingContent] = useState(false);
|
||||||
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
|
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
|
||||||
const [contentError, setContentError] = useState(false);
|
const [contentError, setContentError] = useState(false);
|
||||||
@@ -134,6 +138,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLiveContent(null);
|
setLiveContent(null);
|
||||||
|
setLiveImage(null);
|
||||||
setPost(null);
|
setPost(null);
|
||||||
setAuthorProfile(null);
|
setAuthorProfile(null);
|
||||||
setContentError(false);
|
setContentError(false);
|
||||||
@@ -159,8 +164,14 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
fetchPromise
|
fetchPromise
|
||||||
.then((event) => {
|
.then((event) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (event?.content) setLiveContent(event.content);
|
if (event?.content) {
|
||||||
else setContentError(true); // not found / timed out
|
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))
|
.catch(() => !cancelled && setContentError(true))
|
||||||
.finally(() => !cancelled && setLoadingContent(false));
|
.finally(() => !cancelled && setLoadingContent(false));
|
||||||
@@ -258,6 +269,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
}, [comment, post, hasNostr]);
|
}, [comment, post, hasNostr]);
|
||||||
|
|
||||||
const categories = post?.categories?.map((c) => c.category) || [];
|
const categories = post?.categories?.map((c) => c.category) || [];
|
||||||
|
const headerImage = post?.image || liveImage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -321,7 +333,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="font-medium text-on-surface-variant">
|
<span className="font-medium text-on-surface-variant">
|
||||||
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
|
{authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -338,6 +350,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<article className="mb-16">
|
||||||
{loadingContent ? (
|
{loadingContent ? (
|
||||||
<RelayLoading />
|
<RelayLoading />
|
||||||
@@ -424,9 +447,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
className="bg-surface-container-high rounded-lg p-4"
|
className="bg-surface-container-high rounded-lg p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 mb-2">
|
<div className="flex items-center gap-2.5 mb-2">
|
||||||
<span className="font-semibold text-xs font-mono text-on-surface-variant/70">
|
<NostrAuthor pubkey={r.pubkey} />
|
||||||
{shortenPubkey(r.pubkey)}
|
|
||||||
</span>
|
|
||||||
<span className="text-on-surface-variant/30">·</span>
|
<span className="text-on-surface-variant/30">·</span>
|
||||||
<span className="text-xs text-on-surface-variant/50">
|
<span className="text-xs text-on-surface-variant/50">
|
||||||
{formatDate(new Date(r.created_at * 1000))}
|
{formatDate(new Date(r.created_at * 1000))}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { nip19 } from "nostr-tools";
|
|||||||
import {
|
import {
|
||||||
loadNostrProfile,
|
loadNostrProfile,
|
||||||
resolveEventFromRelays,
|
resolveEventFromRelays,
|
||||||
shortenPubkey,
|
shortenNpub,
|
||||||
type NostrProfile,
|
type NostrProfile,
|
||||||
} from "@/lib/nostr";
|
} from "@/lib/nostr";
|
||||||
|
|
||||||
@@ -39,11 +39,8 @@ export function NostrMention({ bech32 }: { bech32: string }) {
|
|||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
// Show the username; fall back to a shortened npub only when no profile.
|
// Show the username; fall back to a shortened npub only when no profile.
|
||||||
const npub = useMemo(() => {
|
const name =
|
||||||
if (bech32.startsWith("npub")) return bech32;
|
profile?.name || profile?.displayName || shortenNpub(pubkey || bech32);
|
||||||
return pubkey ? nip19.npubEncode(pubkey) : bech32;
|
|
||||||
}, [bech32, pubkey]);
|
|
||||||
const name = profile?.name || profile?.displayName || shortenPubkey(npub);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@@ -67,8 +64,10 @@ export function NostrMention({ bech32 }: { bech32: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact author line used inside an embedded note.
|
// Reusable author line (avatar + username) resolved from the author's kind:0
|
||||||
function NoteAuthor({ pubkey }: { pubkey: string }) {
|
// 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);
|
const [profile, setProfile] = useState<NostrProfile | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -79,21 +78,14 @@ function NoteAuthor({ pubkey }: { pubkey: string }) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
const fallback = useMemo(() => {
|
const name = profile?.name || profile?.displayName || shortenNpub(pubkey);
|
||||||
try {
|
|
||||||
return shortenPubkey(nip19.npubEncode(pubkey));
|
|
||||||
} catch {
|
|
||||||
return shortenPubkey(pubkey);
|
|
||||||
}
|
|
||||||
}, [pubkey]);
|
|
||||||
const name = profile?.name || profile?.displayName || fallback;
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{profile?.picture && (
|
{profile?.picture && (
|
||||||
<img
|
<img
|
||||||
src={profile.picture}
|
src={profile.picture}
|
||||||
alt=""
|
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) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
}}
|
}}
|
||||||
@@ -151,7 +143,7 @@ export function NostrNote({ bech32 }: { bech32: string }) {
|
|||||||
{state === "done" && event && (
|
{state === "done" && event && (
|
||||||
<span className="block">
|
<span className="block">
|
||||||
<span className="flex items-center justify-between mb-3">
|
<span className="flex items-center justify-between mb-3">
|
||||||
<NoteAuthor pubkey={event.pubkey} />
|
<NostrAuthor pubkey={event.pubkey} />
|
||||||
<a
|
<a
|
||||||
href={njump(bech32)}
|
href={njump(bech32)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { MapPin, Clock, ArrowRight } from "lucide-react";
|
import { MapPin, Clock, ArrowRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
||||||
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
|
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 {
|
interface MeetupData {
|
||||||
id?: string;
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -19,6 +27,26 @@ interface MeetupsSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MeetupsSection({ meetups }: 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 (
|
return (
|
||||||
<section className="py-24 px-8 border-t border-zinc-800/50">
|
<section className="py-24 px-8 border-t border-zinc-800/50">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
@@ -59,7 +87,7 @@ export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
|||||||
<Link
|
<Link
|
||||||
key={meetup.id ?? i}
|
key={meetup.id ?? i}
|
||||||
href={href}
|
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="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]">
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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">
|
<div className="md:hidden flex flex-col items-center gap-3 mt-8">
|
||||||
<Link
|
<Link
|
||||||
href="/events"
|
href="/events"
|
||||||
|
|||||||
@@ -82,6 +82,20 @@ export function shortenPubkey(pubkey: string): string {
|
|||||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
|
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 {
|
export interface NostrProfile {
|
||||||
name?: string;
|
name?: string;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user