feat: link resolver and spinner
This commit is contained in:
1
frontend/src/assets/loading.min.svg
Normal file
1
frontend/src/assets/loading.min.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 980 KiB |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
27
frontend/src/components/shared/Loader.tsx
Normal file
27
frontend/src/components/shared/Loader.tsx
Normal 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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user