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 FloatingWindow from "@/components/shared/FloatingWindow";
|
||||||
|
import Loader from "@/components/shared/Loader";
|
||||||
import type { ManagedWindow } from "@/hooks/useWindowManager";
|
import type { ManagedWindow } from "@/hooks/useWindowManager";
|
||||||
import type { BrowseWinData } from "./types";
|
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 */}
|
{/* 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)}>
|
<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.pageError && <span className="text-destructive text-xs">{d.pageError}</span>}
|
||||||
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
|
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
import { useEditorCtx } from "./EditorStoreContext";
|
import { useEditorCtx } from "./EditorStoreContext";
|
||||||
import { renderMicron } from "./micronRenderer";
|
import { renderMicron } from "./micronRenderer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import Loader from "@/components/shared/Loader";
|
||||||
|
|
||||||
type PreviewMode = "micron" | "raw" | "script";
|
type PreviewMode = "micron" | "raw" | "script";
|
||||||
|
|
||||||
@@ -13,6 +15,11 @@ export default function PreviewPane() {
|
|||||||
const isCompiling = useEditorCtx((s) => s.isCompiling);
|
const isCompiling = useEditorCtx((s) => s.isCompiling);
|
||||||
const compileError = useEditorCtx((s) => s.compileError);
|
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 }[] = [
|
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
|
||||||
{ value: "micron", label: "Micron", show: true },
|
{ value: "micron", label: "Micron", show: true },
|
||||||
{ value: "raw", label: "Raw", 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>
|
<span className="ml-1.5 text-primary/60 text-[10px]" title="Dynamic page">⚡</span>
|
||||||
)}
|
)}
|
||||||
{isCompiling && (
|
{isCompiling && (
|
||||||
<span className="ml-1 text-primary/40 text-[10px] animate-pulse">…</span>
|
<Loader size={10} className="ml-1.5 inline-block" />
|
||||||
)}
|
)}
|
||||||
{compileError && (
|
{compileError && (
|
||||||
<span className="ml-1 text-red-400 text-[10px]" title={compileError}>✗</span>
|
<span className="ml-1 text-red-400 text-[10px]" title={compileError}>✗</span>
|
||||||
@@ -57,7 +64,8 @@ export default function PreviewPane() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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" ? (
|
{previewMode === "micron" ? (
|
||||||
compiledMicron ? (
|
compiledMicron ? (
|
||||||
<div
|
<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 {
|
@theme inline {
|
||||||
--font-heading: var(--font-mono);
|
--font-heading: var(--font-mono);
|
||||||
--font-sans: 'JetBrains Mono Variable', 'Courier New', monospace;
|
--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 type { BrowseWinData, HistoryEntry } from "@/components/browse/types";
|
||||||
import BrowseNodeWindow from "@/components/browse/BrowseNodeWindow";
|
import BrowseNodeWindow from "@/components/browse/BrowseNodeWindow";
|
||||||
import BrowseSearchBar from "@/components/browse/BrowseSearchBar";
|
import BrowseSearchBar from "@/components/browse/BrowseSearchBar";
|
||||||
|
import Loader from "@/components/shared/Loader";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main component
|
// Main component
|
||||||
@@ -355,6 +356,13 @@ export default function BrowseView() {
|
|||||||
}, [openWindow, navigateTo]);
|
}, [openWindow, navigateTo]);
|
||||||
|
|
||||||
// ── Handle micron link clicks via event delegation ──
|
// ── 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 handleContentClick = useCallback((e: React.MouseEvent, winId: string, data: BrowseWinData) => {
|
||||||
const anchor = (e.target as HTMLElement).closest("a");
|
const anchor = (e.target as HTMLElement).closest("a");
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
@@ -363,11 +371,35 @@ export default function BrowseView() {
|
|||||||
const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? "";
|
const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? "";
|
||||||
if (!dest) return;
|
if (!dest) return;
|
||||||
|
|
||||||
let path = dest.replace(/^nomadnetwork:\/\//, "").replace(/^\/+/, "");
|
let raw = dest
|
||||||
if (/^[0-9a-f]{32}$/i.test(path)) return;
|
.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";
|
if (!path.endsWith(".mu")) path += ".mu";
|
||||||
|
|
||||||
navigateTo(winId, data.node, path, data);
|
navigateTo(winId, targetNode, path, data);
|
||||||
}, [navigateTo]);
|
}, [navigateTo]);
|
||||||
|
|
||||||
const clearSearch = useCallback(() => {
|
const clearSearch = useCallback(() => {
|
||||||
@@ -400,8 +432,9 @@ export default function BrowseView() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{nodes.length === 0 && (
|
{nodes.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm pointer-events-none">
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm pointer-events-none">
|
||||||
Listening for nodes on the Reticulum network...
|
<Loader />
|
||||||
|
Connecting...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePagesStore } from "@/stores/pagesStore";
|
|||||||
import * as api from "@/api/client";
|
import * as api from "@/api/client";
|
||||||
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
|
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
|
||||||
import StatusBadge from "@/components/dashboard/StatusBadge";
|
import StatusBadge from "@/components/dashboard/StatusBadge";
|
||||||
|
import Loader from "@/components/shared/Loader";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -259,7 +260,7 @@ export default function ComposeView() {
|
|||||||
{/* File table */}
|
{/* File table */}
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
{isLoading ? (
|
{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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user