Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ not published yet (MVP.md §0.1). Local proof uses Anvil + `test/fixtures/tiny-m
| `services/seed` | `@enspack/seed` | seed node API + qBittorrent + Kubo compose |
| `services/registrar` | `@enspack/registrar` | HF-proof → `enspack.eth` publisher subnames |
| `services/indexer` | `@enspack/indexer` | Ponder indexer + JSON API |
| `apps/site` | `@enspack/site` | catalog site, read-only view of the indexer API |
| `bootstrap/` | `@enspack/bootstrap` | `models.yaml` mirror runner (`enspack-bootstrap plan|run`) |
| `infra/` | `@enspack/infra` | one-box Sepolia deploy (Caddy + compose + scripts) |
| `test/fixtures` | — | tiny-model folder + torrent for swarm tests |
Expand Down
5 changes: 5 additions & 0 deletions apps/site/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @enspack/site

## 0.1.0 (Unreleased)

- Initial catalog site (WP-19): read-only view of the indexer JSON API.
50 changes: 50 additions & 0 deletions apps/site/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# @enspack/site

Read-only catalog for enspack names. Someone who has never heard of enspack
should see, on one page, what a name is, what it points at, and that the bytes
verify. The site is a window onto the indexer JSON API (`MVP.md` §4.3). No
wallet, no writes, no keys, no RPC URL in the bundle.

`@enspack/core` is a type-only (dev) dependency. Runtime core is never imported
into the browser bundle (it would pull viem and IPFS).

## Environment

| Variable | Default | Meaning |
| --- | --- | --- |
| `VITE_INDEX_URL` | `https://index.enspack.dev` | Indexer base URL |
| `VITE_REGISTRAR_URL` | `https://registrar.enspack.dev` | Registrar base URL (attestations, claim-page link) |
| `VITE_CHAIN` | `sepolia` | `sepolia` \| `mainnet`; drives the testnet banner and explorer / ENS-app links |
| `VITE_DATA_SOURCE` | `http` | `http` \| `demo`; `demo` serves the recorded fixtures in-app |

## Develop

From the repo root:

```sh
pnpm --filter @enspack/site dev
```

Without a live indexer, serve the recorded fixtures:

```sh
VITE_DATA_SOURCE=demo pnpm --filter @enspack/site dev
```

```sh
pnpm --filter @enspack/site build
pnpm --filter @enspack/site preview
```

## Deploy (Cloudflare Pages)

Hosting target is `enspack.dev`. DNS and the Pages project are manual.

| | |
| --- | --- |
| Build command | `pnpm --filter @enspack/site build` |
| Output directory | `apps/site/dist` |
| SPA fallback | `public/_redirects` (`/* /index.html 200`) |

The built bundle contains no keys and no RPC URLs. Chain state arrives only
through the indexer (or demo fixtures).
17 changes: 17 additions & 0 deletions apps/site/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<meta
name="description"
content="enspack: model weights with a name you can verify. ENS name → content-addressed manifest → SHA-256 checked files."
/>
<title>enspack</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
35 changes: 35 additions & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@enspack/site",
"version": "0.1.0",
"private": true,
"description": "enspack catalog site: read-only window onto the indexer JSON API (MVP.md §4.3)",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"check": "pnpm typecheck && pnpm test"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.6.0"
},
"devDependencies": {
"@enspack/core": "workspace:*",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.0",
"jsdom": "^26.1.0",
"typescript": "^5.7.0",
"vite": "^6.3.0",
"vitest": "^3.0.0"
}
}
1 change: 1 addition & 0 deletions apps/site/public/_redirects
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/* /index.html 200
23 changes: 23 additions & 0 deletions apps/site/src/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Route, Routes } from "react-router";
import { Layout } from "./components/layout.js";
import { GetStartedPage } from "./pages/get-started.js";
import { HomePage } from "./pages/home.js";
import { ModelPage } from "./pages/model.js";
import { NotFoundPage } from "./pages/not-found.js";
import { PublisherPage } from "./pages/publisher.js";
import { ViolationsPage } from "./pages/violations.js";

export function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/name/:name" element={<ModelPage />} />
<Route path="/publisher/:label" element={<PublisherPage />} />
<Route path="/violations" element={<ViolationsPage />} />
<Route path="/get-started" element={<GetStartedPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Layout>
);
}
46 changes: 46 additions & 0 deletions apps/site/src/components/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";

export type BadgeTone = "neutral" | "accent" | "ok" | "warn" | "danger";

export function Badge({
children,
tone = "neutral",
title,
}: {
children: ReactNode;
tone?: BadgeTone;
title?: string;
}) {
return (
<span className={`badge badge-${tone}`} title={title}>
{children}
</span>
);
}

const ROLE_TONE: Record<string, BadgeTone> = {
weight: "accent",
config: "neutral",
tokenizer: "neutral",
index: "neutral",
doc: "neutral",
license: "warn",
code: "neutral",
other: "neutral",
};

export function RoleBadge({ role }: { role: string | undefined }) {
const r = role ?? "other";
return <Badge tone={ROLE_TONE[r] ?? "neutral"}>{r}</Badge>;
}

export function LicenseBadge({ license, href }: { license: string; href?: string }) {
const badge = <Badge tone="ok">{license}</Badge>;
return href ? (
<a href={href} rel="noreferrer" target="_blank" className="badge-link">
{badge}
</a>
) : (
badge
);
}
13 changes: 13 additions & 0 deletions apps/site/src/components/command-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { CopyButton } from "./copy-button.js";

/** Copyable CLI snippet; chrome comes from global `.cmd`. */
export function CommandBlock({ command }: { command: string }) {
return (
<div className="cmd">
<pre>
<code>{command}</code>
</pre>
<CopyButton value={command} />
</div>
);
}
55 changes: 55 additions & 0 deletions apps/site/src/components/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";

/** The async clipboard API is denied in some embedded/permissioned contexts; fall back to execCommand. */
async function copyText(value: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch {
// fall through to the legacy path
}
const ta = document.createElement("textarea");
ta.value = value;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
document.body.removeChild(ta);
return ok;
}

export function CopyButton({ value, label = "Copy" }: { value: string; label?: string }) {
const [copied, setCopied] = useState(false);

useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 1500);
return () => clearTimeout(t);
}, [copied]);

async function onClick() {
if (await copyText(value)) setCopied(true);
}

return (
<button
type="button"
className="copy-btn"
onClick={onClick}
aria-live="polite"
aria-label={copied ? "Copied" : `${label} to clipboard`}
data-copied={copied ? "true" : undefined}
>
{copied ? "Copied" : label}
</button>
);
}
17 changes: 17 additions & 0 deletions apps/site/src/components/external-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ReactNode } from "react";

export function ExternalLink({
href,
children,
className,
}: {
href: string;
children: ReactNode;
className?: string;
}) {
return (
<a href={href} rel="noreferrer" target="_blank" className={className}>
{children}
</a>
);
}
102 changes: 102 additions & 0 deletions apps/site/src/components/files-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useMemo, useState } from "react";
import type { ManifestFile } from "../lib/api.js";
import { formatBytes } from "../lib/format.js";
import { RoleBadge } from "./badge.js";
import { Hash } from "./hash.js";

type SortKey = "path" | "size";
type SortDir = "asc" | "desc";

function ariaSort(
active: SortKey | null,
key: SortKey,
dir: SortDir,
): "ascending" | "descending" | "none" {
if (active !== key) return "none";
return dir === "asc" ? "ascending" : "descending";
}

function compareFiles(a: ManifestFile, b: ManifestFile, key: SortKey, dir: SortDir): number {
const mul = dir === "asc" ? 1 : -1;
if (key === "size") return (a.size - b.size) * mul;
return a.path.localeCompare(b.path) * mul;
}

export function FilesTable({
files,
totalSize,
}: {
files: readonly ManifestFile[];
totalSize: number;
}) {
const [key, setKey] = useState<SortKey | null>(null);
const [dir, setDir] = useState<SortDir>("asc");

const rows = useMemo(() => {
if (key === null) return [...files];
return [...files].sort((a, b) => compareFiles(a, b, key, dir));
}, [files, key, dir]);

function onSort(next: SortKey) {
if (key === next) {
setDir((d) => (d === "asc" ? "desc" : "asc"));
return;
}
setKey(next);
setDir("asc");
}

return (
<section className="files-section" aria-labelledby="files-heading">
<h2 id="files-heading">Files</h2>
<div className="files-table-wrap">
<table className="files-table" aria-label="Files">
<thead>
<tr>
<th scope="col" aria-sort={ariaSort(key, "path", dir)}>
<button type="button" className="files-sort" onClick={() => onSort("path")}>
path
</button>
</th>
<th scope="col">role</th>
<th scope="col" aria-sort={ariaSort(key, "size", dir)}>
<button type="button" className="files-sort" onClick={() => onSort("size")}>
size
</button>
</th>
<th scope="col">sha256</th>
</tr>
</thead>
<tbody>
{rows.map((file) => (
<tr key={file.path}>
<td data-label="path">
<code className="files-path">{file.path}</code>
</td>
<td data-label="role">
<RoleBadge role={file.role} />
</td>
<td className="files-size" data-label="size">
{formatBytes(file.size)}
</td>
<td className="files-sha" data-label="sha256">
<Hash value={file.sha256} />
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th scope="row">
{files.length} {files.length === 1 ? "file" : "files"}
</th>
<td />
<td className="files-size">{formatBytes(totalSize)}</td>
<td />
</tr>
</tfoot>
</table>
</div>
</section>
);
}
Loading
Loading