dev #2

Merged
Michilis merged 2 commits from dev into main 2026-07-18 17:17:22 +00:00
4 changed files with 92 additions and 27 deletions
Showing only changes of commit 495289232b - Show all commits
+28 -7
View File
@@ -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))}
+11 -19
View File
@@ -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"
+39 -1
View File
@@ -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"
+14
View File
@@ -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;