feat: add preview routes for Nostr identifiers (npub, nevent, note, naddr)

This commit adds dedicated preview routes for Nostr identifiers at the root level:
- /npub... - Shows a single profile view for npub identifiers
- /nevent... - Shows a single event detail view for nevent identifiers
- /note... - Shows a single event detail view for note identifiers
- /naddr... - Redirects spellbooks (kind 30777) to /:actor/:identifier route

Key changes:
- Created PreviewProfilePage component for npub identifiers
- Created PreviewEventPage component for nevent/note identifiers
- Created PreviewAddressPage component for naddr redirects
- Added hideBottomBar prop to AppShell to hide tabs in preview mode
- Added routes to root.tsx for all identifier types

Preview pages don't show bottom tabs and don't affect user's workspace layout.
This commit is contained in:
Claude
2026-01-04 17:50:50 +00:00
parent a4eff14620
commit 3e9c4f3238
5 changed files with 298 additions and 2 deletions

View File

@@ -13,9 +13,10 @@ import { AppShellContext } from "./AppShellContext";
interface AppShellProps {
children: ReactNode;
hideBottomBar?: boolean;
}
export function AppShell({ children }: AppShellProps) {
export function AppShell({ children, hideBottomBar = false }: AppShellProps) {
const [commandLauncherOpen, setCommandLauncherOpen] = useState(false);
// Sync active account and fetch relay lists
@@ -76,7 +77,7 @@ export function AppShell({ children }: AppShellProps) {
<section className="flex-1 relative overflow-hidden">
{children}
</section>
<TabBar />
{!hideBottomBar && <TabBar />}
</main>
</AppShellContext.Provider>
);

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router";
import { nip19 } from "nostr-tools";
import type { AddressPointer } from "nostr-tools/nip19";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
/**
* PreviewAddressPage - Redirect naddr identifiers to appropriate routes
* Route: /naddr...
* For spellbooks (kind 30777), redirects to /:actor/:identifier
* For other kinds, shows error (could be extended to handle other addressable events)
*/
export default function PreviewAddressPage() {
const { identifier } = useParams<{ identifier: string }>();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!identifier) {
setError("No identifier provided");
return;
}
// Reconstruct the full identifier
const fullIdentifier = `naddr${identifier}`;
try {
const decoded = nip19.decode(fullIdentifier);
if (decoded.type !== "naddr") {
setError(`Invalid identifier type: expected naddr, got ${decoded.type}`);
toast.error("Invalid naddr identifier");
return;
}
const pointer = decoded.data as AddressPointer;
// Check if it's a spellbook (kind 30777)
if (pointer.kind === 30777) {
// Redirect to the spellbook route
const npub = nip19.npubEncode(pointer.pubkey);
navigate(`/${npub}/${pointer.identifier}`, { replace: true });
} else {
// For other kinds, we could extend this to handle them differently
// For now, show an error
setError(
`Addressable events of kind ${pointer.kind} are not yet supported in preview mode`
);
toast.error("Unsupported event kind");
}
} catch (e) {
console.error("Failed to decode naddr:", e);
setError(e instanceof Error ? e.message : "Failed to decode identifier");
toast.error("Invalid naddr identifier");
}
}, [identifier, navigate]);
// Loading/error state
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<div className="text-destructive text-sm bg-destructive/10 px-4 py-2 rounded-md">
{error}
</div>
<button
onClick={() => navigate("/")}
className="text-sm text-muted-foreground hover:text-foreground underline"
>
Return to dashboard
</button>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground">
<Loader2 className="size-8 animate-spin text-primary/50" />
<div className="flex flex-col items-center gap-1">
<p className="font-medium text-foreground">Redirecting...</p>
<p className="text-xs">Processing address pointer</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router";
import { nip19 } from "nostr-tools";
import type { EventPointer } from "nostr-tools/nip19";
import { EventDetailViewer } from "../EventDetailViewer";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
/**
* PreviewEventPage - Preview a Nostr event from a nevent or note identifier
* Routes: /nevent..., /note...
* This page shows a single event view without affecting user's workspace layout
*/
export default function PreviewEventPage() {
const { identifier } = useParams<{ identifier: string }>();
const navigate = useNavigate();
const [pointer, setPointer] = useState<EventPointer | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!identifier) {
setError("No identifier provided");
return;
}
// Determine the prefix based on the current path
const path = window.location.pathname;
let fullIdentifier: string;
if (path.startsWith("/nevent")) {
fullIdentifier = `nevent${identifier}`;
} else if (path.startsWith("/note")) {
fullIdentifier = `note${identifier}`;
} else {
setError("Invalid route");
return;
}
try {
const decoded = nip19.decode(fullIdentifier);
if (decoded.type === "nevent") {
setPointer(decoded.data);
} else if (decoded.type === "note") {
// note is just an event ID, convert to EventPointer
setPointer({
id: decoded.data,
});
} else {
setError(`Invalid identifier type: expected nevent or note, got ${decoded.type}`);
toast.error("Invalid event identifier");
return;
}
} catch (e) {
console.error("Failed to decode event identifier:", e);
setError(e instanceof Error ? e.message : "Failed to decode identifier");
toast.error("Invalid event identifier");
}
}, [identifier]);
// Loading state
if (!pointer && !error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground">
<Loader2 className="size-8 animate-spin text-primary/50" />
<div className="flex flex-col items-center gap-1">
<p className="font-medium text-foreground">Loading Event...</p>
<p className="text-xs">Decoding identifier</p>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<div className="text-destructive text-sm bg-destructive/10 px-4 py-2 rounded-md">
{error}
</div>
<button
onClick={() => navigate("/")}
className="text-sm text-muted-foreground hover:text-foreground underline"
>
Return to dashboard
</button>
</div>
);
}
return (
<div className="h-full overflow-auto">
<EventDetailViewer pointer={pointer!} />
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router";
import { nip19 } from "nostr-tools";
import { ProfileViewer } from "../ProfileViewer";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
/**
* PreviewProfilePage - Preview a Nostr profile from an npub identifier
* Route: /npub...
* This page shows a single profile view without affecting user's workspace layout
*/
export default function PreviewProfilePage() {
const { identifier } = useParams<{ identifier: string }>();
const navigate = useNavigate();
const [pubkey, setPubkey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!identifier) {
setError("No identifier provided");
return;
}
// Reconstruct the full identifier (react-router splits on /)
const fullIdentifier = `npub${identifier}`;
try {
const decoded = nip19.decode(fullIdentifier);
if (decoded.type !== "npub") {
setError(`Invalid identifier type: expected npub, got ${decoded.type}`);
toast.error("Invalid npub identifier");
return;
}
setPubkey(decoded.data);
} catch (e) {
console.error("Failed to decode npub:", e);
setError(e instanceof Error ? e.message : "Failed to decode identifier");
toast.error("Invalid npub identifier");
}
}, [identifier]);
// Loading state
if (!pubkey && !error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground">
<Loader2 className="size-8 animate-spin text-primary/50" />
<div className="flex flex-col items-center gap-1">
<p className="font-medium text-foreground">Loading Profile...</p>
<p className="text-xs">Decoding identifier</p>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<div className="text-destructive text-sm bg-destructive/10 px-4 py-2 rounded-md">
{error}
</div>
<button
onClick={() => navigate("/")}
className="text-sm text-muted-foreground hover:text-foreground underline"
>
Return to dashboard
</button>
</div>
);
}
return (
<div className="h-full overflow-auto">
<ProfileViewer pubkey={pubkey!} />
</div>
);
}

View File

@@ -2,6 +2,9 @@ import { createBrowserRouter, RouterProvider } from "react-router";
import { AppShell } from "./components/layouts/AppShell";
import DashboardPage from "./components/pages/DashboardPage";
import SpellbookPage from "./components/pages/SpellbookPage";
import PreviewProfilePage from "./components/pages/PreviewProfilePage";
import PreviewEventPage from "./components/pages/PreviewEventPage";
import PreviewAddressPage from "./components/pages/PreviewAddressPage";
const router = createBrowserRouter([
{
@@ -12,6 +15,38 @@ const router = createBrowserRouter([
</AppShell>
),
},
{
path: "/npub:identifier",
element: (
<AppShell hideBottomBar>
<PreviewProfilePage />
</AppShell>
),
},
{
path: "/nevent:identifier",
element: (
<AppShell hideBottomBar>
<PreviewEventPage />
</AppShell>
),
},
{
path: "/note:identifier",
element: (
<AppShell hideBottomBar>
<PreviewEventPage />
</AppShell>
),
},
{
path: "/naddr:identifier",
element: (
<AppShell hideBottomBar>
<PreviewAddressPage />
</AppShell>
),
},
{
path: "/preview/:actor/:identifier",
element: (