feat: link resolver and spinner

This commit is contained in:
2026-04-05 11:01:48 +02:00
parent ad89f409bf
commit 3c5856aecb
7 changed files with 91 additions and 9 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 980 KiB

View File

@@ -1,4 +1,5 @@
import FloatingWindow from "@/components/shared/FloatingWindow";
import Loader from "@/components/shared/Loader";
import type { ManagedWindow } from "@/hooks/useWindowManager";
import type { BrowseWinData } from "./types";
@@ -65,7 +66,7 @@ export default function BrowseNodeWindow({
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="p-3 h-full overflow-auto" onClick={(e) => onContentClick(e, win.id, d)}>
{d.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
{d.pageLoading && <div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground text-xs"><Loader /> Requesting page...</div>}
{d.pageError && <span className="text-destructive text-xs">{d.pageError}</span>}
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
</div>

View File

@@ -1,6 +1,8 @@
import { useCallback } from "react";
import { useEditorCtx } from "./EditorStoreContext";
import { renderMicron } from "./micronRenderer";
import { cn } from "@/lib/utils";
import Loader from "@/components/shared/Loader";
type PreviewMode = "micron" | "raw" | "script";
@@ -13,6 +15,11 @@ export default function PreviewPane() {
const isCompiling = useEditorCtx((s) => s.isCompiling);
const compileError = useEditorCtx((s) => s.compileError);
const handlePreviewClick = useCallback((e: React.MouseEvent) => {
const anchor = (e.target as HTMLElement).closest("a");
if (anchor) e.preventDefault();
}, []);
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
{ value: "micron", label: "Micron", show: true },
{ value: "raw", label: "Raw", show: true },
@@ -28,7 +35,7 @@ export default function PreviewPane() {
<span className="ml-1.5 text-primary/60 text-[10px]" title="Dynamic page"></span>
)}
{isCompiling && (
<span className="ml-1 text-primary/40 text-[10px] animate-pulse"></span>
<Loader size={10} className="ml-1.5 inline-block" />
)}
{compileError && (
<span className="ml-1 text-red-400 text-[10px]" title={compileError}></span>
@@ -57,7 +64,8 @@ export default function PreviewPane() {
))}
</div>
</div>
<div className="flex-1 bg-background overflow-auto min-h-0">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="flex-1 bg-background overflow-auto min-h-0" onClick={handlePreviewClick}>
{previewMode === "micron" ? (
compiledMicron ? (
<div

View File

@@ -0,0 +1,27 @@
import loadingSvg from "@/assets/loading.min.svg";
interface LoaderProps {
size?: number;
className?: string;
}
export default function Loader({ size = 120, className = "" }: LoaderProps) {
return (
<div
className={`animate-spin-slow ${className}`}
style={{
width: size,
height: size,
background: "var(--primary)",
maskImage: `url(${loadingSvg})`,
maskSize: "contain",
maskRepeat: "no-repeat",
maskPosition: "center",
WebkitMaskImage: `url(${loadingSvg})`,
WebkitMaskSize: "contain",
WebkitMaskRepeat: "no-repeat",
WebkitMaskPosition: "center",
}}
/>
);
}

View File

@@ -15,6 +15,17 @@
}
}
@keyframes spin-slow {
0% { transform: rotate(0deg); }
60% { transform: rotate(380deg); }
80% { transform: rotate(355deg); }
100% { transform: rotate(360deg); }
}
@utility animate-spin-slow {
animation: spin-slow 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@theme inline {
--font-heading: var(--font-mono);
--font-sans: 'JetBrains Mono Variable', 'Courier New', monospace;

View File

@@ -8,6 +8,7 @@ import { buildGraphArrays, SPACE_SIZE } from "@/components/browse/buildGraph";
import type { BrowseWinData, HistoryEntry } from "@/components/browse/types";
import BrowseNodeWindow from "@/components/browse/BrowseNodeWindow";
import BrowseSearchBar from "@/components/browse/BrowseSearchBar";
import Loader from "@/components/shared/Loader";
// ---------------------------------------------------------------------------
// Main component
@@ -355,6 +356,13 @@ export default function BrowseView() {
}, [openWindow, navigateTo]);
// ── Handle micron link clicks via event delegation ──
//
// Micron link destinations come in several forms:
// /page.mu — same-node, absolute path
// page.mu — same-node, relative
// :/page/page.mu — NomadNet "request" link (: prefix + /page/ segment)
// <32-hex-hash>/page.mu — cross-node link
// nomadnetwork://... — already stripped by micron-parser's data-destination
const handleContentClick = useCallback((e: React.MouseEvent, winId: string, data: BrowseWinData) => {
const anchor = (e.target as HTMLElement).closest("a");
if (!anchor) return;
@@ -363,11 +371,35 @@ export default function BrowseView() {
const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? "";
if (!dest) return;
let path = dest.replace(/^nomadnetwork:\/\//, "").replace(/^\/+/, "");
if (/^[0-9a-f]{32}$/i.test(path)) return;
let raw = dest
.replace(/^nomadnetwork:\/\//, "") // strip scheme if present
.replace(/^:/, "") // strip NomadNet request prefix
.replace(/^\/page\//, "") // strip /page/ path segment
.replace(/^\/+/, ""); // strip remaining leading slashes
// A bare 32-char hex hash with no path — nothing to navigate to
if (/^[0-9a-f]{32}$/i.test(raw)) return;
// If the destination starts with a 32-char hex hash followed by "/",
// it's a cross-node link: <hash>/page.mu → use that node's hash
let targetNode = data.node;
let path = raw;
const crossNodeMatch = raw.match(/^([0-9a-f]{32})\/(.+)$/i);
if (crossNodeMatch) {
const targetHash = crossNodeMatch[1]!;
path = crossNodeMatch[2]!;
// Strip /page/ from the path portion too
path = path.replace(/^\/page\//, "").replace(/^\/+/, "");
const known = nodesMapRef.current.get(targetHash);
if (known) {
targetNode = known;
}
}
if (!path || path === "/") return;
if (!path.endsWith(".mu")) path += ".mu";
navigateTo(winId, data.node, path, data);
navigateTo(winId, targetNode, path, data);
}, [navigateTo]);
const clearSearch = useCallback(() => {
@@ -400,8 +432,9 @@ export default function BrowseView() {
/>
{nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm pointer-events-none">
Listening for nodes on the Reticulum network...
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm pointer-events-none">
<Loader />
Connecting...
</div>
)}

View File

@@ -5,6 +5,7 @@ import { usePagesStore } from "@/stores/pagesStore";
import * as api from "@/api/client";
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
import StatusBadge from "@/components/dashboard/StatusBadge";
import Loader from "@/components/shared/Loader";
import { Button } from "@/components/ui/button";
import {
Table,
@@ -259,7 +260,7 @@ export default function ComposeView() {
{/* File table */}
<div className="flex-1 min-h-0 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">Loading...</div>
<div className="flex flex-col items-center justify-center h-32 gap-3 text-muted-foreground text-xs"><Loader size={48} /> Loading...</div>
) : (
<Table>
<TableHeader>