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,
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))}
+11 -19
View File
@@ -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"
+39 -1
View File
@@ -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"
+14
View File
@@ -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;