mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
85
src/components/pages/PreviewAddressPage.tsx
Normal file
85
src/components/pages/PreviewAddressPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/components/pages/PreviewEventPage.tsx
Normal file
96
src/components/pages/PreviewEventPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/pages/PreviewProfilePage.tsx
Normal file
79
src/components/pages/PreviewProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/root.tsx
35
src/root.tsx
@@ -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: (
|
||||
|
||||
Reference in New Issue
Block a user