feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["../biome.json"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/shared/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/shared/ui",
|
||||
"utils": "@/shared/lib/cn",
|
||||
"ui": "@/shared/ui",
|
||||
"lib": "@/shared/lib"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hans">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Buzz</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "buzz-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:e2e": "tsc && vite build --base=/buzz/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:file-sizes": "node ./scripts/check-file-sizes.mjs",
|
||||
"check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs",
|
||||
"lint": "biome lint .",
|
||||
"check": "biome check . && pnpm check:file-sizes && pnpm check:pubkey-truncation",
|
||||
"format": "biome format --write .",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "pnpm build:e2e && playwright test",
|
||||
"test:e2e:smoke": "pnpm build:e2e && playwright test --project=smoke"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@isomorphic-git/lightning-fs": "^4.6.2",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"buffer": "^6.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"isomorphic-git": "^1.38.3",
|
||||
"lucide-react": "^1.0.0",
|
||||
"nostr-tools": "^2.23.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tanstack/router-plugin": "^1.167.12",
|
||||
"@tanstack/virtual-file-routes": "^1.161.7",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { open: "never", outputFolder: "playwright-report" }],
|
||||
],
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4173/buzz/",
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "smoke",
|
||||
testMatch: ["**/smoke.spec.ts"],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command:
|
||||
"pnpm exec vite preview --port 4173 --strictPort --host 127.0.0.1 --base=/buzz/",
|
||||
cwd: ".",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
url: "http://127.0.0.1:4173/buzz/",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const MAX_LINES = 1000;
|
||||
|
||||
const rules = [
|
||||
{
|
||||
root: "src/app",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
maxLines: MAX_LINES,
|
||||
},
|
||||
{
|
||||
root: "src/features",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
maxLines: MAX_LINES,
|
||||
},
|
||||
{
|
||||
root: "src/shared/api",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
maxLines: MAX_LINES,
|
||||
},
|
||||
];
|
||||
|
||||
await runFileSizeCheck({
|
||||
projectRoot,
|
||||
rules,
|
||||
label: "Web",
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runPubkeyTruncationCheck } from "../../scripts/check-pubkey-truncation-core.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const rules = [
|
||||
{
|
||||
root: "src",
|
||||
extensions: new Set([".ts", ".tsx"]),
|
||||
},
|
||||
];
|
||||
|
||||
const overrides = new Set([
|
||||
// Avatar fallback initials — two glyphs inside an avatar disc.
|
||||
"src/features/repos/ui/PubkeyAvatar.tsx:29",
|
||||
// Array window (first N pubkeys), not string truncation.
|
||||
"src/features/repos/ui/OrgSidebar.tsx:24",
|
||||
]);
|
||||
|
||||
await runPubkeyTruncationCheck({
|
||||
projectRoot,
|
||||
rules,
|
||||
overrides,
|
||||
allowedFiles: new Set(["src/shared/lib/pubkey.ts"]),
|
||||
label: "Web",
|
||||
scriptPath: "web/scripts/check-pubkey-truncation.mjs",
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
|
||||
import { router } from "@/app/router";
|
||||
|
||||
export function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// biome-ignore-all lint: generated by TanStack Router
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from "./routes/root";
|
||||
import { Route as reposRouteImport } from "./routes/repos";
|
||||
import { Route as indexRouteImport } from "./routes/index";
|
||||
import { Route as reposDotrepoIdRouteImport } from "./routes/repos.$repoId";
|
||||
import { Route as inviteDotcodeRouteImport } from "./routes/invite.$code";
|
||||
import { Route as reposDotrepoIdDotblobDotsplatRouteImport } from "./routes/repos.$repoId.blob.$";
|
||||
|
||||
const reposRoute = reposRouteImport.update({
|
||||
id: "/repos",
|
||||
path: "/repos",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const indexRoute = indexRouteImport.update({
|
||||
id: "/",
|
||||
path: "/",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const reposDotrepoIdRoute = reposDotrepoIdRouteImport.update({
|
||||
id: "/repos/$repoId",
|
||||
path: "/repos/$repoId",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const inviteDotcodeRoute = inviteDotcodeRouteImport.update({
|
||||
id: "/invite/$code",
|
||||
path: "/invite/$code",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const reposDotrepoIdDotblobDotsplatRoute =
|
||||
reposDotrepoIdDotblobDotsplatRouteImport.update({
|
||||
id: "/repos/$repoId/blob/$",
|
||||
path: "/repos/$repoId/blob/$",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof indexRoute;
|
||||
"/repos": typeof reposRoute;
|
||||
"/invite/$code": typeof inviteDotcodeRoute;
|
||||
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
||||
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof indexRoute;
|
||||
"/repos": typeof reposRoute;
|
||||
"/invite/$code": typeof inviteDotcodeRoute;
|
||||
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
||||
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport;
|
||||
"/": typeof indexRoute;
|
||||
"/repos": typeof reposRoute;
|
||||
"/invite/$code": typeof inviteDotcodeRoute;
|
||||
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
||||
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath;
|
||||
fullPaths:
|
||||
| "/"
|
||||
| "/repos"
|
||||
| "/invite/$code"
|
||||
| "/repos/$repoId"
|
||||
| "/repos/$repoId/blob/$";
|
||||
fileRoutesByTo: FileRoutesByTo;
|
||||
to:
|
||||
| "/"
|
||||
| "/repos"
|
||||
| "/invite/$code"
|
||||
| "/repos/$repoId"
|
||||
| "/repos/$repoId/blob/$";
|
||||
id:
|
||||
| "__root__"
|
||||
| "/"
|
||||
| "/repos"
|
||||
| "/invite/$code"
|
||||
| "/repos/$repoId"
|
||||
| "/repos/$repoId/blob/$";
|
||||
fileRoutesById: FileRoutesById;
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
indexRoute: typeof indexRoute;
|
||||
reposRoute: typeof reposRoute;
|
||||
inviteDotcodeRoute: typeof inviteDotcodeRoute;
|
||||
reposDotrepoIdRoute: typeof reposDotrepoIdRoute;
|
||||
reposDotrepoIdDotblobDotsplatRoute: typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface FileRoutesByPath {
|
||||
"/repos": {
|
||||
id: "/repos";
|
||||
path: "/repos";
|
||||
fullPath: "/repos";
|
||||
preLoaderRoute: typeof reposRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/": {
|
||||
id: "/";
|
||||
path: "/";
|
||||
fullPath: "/";
|
||||
preLoaderRoute: typeof indexRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/repos/$repoId": {
|
||||
id: "/repos/$repoId";
|
||||
path: "/repos/$repoId";
|
||||
fullPath: "/repos/$repoId";
|
||||
preLoaderRoute: typeof reposDotrepoIdRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/invite/$code": {
|
||||
id: "/invite/$code";
|
||||
path: "/invite/$code";
|
||||
fullPath: "/invite/$code";
|
||||
preLoaderRoute: typeof inviteDotcodeRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/repos/$repoId/blob/$": {
|
||||
id: "/repos/$repoId/blob/$";
|
||||
path: "/repos/$repoId/blob/$";
|
||||
fullPath: "/repos/$repoId/blob/$";
|
||||
preLoaderRoute: typeof reposDotrepoIdDotblobDotsplatRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
indexRoute: indexRoute,
|
||||
reposRoute: reposRoute,
|
||||
inviteDotcodeRoute: inviteDotcodeRoute,
|
||||
reposDotrepoIdRoute: reposDotrepoIdRoute,
|
||||
reposDotrepoIdDotblobDotsplatRoute: reposDotrepoIdDotblobDotsplatRoute,
|
||||
};
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>();
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createBrowserHistory, createRouter } from "@tanstack/react-router";
|
||||
|
||||
import { routeTree } from "@/app/routeTree.gen";
|
||||
import { appBasePath } from "@/shared/lib/app-base";
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
basepath: appBasePath(),
|
||||
history: createBrowserHistory(),
|
||||
scrollRestoration: true,
|
||||
getScrollRestorationKey: (location: { pathname: string }) =>
|
||||
location.pathname,
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { index, route, rootRoute } from "@tanstack/virtual-file-routes";
|
||||
|
||||
export const routes = rootRoute("root.tsx", [
|
||||
index("index.tsx"),
|
||||
route("/invite/$code", "invite.$code.tsx"),
|
||||
route("/repos", "repos.tsx"),
|
||||
route("/repos/$repoId", "repos.$repoId.tsx"),
|
||||
route("/repos/$repoId/blob/$", "repos.$repoId.blob.$.tsx"),
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ReposPage } from "@/features/repos/ui/ReposPage";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: ReposPage,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { InvitePage } from "@/features/invite/ui/InvitePage";
|
||||
|
||||
export const Route = createFileRoute("/invite/$code")({
|
||||
component: InvitePageRoute,
|
||||
});
|
||||
|
||||
function InvitePageRoute() {
|
||||
const { code } = Route.useParams();
|
||||
return <InvitePage code={code} />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { RepoBlobPage } from "@/features/repos/ui/RepoBlobViewer";
|
||||
|
||||
export const Route = createFileRoute("/repos/$repoId/blob/$")({
|
||||
component: RepoBlobPage,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { RepoDetailPage } from "@/features/repos/ui/RepoDetailPage";
|
||||
|
||||
export const Route = createFileRoute("/repos/$repoId")({
|
||||
component: RepoDetailPage,
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/repos")({
|
||||
component: () => <Navigate to="/" />,
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { LocaleToggle } from "@/shared/i18n/LocaleToggle";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col">
|
||||
<div className="fixed top-4 right-4 z-40">
|
||||
<LocaleToggle />
|
||||
</div>
|
||||
<main className="flex flex-1 flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,51 @@
|
||||
import { makeNip98AuthHeader } from "@/shared/lib/nip98";
|
||||
import { relayHttpBaseUrl } from "@/shared/lib/relay-url";
|
||||
|
||||
const INVITE_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type BrowserInviteClaim = {
|
||||
status: "joined" | "already_member";
|
||||
communityId: string;
|
||||
host: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export async function claimInviteInBrowser(
|
||||
code: string,
|
||||
policyReceipt?: string,
|
||||
): Promise<BrowserInviteClaim> {
|
||||
const url = `${relayHttpBaseUrl().replace(/\/+$/, "")}/api/invites/claim`;
|
||||
const body = JSON.stringify({
|
||||
code,
|
||||
policy_receipt: policyReceipt,
|
||||
});
|
||||
const authorization = await makeNip98AuthHeader(url, "POST", {
|
||||
body,
|
||||
requireNip07: true,
|
||||
});
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(INVITE_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const json = (await response.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof json.error === "string" ? json.error : `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return {
|
||||
status: json.status as BrowserInviteClaim["status"],
|
||||
communityId: String(json.community_id),
|
||||
host: String(json.host),
|
||||
role: String(json.role),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import * as React from "react";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
|
||||
type InviteJoinPolicy = {
|
||||
terms_markdown?: string;
|
||||
privacy_markdown?: string;
|
||||
age_attestation_required: boolean;
|
||||
};
|
||||
|
||||
type PolicyCheckboxProps = {
|
||||
accessibleLabel: string;
|
||||
checked: boolean;
|
||||
children: React.ReactNode;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
function PolicyCheckbox({
|
||||
accessibleLabel,
|
||||
checked,
|
||||
children,
|
||||
onCheckedChange,
|
||||
}: PolicyCheckboxProps) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
aria-label={accessibleLabel}
|
||||
checked={checked}
|
||||
className="peer sr-only"
|
||||
id={id}
|
||||
onChange={(event) => onCheckedChange(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="flex cursor-pointer items-start gap-3 rounded-sm text-left text-xs leading-5 text-black/60 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-black"
|
||||
htmlFor={id}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-[background-color,border-color] duration-150 motion-reduce:transition-none ${
|
||||
checked ? "border-black bg-black" : "border-black/40 bg-white"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
className={
|
||||
checked
|
||||
? "transition-[stroke-dashoffset] duration-[180ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
|
||||
: "transition-none"
|
||||
}
|
||||
d="M3 8.25 6.25 11.5 13 4.75"
|
||||
pathLength="16"
|
||||
stroke="currentColor"
|
||||
strokeDasharray="16"
|
||||
strokeDashoffset={checked ? 0 : 16}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The invite page's age and legal confirmations. */
|
||||
export function InviteJoinPolicyNotice({
|
||||
ageConfirmed,
|
||||
agreementConfirmed,
|
||||
onAgeConfirmedChange,
|
||||
onAgreementConfirmedChange,
|
||||
onShowDocument,
|
||||
policy,
|
||||
}: {
|
||||
ageConfirmed: boolean;
|
||||
agreementConfirmed: boolean;
|
||||
onAgeConfirmedChange: (checked: boolean) => void;
|
||||
onAgreementConfirmedChange: (checked: boolean) => void;
|
||||
onShowDocument: (title: string, markdown: string) => void;
|
||||
policy: InviteJoinPolicy;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const stopLabelActivation = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
title: string,
|
||||
markdown: string,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onShowDocument(title, markdown);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full space-y-3 rounded-xl border border-black/10 bg-black/[0.03] p-4 text-left"
|
||||
data-testid="invite-join-policy-notice"
|
||||
>
|
||||
{policy.age_attestation_required ? (
|
||||
<PolicyCheckbox
|
||||
accessibleLabel={t("invite.ageLabel")}
|
||||
checked={ageConfirmed}
|
||||
onCheckedChange={onAgeConfirmedChange}
|
||||
>
|
||||
{t("invite.ageLabel")}
|
||||
</PolicyCheckbox>
|
||||
) : null}
|
||||
|
||||
{policy.terms_markdown || policy.privacy_markdown ? (
|
||||
<PolicyCheckbox
|
||||
accessibleLabel={t("invite.agreementLabel")}
|
||||
checked={agreementConfirmed}
|
||||
onCheckedChange={onAgreementConfirmedChange}
|
||||
>
|
||||
{t("invite.agreementPrefix")}
|
||||
{policy.terms_markdown ? (
|
||||
<button
|
||||
className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
stopLabelActivation(
|
||||
event,
|
||||
t("invite.terms"),
|
||||
policy.terms_markdown ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("invite.terms")}
|
||||
</button>
|
||||
) : (
|
||||
t("invite.terms")
|
||||
)}
|
||||
{t("invite.agreementJoin")}
|
||||
{policy.privacy_markdown ? (
|
||||
<button
|
||||
className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
stopLabelActivation(
|
||||
event,
|
||||
t("invite.privacy"),
|
||||
policy.privacy_markdown ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("invite.privacy")}
|
||||
</button>
|
||||
) : (
|
||||
t("invite.privacy")
|
||||
)}
|
||||
{t("invite.agreementSuffix")}
|
||||
</PolicyCheckbox>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import buzzAppIcon from "@/assets/app-icon@3x.png";
|
||||
import { claimInviteInBrowser } from "@/features/invite/invite-api";
|
||||
import {
|
||||
BUZZ_RELEASES_URL,
|
||||
type BuzzDownloadPlatform,
|
||||
detectBuzzDownloadPlatform,
|
||||
resolveBuzzDownloadUrlForPlatform,
|
||||
} from "@/shared/lib/buzz-download";
|
||||
import { hasNip07Provider } from "@/shared/lib/nostr-signer";
|
||||
import { relayHttpBaseUrl, relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { appRootHref } from "@/shared/lib/app-base";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
import type { TranslationKey } from "@/shared/i18n/catalogs";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import * as React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { InviteJoinPolicyNotice } from "./InviteJoinPolicyNotice";
|
||||
|
||||
type JoinPolicy = {
|
||||
terms_markdown?: string;
|
||||
privacy_markdown?: string;
|
||||
age_attestation_required: boolean;
|
||||
version: string;
|
||||
};
|
||||
|
||||
type PolicyDocument = { title: string; markdown: string };
|
||||
|
||||
/** Convert relay invite sentinels into user-facing recovery guidance. */
|
||||
function inviteClaimErrorMessage(
|
||||
message: string,
|
||||
t: (key: TranslationKey) => string,
|
||||
): string {
|
||||
if (message.includes("invite_exhausted")) {
|
||||
return t("invite.error.exhausted");
|
||||
}
|
||||
if (message.includes("invite_expired")) {
|
||||
return t("invite.error.expired");
|
||||
}
|
||||
if (message.includes("invite_invalid")) {
|
||||
return t("invite.error.invalid");
|
||||
}
|
||||
console.error("[invite-claim]", message);
|
||||
return t("invite.error.claimFailed");
|
||||
}
|
||||
|
||||
/** Landing page for a community invite link (`/invite/<code>`). */
|
||||
export function InvitePage({ code }: { code: string }) {
|
||||
const { t } = useI18n();
|
||||
const relay = relayWsUrl();
|
||||
const relayHttp = relayHttpBaseUrl();
|
||||
const host = relay.replace(/^wss?:\/\//, "");
|
||||
const [policy, setPolicy] = React.useState<JoinPolicy | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [document, setDocument] = React.useState<PolicyDocument | null>(null);
|
||||
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
||||
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
|
||||
const [opening, setOpening] = React.useState(false);
|
||||
const [joiningBrowser, setJoiningBrowser] = React.useState(false);
|
||||
const [browserJoinError, setBrowserJoinError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [downloadUrl, setDownloadUrl] = React.useState(BUZZ_RELEASES_URL);
|
||||
const [needsMacChoice, setNeedsMacChoice] = React.useState(false);
|
||||
const [showMacChoice, setShowMacChoice] = React.useState(false);
|
||||
const [choosingMacDownload, setChoosingMacDownload] = React.useState(false);
|
||||
const choosingMacDownloadRef = React.useRef(false);
|
||||
const downloadTriggerRef = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
detectBuzzDownloadPlatform(navigator).then(async (platform) => {
|
||||
if (!active) return;
|
||||
if (
|
||||
platform.operatingSystem === "macos" &&
|
||||
platform.architecture === "unknown"
|
||||
) {
|
||||
setNeedsMacChoice(true);
|
||||
return;
|
||||
}
|
||||
const url = await resolveBuzzDownloadUrlForPlatform(platform);
|
||||
if (active) setDownloadUrl(url);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch(`${relayHttp}/api/join-policy`)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const config = (await response.json()) as { policy?: JoinPolicy };
|
||||
setPolicy(config.policy ?? null);
|
||||
})
|
||||
.catch(() => setPolicy(undefined));
|
||||
}, [relayHttp]);
|
||||
|
||||
const acceptPolicy = async (): Promise<string | undefined> => {
|
||||
if (!policy) return undefined;
|
||||
const response = await fetch(`${relayHttp}/api/invites/accept-policy`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
policy_version: policy.version,
|
||||
age_confirmed: ageConfirmed,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return ((await response.json()) as { receipt: string }).receipt;
|
||||
};
|
||||
|
||||
const openInvite = async () => {
|
||||
setOpening(true);
|
||||
try {
|
||||
const receipt = await acceptPolicy();
|
||||
const query = new URLSearchParams({ relay, code });
|
||||
if (receipt) query.set("policy_receipt", receipt);
|
||||
window.location.href = `buzz://join?${query.toString()}`;
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const joinInBrowser = async () => {
|
||||
setBrowserJoinError(null);
|
||||
setJoiningBrowser(true);
|
||||
try {
|
||||
const receipt = await acceptPolicy();
|
||||
await claimInviteInBrowser(code, receipt);
|
||||
window.location.assign(appRootHref());
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("invite.error.claimFailed");
|
||||
setBrowserJoinError(inviteClaimErrorMessage(message, t));
|
||||
} finally {
|
||||
setJoiningBrowser(false);
|
||||
}
|
||||
};
|
||||
|
||||
const browserSigningAvailable = hasNip07Provider();
|
||||
const disabled =
|
||||
policy === undefined ||
|
||||
opening ||
|
||||
joiningBrowser ||
|
||||
Boolean(policy?.age_attestation_required && !ageConfirmed) ||
|
||||
Boolean(
|
||||
policy &&
|
||||
(policy.terms_markdown || policy.privacy_markdown) &&
|
||||
!agreementConfirmed,
|
||||
);
|
||||
const hasPolicyRequirements = Boolean(
|
||||
policy &&
|
||||
(policy.age_attestation_required ||
|
||||
policy.terms_markdown ||
|
||||
policy.privacy_markdown),
|
||||
);
|
||||
const showDocument = (title: string, markdown: string) =>
|
||||
setDocument({ title, markdown });
|
||||
const closeMacChoice = React.useCallback(() => {
|
||||
setShowMacChoice(false);
|
||||
window.setTimeout(() => downloadTriggerRef.current?.focus());
|
||||
}, []);
|
||||
const chooseMacDownload = async (
|
||||
event: React.MouseEvent<HTMLAnchorElement>,
|
||||
platform: BuzzDownloadPlatform,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
if (choosingMacDownloadRef.current) return;
|
||||
choosingMacDownloadRef.current = true;
|
||||
setChoosingMacDownload(true);
|
||||
const downloadWindow = window.open("about:blank", "_blank");
|
||||
if (downloadWindow) downloadWindow.opener = null;
|
||||
setShowMacChoice(false);
|
||||
try {
|
||||
const url = await resolveBuzzDownloadUrlForPlatform(platform);
|
||||
downloadWindow?.location.replace(url);
|
||||
} finally {
|
||||
choosingMacDownloadRef.current = false;
|
||||
setChoosingMacDownload(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showMacChoice) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeMacChoice();
|
||||
};
|
||||
window.document.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.document.removeEventListener("keydown", closeOnEscape);
|
||||
}, [closeMacChoice, showMacChoice]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center"
|
||||
style={{
|
||||
backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-xl space-y-4">
|
||||
<div className="flex w-full flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12">
|
||||
<div
|
||||
className="h-12 w-12 overflow-hidden bg-black"
|
||||
style={{ borderRadius: "22.37%" }}
|
||||
>
|
||||
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
|
||||
</div>
|
||||
<h1 className="mt-4 text-2xl font-semibold tracking-tight text-black">
|
||||
{t("invite.title")}
|
||||
</h1>
|
||||
<p className="mt-9 font-mono text-lg text-black/70">{host}</p>
|
||||
|
||||
<div
|
||||
className={`grid w-full max-w-md overflow-hidden transition-[grid-template-rows,margin,opacity,transform] duration-[220ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none ${
|
||||
hasPolicyRequirements
|
||||
? "mt-9 -mb-4 grid-rows-[1fr] opacity-100 translate-y-0"
|
||||
: "m-0 grid-rows-[0fr] opacity-0 -translate-y-1"
|
||||
}`}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
{policy && hasPolicyRequirements ? (
|
||||
<InviteJoinPolicyNotice
|
||||
ageConfirmed={ageConfirmed}
|
||||
agreementConfirmed={agreementConfirmed}
|
||||
onAgeConfirmedChange={setAgeConfirmed}
|
||||
onAgreementConfirmedChange={setAgreementConfirmed}
|
||||
onShowDocument={showDocument}
|
||||
policy={policy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-9 w-full max-w-md space-y-2">
|
||||
{browserSigningAvailable ? (
|
||||
<Button
|
||||
className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
|
||||
disabled={disabled}
|
||||
onClick={joinInBrowser}
|
||||
>
|
||||
{joiningBrowser
|
||||
? t("invite.joining")
|
||||
: t("invite.joinInBrowser")}
|
||||
</Button>
|
||||
) : null}
|
||||
{policy === null ? (
|
||||
<Button
|
||||
asChild
|
||||
className={`h-10 w-full ${
|
||||
browserSigningAvailable
|
||||
? "border border-black bg-white text-black hover:bg-black/5"
|
||||
: "bg-black text-white hover:bg-black/90 focus-visible:ring-black"
|
||||
}`}
|
||||
>
|
||||
<a
|
||||
href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
|
||||
>
|
||||
{t("invite.accept")}
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={`h-10 w-full disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70 ${
|
||||
browserSigningAvailable
|
||||
? "border border-black bg-white text-black hover:bg-black/5"
|
||||
: "bg-black text-white hover:bg-black/90 focus-visible:ring-black"
|
||||
}`}
|
||||
disabled={disabled}
|
||||
onClick={openInvite}
|
||||
>
|
||||
{t("invite.accept")}
|
||||
</Button>
|
||||
)}
|
||||
{browserJoinError ? (
|
||||
<p className="text-sm text-red-700" role="alert">
|
||||
{browserJoinError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
|
||||
{t("invite.noApp")}{" "}
|
||||
<a
|
||||
aria-expanded={needsMacChoice ? showMacChoice : undefined}
|
||||
aria-haspopup={needsMacChoice ? "dialog" : undefined}
|
||||
className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
|
||||
href={downloadUrl}
|
||||
ref={downloadTriggerRef}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
onClick={(event) => {
|
||||
if (!needsMacChoice) return;
|
||||
event.preventDefault();
|
||||
setShowMacChoice(true);
|
||||
}}
|
||||
>
|
||||
{t("invite.downloadNow")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showMacChoice && (
|
||||
<div
|
||||
aria-label={t("invite.mac.title")}
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 text-left"
|
||||
role="dialog"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) closeMacChoice();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-3xl bg-white p-7 text-black shadow-xl sm:p-9">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("invite.mac.title")}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-black/60">
|
||||
{t("invite.mac.description")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("common.close")}
|
||||
className="text-2xl leading-none text-black/60 hover:text-black"
|
||||
type="button"
|
||||
onClick={closeMacChoice}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3">
|
||||
<a
|
||||
aria-disabled={choosingMacDownload}
|
||||
className="rounded-2xl border border-black p-5 text-black no-underline hover:bg-black hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black aria-disabled:pointer-events-none aria-disabled:opacity-50"
|
||||
href={BUZZ_RELEASES_URL}
|
||||
onClick={(event) =>
|
||||
void chooseMacDownload(event, {
|
||||
operatingSystem: "macos",
|
||||
architecture: "arm64",
|
||||
})
|
||||
}
|
||||
>
|
||||
<strong className="block text-lg">
|
||||
{t("invite.mac.newer")}
|
||||
</strong>
|
||||
<span className="mt-1 block text-sm">
|
||||
{t("invite.mac.newerDescription")}
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
aria-disabled={choosingMacDownload}
|
||||
className="rounded-2xl border border-black p-5 text-black no-underline hover:bg-black hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black aria-disabled:pointer-events-none aria-disabled:opacity-50"
|
||||
href={BUZZ_RELEASES_URL}
|
||||
onClick={(event) =>
|
||||
void chooseMacDownload(event, {
|
||||
operatingSystem: "macos",
|
||||
architecture: "x64",
|
||||
})
|
||||
}
|
||||
>
|
||||
<strong className="block text-lg">
|
||||
{t("invite.mac.older")}
|
||||
</strong>
|
||||
<span className="mt-1 block text-sm">
|
||||
{t("invite.mac.olderDescription")}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-5 text-sm leading-5">
|
||||
<strong>{t("invite.mac.notSure")}</strong> {t("invite.mac.help")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{document && (
|
||||
<div
|
||||
aria-label={document.title}
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 text-left"
|
||||
role="dialog"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) setDocument(null);
|
||||
}}
|
||||
>
|
||||
<div className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-2xl bg-white p-6 text-black shadow-xl sm:p-8">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{document.title}</h2>
|
||||
<button
|
||||
aria-label={t("common.close")}
|
||||
className="text-2xl leading-none text-black/60 hover:text-black"
|
||||
type="button"
|
||||
onClick={() => setDocument(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>
|
||||
{document.markdown}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* isomorphic-git wrapper for in-browser repo browsing.
|
||||
*
|
||||
* Uses LightningFS (IndexedDB-backed) for persistence and NIP-98 auth
|
||||
* for the relay's smart HTTP git transport.
|
||||
*/
|
||||
|
||||
// isomorphic-git expects a global Buffer (Node API) for pack-file parsing,
|
||||
// tree serialization, etc. The `buffer` package (feross/buffer) is the
|
||||
// standard browser polyfill — we install it before any git imports run.
|
||||
import { Buffer } from "buffer";
|
||||
if (typeof (globalThis as Record<string, unknown>).Buffer === "undefined") {
|
||||
(globalThis as Record<string, unknown>).Buffer = Buffer;
|
||||
}
|
||||
|
||||
import LightningFS from "@isomorphic-git/lightning-fs";
|
||||
import {
|
||||
clone,
|
||||
fetch,
|
||||
log,
|
||||
readBlob,
|
||||
readTree,
|
||||
resolveRef,
|
||||
} from "isomorphic-git";
|
||||
import http from "isomorphic-git/http/web";
|
||||
import { makeNip98AuthHeader } from "@/shared/lib/nip98";
|
||||
import { relayHttpBaseUrl } from "@/shared/lib/relay-url";
|
||||
|
||||
/** Get a repo-specific LightningFS instance backed by IndexedDB. */
|
||||
export function getFs(owner: string, repoName: string): LightningFS {
|
||||
return new LightningFS(`buzz-git-${owner}-${repoName}`);
|
||||
}
|
||||
|
||||
/** Working directory inside the virtual FS. */
|
||||
export function getDir(owner: string, repoName: string): string {
|
||||
return `/${owner}/${repoName}`;
|
||||
}
|
||||
|
||||
function repoGitUrl(owner: string, repoName: string): string {
|
||||
return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The NIP-98 `u` tag URL — must match what transport.rs expects after
|
||||
* stripping `/info/refs`, `/git-upload-pack`, `/git-receive-pack`.
|
||||
* That means the full path including `.git`.
|
||||
*/
|
||||
function repoAuthUrl(owner: string, repoName: string): string {
|
||||
return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`;
|
||||
}
|
||||
|
||||
async function authHeaders(
|
||||
owner: string,
|
||||
repoName: string,
|
||||
): Promise<Record<string, string>> {
|
||||
return {
|
||||
Authorization: await makeNip98AuthHeader(
|
||||
repoAuthUrl(owner, repoName),
|
||||
"GET",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a shallow clone exists in IndexedDB. If it already exists, fetch
|
||||
* the latest for the given ref.
|
||||
*/
|
||||
export async function ensureClone(
|
||||
owner: string,
|
||||
repoName: string,
|
||||
ref: string,
|
||||
): Promise<{ fs: LightningFS; dir: string }> {
|
||||
const fs = getFs(owner, repoName);
|
||||
const dir = getDir(owner, repoName);
|
||||
const url = repoGitUrl(owner, repoName);
|
||||
const headers = await authHeaders(owner, repoName);
|
||||
|
||||
let exists = false;
|
||||
try {
|
||||
await fs.promises.stat(`${dir}/.git`);
|
||||
exists = true;
|
||||
} catch {
|
||||
// repo not cloned yet
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
try {
|
||||
await fetch({
|
||||
fs,
|
||||
http,
|
||||
dir,
|
||||
url,
|
||||
ref,
|
||||
depth: 1,
|
||||
singleBranch: true,
|
||||
headers,
|
||||
});
|
||||
} catch {
|
||||
// fetch may fail if ref hasn't changed — that's fine
|
||||
}
|
||||
} else {
|
||||
await clone({
|
||||
fs,
|
||||
http,
|
||||
dir,
|
||||
url,
|
||||
ref,
|
||||
depth: 1,
|
||||
singleBranch: true,
|
||||
noTags: true,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return { fs, dir };
|
||||
}
|
||||
|
||||
export interface TreeEntry {
|
||||
name: string;
|
||||
type: "blob" | "tree";
|
||||
mode: string;
|
||||
oid: string;
|
||||
}
|
||||
|
||||
/** Read tree entries at a given path (or root if no filepath). */
|
||||
export async function readTreeEntries(
|
||||
fs: LightningFS,
|
||||
dir: string,
|
||||
oid: string,
|
||||
filepath?: string,
|
||||
): Promise<TreeEntry[]> {
|
||||
const result = await readTree({ fs, dir, oid, filepath });
|
||||
return result.tree.map((entry) => ({
|
||||
name: entry.path,
|
||||
type: entry.type as "blob" | "tree",
|
||||
mode: entry.mode,
|
||||
oid: entry.oid,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface FileContent {
|
||||
content: string;
|
||||
isBinary: boolean;
|
||||
}
|
||||
|
||||
/** Read a blob and decode as text. Detects binary by checking for NUL bytes. */
|
||||
export async function readFileContent(
|
||||
fs: LightningFS,
|
||||
dir: string,
|
||||
oid: string,
|
||||
filepath: string,
|
||||
): Promise<FileContent> {
|
||||
const { blob } = await readBlob({ fs, dir, oid, filepath });
|
||||
|
||||
const checkLength = Math.min(blob.length, 512);
|
||||
for (let i = 0; i < checkLength; i++) {
|
||||
if (blob[i] === 0) {
|
||||
return { content: "", isBinary: true };
|
||||
}
|
||||
}
|
||||
|
||||
const content = new TextDecoder().decode(blob);
|
||||
return { content, isBinary: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline-preview caps. Different ceilings per kind:
|
||||
* - Text: a 1 MiB string is already big to render in the DOM. Over → download.
|
||||
* - Image: raster decoders handle this cheaply; cap is a sanity ceiling, not
|
||||
* a perf brake. Normal screenshots (≤ ~few MB) preview just fine.
|
||||
* - Binary: no preview cap — we always offer download, regardless of size.
|
||||
*
|
||||
* The clone in IndexedDB always holds full bytes; these are display caps only.
|
||||
*/
|
||||
export const TEXT_PREVIEW_LIMIT_BYTES = 1 * 1024 * 1024;
|
||||
export const IMAGE_PREVIEW_LIMIT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Discriminated view of a blob, suitable for rendering. The viewer component
|
||||
* is responsible for `URL.createObjectURL` / `revokeObjectURL` over `bytes` —
|
||||
* we deliberately do NOT create object URLs inside the React Query cache.
|
||||
*
|
||||
* Image-by-extension is restricted to raster formats. SVG is intentionally
|
||||
* absent: it can carry active content; we render SVG via the text path
|
||||
* (where applicable) instead.
|
||||
*
|
||||
* `too-large` carries the cap that was hit, so the viewer can explain which
|
||||
* limit applied without re-computing it.
|
||||
*/
|
||||
export type BlobView =
|
||||
| { kind: "text"; content: string; sizeBytes: number }
|
||||
| { kind: "markdown"; content: string; sizeBytes: number }
|
||||
| { kind: "html"; content: string; sizeBytes: number }
|
||||
| { kind: "image"; bytes: Uint8Array; contentType: string; sizeBytes: number }
|
||||
| { kind: "binary"; bytes: Uint8Array; sizeBytes: number }
|
||||
| {
|
||||
kind: "too-large";
|
||||
bytes: Uint8Array;
|
||||
sizeBytes: number;
|
||||
limitBytes: number;
|
||||
};
|
||||
|
||||
const RASTER_IMAGE_MIME: Readonly<Record<string, string>> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
};
|
||||
|
||||
const MARKDOWN_EXTS = new Set(["md", "markdown"]);
|
||||
const HTML_EXTS = new Set(["html", "htm"]);
|
||||
|
||||
function extOf(filepath: string): string {
|
||||
const base = filepath.split("/").pop() ?? "";
|
||||
const dot = base.lastIndexOf(".");
|
||||
if (dot <= 0) return "";
|
||||
return base.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function hasNulByte(bytes: Uint8Array): boolean {
|
||||
const n = Math.min(bytes.length, 512);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (bytes[i] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a blob into a `BlobView`. Applies per-kind preview caps.
|
||||
*
|
||||
* Order: image-by-extension first (so a 2 MiB PNG isn't rejected as oversized
|
||||
* text), then binary detection, then text/markdown decode.
|
||||
*/
|
||||
export async function readBlobView(
|
||||
fs: LightningFS,
|
||||
dir: string,
|
||||
oid: string,
|
||||
filepath: string,
|
||||
): Promise<BlobView> {
|
||||
const { blob } = await readBlob({ fs, dir, oid, filepath });
|
||||
const bytes = blob as Uint8Array;
|
||||
const sizeBytes = bytes.length;
|
||||
const ext = extOf(filepath);
|
||||
|
||||
const mime = RASTER_IMAGE_MIME[ext];
|
||||
if (mime) {
|
||||
if (sizeBytes > IMAGE_PREVIEW_LIMIT_BYTES) {
|
||||
return {
|
||||
kind: "too-large",
|
||||
bytes,
|
||||
sizeBytes,
|
||||
limitBytes: IMAGE_PREVIEW_LIMIT_BYTES,
|
||||
};
|
||||
}
|
||||
return { kind: "image", bytes, contentType: mime, sizeBytes };
|
||||
}
|
||||
|
||||
if (hasNulByte(bytes)) {
|
||||
// Binary: no preview cap. Always download.
|
||||
return { kind: "binary", bytes, sizeBytes };
|
||||
}
|
||||
|
||||
if (sizeBytes > TEXT_PREVIEW_LIMIT_BYTES) {
|
||||
return {
|
||||
kind: "too-large",
|
||||
bytes,
|
||||
sizeBytes,
|
||||
limitBytes: TEXT_PREVIEW_LIMIT_BYTES,
|
||||
};
|
||||
}
|
||||
// Fatal decode: anything that *looks* binary-ish but slipped past the NUL
|
||||
// sniff (rare-but-real for non-UTF formats without an early 0x00) falls
|
||||
// through to the binary path instead of being rendered as mojibake.
|
||||
let content: string;
|
||||
try {
|
||||
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return { kind: "binary", bytes, sizeBytes };
|
||||
}
|
||||
if (MARKDOWN_EXTS.has(ext)) {
|
||||
return { kind: "markdown", content, sizeBytes };
|
||||
}
|
||||
if (HTML_EXTS.has(ext)) {
|
||||
return { kind: "html", content, sizeBytes };
|
||||
}
|
||||
return { kind: "text", content, sizeBytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline a repo's same-repo relative assets into one self-contained HTML
|
||||
* string, suitable for rendering inside a sandboxed iframe (which has no
|
||||
* notion of the repo's directory tree and cannot fetch siblings).
|
||||
*
|
||||
* Only *relative* `<script src>`, `<link href>`, and `<img src>` are
|
||||
* resolved — against the HTML file's own directory, scoped to paths that
|
||||
* exist in the clone. Absolute paths (`/x`) and external URLs
|
||||
* (`http(s):`, `data:`, `//host`, `#frag`) are left untouched: we never
|
||||
* reach outside the repo or rewrite something the author meant for the
|
||||
* network.
|
||||
*
|
||||
* Assets are inlined as `data:` URLs so the result is fully detached — it
|
||||
* carries no live `blob:` handles that would need revoking. This is a
|
||||
* display transform on a copy; the clone in IndexedDB is unchanged.
|
||||
*/
|
||||
const ASSET_MIME: Readonly<Record<string, string>> = {
|
||||
...RASTER_IMAGE_MIME,
|
||||
js: "text/javascript",
|
||||
mjs: "text/javascript",
|
||||
css: "text/css",
|
||||
json: "application/json",
|
||||
svg: "image/svg+xml",
|
||||
woff: "font/woff",
|
||||
woff2: "font/woff2",
|
||||
};
|
||||
|
||||
function isExternalRef(ref: string): boolean {
|
||||
// Absolute path, protocol URL, protocol-relative, fragment, or empty.
|
||||
return (
|
||||
ref === "" ||
|
||||
ref.startsWith("/") ||
|
||||
ref.startsWith("#") ||
|
||||
ref.startsWith("data:") ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(ref) ||
|
||||
ref.startsWith("//")
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve `dir`-relative `ref` (e.g. `../js/app.js`) to a clone path. */
|
||||
function resolveRelative(baseDir: string, ref: string): string | null {
|
||||
const clean = ref.split(/[?#]/)[0];
|
||||
const parts = baseDir ? baseDir.split("/") : [];
|
||||
for (const seg of clean.split("/")) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") {
|
||||
if (parts.length === 0) return null; // escapes repo root
|
||||
parts.pop();
|
||||
} else {
|
||||
parts.push(seg);
|
||||
}
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function bytesToDataUrl(bytes: Uint8Array, mime: string): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return `data:${mime};base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
export async function resolveHtmlAssets(
|
||||
fs: LightningFS,
|
||||
dir: string,
|
||||
oid: string,
|
||||
htmlPath: string,
|
||||
html: string,
|
||||
): Promise<string> {
|
||||
const slash = htmlPath.lastIndexOf("/");
|
||||
const baseDir = slash >= 0 ? htmlPath.slice(0, slash) : "";
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
const targets: Array<{ el: Element; attr: string }> = [
|
||||
...[...doc.querySelectorAll("script[src]")].map((el) => ({
|
||||
el,
|
||||
attr: "src",
|
||||
})),
|
||||
...[...doc.querySelectorAll("link[href]")].map((el) => ({
|
||||
el,
|
||||
attr: "href",
|
||||
})),
|
||||
...[...doc.querySelectorAll("img[src]")].map((el) => ({ el, attr: "src" })),
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
targets.map(async ({ el, attr }) => {
|
||||
const ref = el.getAttribute(attr);
|
||||
if (!ref || isExternalRef(ref)) return;
|
||||
const path = resolveRelative(baseDir, ref);
|
||||
if (!path) return;
|
||||
const mime = ASSET_MIME[extOf(path)] ?? "application/octet-stream";
|
||||
try {
|
||||
const { blob } = await readBlob({ fs, dir, oid, filepath: path });
|
||||
el.setAttribute(attr, bytesToDataUrl(blob as Uint8Array, mime));
|
||||
} catch {
|
||||
// Sibling not in the clone (e.g. a deferred subtree): leave the
|
||||
// reference as-is. It will simply fail to load in the sandbox.
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return `<!doctype html>\n${doc.documentElement.outerHTML}`;
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
oid: string;
|
||||
message: string;
|
||||
author: {
|
||||
name: string;
|
||||
email: string;
|
||||
timestamp: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Get recent commits for a ref. */
|
||||
export async function getCommitLog(
|
||||
fs: LightningFS,
|
||||
dir: string,
|
||||
ref: string,
|
||||
depth = 20,
|
||||
): Promise<CommitInfo[]> {
|
||||
const commits = await log({ fs, dir, ref, depth });
|
||||
return commits.map((c) => ({
|
||||
oid: c.oid,
|
||||
message: c.commit.message,
|
||||
author: {
|
||||
name: c.commit.author.name,
|
||||
email: c.commit.author.email,
|
||||
timestamp: c.commit.author.timestamp,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export interface ReadmeResult {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const README_PATTERNS = ["readme.md", "readme", "readme.rst", "readme.txt"];
|
||||
|
||||
/** Find and read a README file from the root tree. */
|
||||
export async function findReadme(
|
||||
fs: LightningFS,
|
||||
dir: string,
|
||||
ref: string,
|
||||
): Promise<ReadmeResult | null> {
|
||||
const oid = await resolveRef({ fs, dir, ref });
|
||||
const entries = await readTreeEntries(fs, dir, oid);
|
||||
|
||||
for (const pattern of README_PATTERNS) {
|
||||
const entry = entries.find(
|
||||
(e) => e.type === "blob" && e.name.toLowerCase() === pattern,
|
||||
);
|
||||
if (entry) {
|
||||
const file = await readFileContent(fs, dir, oid, entry.name);
|
||||
if (!file.isBinary) {
|
||||
return { filename: entry.name, content: file.content };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Repo } from "./use-repos";
|
||||
import type {
|
||||
BlobView,
|
||||
CommitInfo,
|
||||
ReadmeResult,
|
||||
TreeEntry,
|
||||
} from "./git-client";
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const people = {
|
||||
ada: "1".repeat(64),
|
||||
grace: "2".repeat(64),
|
||||
linus: "3".repeat(64),
|
||||
margaret: "4".repeat(64),
|
||||
};
|
||||
|
||||
/** Local-only data for previewing the populated repositories state. */
|
||||
export const mockRepos: Repo[] = [
|
||||
{
|
||||
id: "buzz-desktop",
|
||||
name: "buzz-desktop",
|
||||
description:
|
||||
"The desktop client for collaborating with people and agents across Buzz communities.",
|
||||
cloneUrls: ["https://example.com/buzz-desktop.git"],
|
||||
webUrl: null,
|
||||
channelId: null,
|
||||
owner: people.ada,
|
||||
contributors: [people.grace, people.linus],
|
||||
createdAt: now - 60 * 24,
|
||||
},
|
||||
{
|
||||
id: "agent-harness",
|
||||
name: "agent-harness",
|
||||
description:
|
||||
"Tools and shared workflows for launching and coordinating coding agents.",
|
||||
cloneUrls: ["https://example.com/agent-harness.git"],
|
||||
webUrl: null,
|
||||
channelId: null,
|
||||
owner: people.grace,
|
||||
contributors: [people.ada, people.margaret],
|
||||
createdAt: now - 60 * 60 * 3,
|
||||
},
|
||||
{
|
||||
id: "relay-infrastructure",
|
||||
name: "relay-infrastructure",
|
||||
description:
|
||||
"Infrastructure and deployment configuration for community relays.",
|
||||
cloneUrls: ["https://example.com/relay-infrastructure.git"],
|
||||
webUrl: null,
|
||||
channelId: null,
|
||||
owner: people.linus,
|
||||
contributors: [people.margaret],
|
||||
createdAt: now - 60 * 60 * 24 * 2,
|
||||
},
|
||||
{
|
||||
id: "design-system",
|
||||
name: "design-system",
|
||||
description: "Shared foundations, components, and interaction patterns.",
|
||||
cloneUrls: ["https://example.com/design-system.git"],
|
||||
webUrl: null,
|
||||
channelId: null,
|
||||
owner: people.margaret,
|
||||
contributors: [people.ada, people.grace, people.linus],
|
||||
createdAt: now - 60 * 60 * 24 * 8,
|
||||
},
|
||||
];
|
||||
|
||||
export function getMockRepo(repoId: string): Repo | undefined {
|
||||
if (!import.meta.env.DEV) return undefined;
|
||||
return mockRepos.find((repo) => repo.id === repoId);
|
||||
}
|
||||
|
||||
export const mockRepoTree: TreeEntry[] = [
|
||||
{
|
||||
name: "src",
|
||||
type: "tree",
|
||||
mode: "040000",
|
||||
oid: "1".repeat(40),
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
type: "blob",
|
||||
mode: "100644",
|
||||
oid: "2".repeat(40),
|
||||
},
|
||||
{
|
||||
name: "package.json",
|
||||
type: "blob",
|
||||
mode: "100644",
|
||||
oid: "3".repeat(40),
|
||||
},
|
||||
];
|
||||
|
||||
export const mockRepoCommits: CommitInfo[] = [
|
||||
{
|
||||
oid: "a".repeat(40),
|
||||
message: "Polish repository browsing styles",
|
||||
author: {
|
||||
name: "Ada",
|
||||
email: "ada@example.com",
|
||||
timestamp: now - 60 * 24,
|
||||
},
|
||||
},
|
||||
{
|
||||
oid: "b".repeat(40),
|
||||
message: "Add repository empty states",
|
||||
author: {
|
||||
name: "Grace",
|
||||
email: "grace@example.com",
|
||||
timestamp: now - 60 * 60 * 5,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const mockRepoReadme: ReadmeResult = {
|
||||
filename: "README.md",
|
||||
content:
|
||||
"# Buzz Desktop\n\nA focused community for people and agents to collaborate.\n\n## Getting started\n\nInstall dependencies, then start the development app.",
|
||||
};
|
||||
|
||||
export function getMockBlob(
|
||||
repoId: string,
|
||||
filepath: string,
|
||||
): BlobView | undefined {
|
||||
if (!getMockRepo(repoId)) return undefined;
|
||||
|
||||
if (filepath === "README.md") {
|
||||
return {
|
||||
kind: "markdown",
|
||||
content: mockRepoReadme.content,
|
||||
sizeBytes: mockRepoReadme.content.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (filepath === "package.json") {
|
||||
const content = `${JSON.stringify(
|
||||
{
|
||||
name: repoId,
|
||||
private: true,
|
||||
scripts: { dev: "vite", build: "tsc && vite build" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
return { kind: "text", content, sizeBytes: content.length };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
|
||||
export function ConnectButton({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const deepLink = `buzz://connect?relay=${encodeURIComponent(relayWsUrl())}`;
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className={`bg-black text-white hover:bg-black/90 focus-visible:ring-black dark:bg-white dark:text-black dark:hover:bg-white/90 dark:focus-visible:ring-white ${className ?? ""}`}
|
||||
>
|
||||
<a href={deepLink}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t("common.openInBuzz")}
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
|
||||
import type { Repo } from "../use-repos";
|
||||
import { ConnectButton } from "./ConnectButton";
|
||||
import { PubkeyAvatar } from "./PubkeyAvatar";
|
||||
|
||||
const MAX_AVATARS = 20;
|
||||
|
||||
export function OrgSidebar({ repos }: { repos: Repo[] }) {
|
||||
const { t } = useI18n();
|
||||
const uniquePubkeys = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const repo of repos) {
|
||||
set.add(repo.owner);
|
||||
for (const c of repo.contributors) {
|
||||
set.add(c);
|
||||
}
|
||||
}
|
||||
return [...set];
|
||||
}, [repos]);
|
||||
|
||||
const visiblePubkeys = uniquePubkeys.slice(0, MAX_AVATARS);
|
||||
const overflowCount = uniquePubkeys.length - MAX_AVATARS;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Open in Buzz */}
|
||||
<ConnectButton className="w-full" />
|
||||
|
||||
{/* People section */}
|
||||
{uniquePubkeys.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<Users className="h-4 w-4" />
|
||||
{t("common.people")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visiblePubkeys.map((pk) => (
|
||||
<PubkeyAvatar key={pk} pubkey={pk} />
|
||||
))}
|
||||
</div>
|
||||
{overflowCount > 0 && (
|
||||
<span className="mt-2 block text-xs text-black/50 dark:text-white/50">
|
||||
{t("repos.peopleCount", { count: uniquePubkeys.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
/** Simple hash of a hex pubkey to a hue value (0-360). */
|
||||
function pubkeyToHue(hex: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < hex.length; i++) {
|
||||
hash = (hash * 31 + hex.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash) % 360;
|
||||
}
|
||||
|
||||
export function PubkeyAvatar({
|
||||
pubkey,
|
||||
size = "md",
|
||||
}: {
|
||||
pubkey: string;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const hue = pubkeyToHue(pubkey);
|
||||
const sizeClasses = size === "sm" ? "h-6 w-6 text-[10px]" : "h-8 w-8 text-xs";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-lg font-medium text-white ${sizeClasses}`}
|
||||
style={{ backgroundColor: `hsl(${hue}, 55%, 45%)` }}
|
||||
>
|
||||
{pubkey.slice(0, 2)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="font-mono text-xs">{pubkey}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Renders a single repo blob fetched via `useGitBlob`. Designed to be safe by
|
||||
* construction: no JS/HTML execution path, no SVG-as-image (SVG can carry
|
||||
* active content; we render it as text instead), and a hard preview-size cap
|
||||
* with a download fallback for anything over the limit.
|
||||
*
|
||||
* Object URLs for image/binary are created in a local effect and revoked on
|
||||
* unmount or input change — they are never cached inside React Query results.
|
||||
*/
|
||||
import { ArrowLeft, Check, Copy, Download, FileText, Play } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
import type { BlobView } from "../git-client";
|
||||
import { useGitBlob, useGitHtmlDoc } from "../use-git-browse";
|
||||
import { useRepoContext } from "../use-repo-context";
|
||||
import { getMockBlob } from "../mock-repos";
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
return path.split("/").pop() ?? path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable object-URL for a byte buffer. Revokes on dependency change / unmount.
|
||||
* The viewer creates one per render-lifetime — the cache layer only stores bytes.
|
||||
*/
|
||||
function useObjectUrl(
|
||||
bytes: Uint8Array | null,
|
||||
contentType: string,
|
||||
): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!bytes) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
// The cast normalises `Uint8Array<ArrayBufferLike>` (isomorphic-git's
|
||||
// return shape) to `Uint8Array<ArrayBuffer>` so it's accepted as a `BlobPart`
|
||||
// under strict TS lib types.
|
||||
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], {
|
||||
type: contentType,
|
||||
});
|
||||
const next = URL.createObjectURL(blob);
|
||||
setUrl(next);
|
||||
return () => {
|
||||
URL.revokeObjectURL(next);
|
||||
};
|
||||
}, [bytes, contentType]);
|
||||
return url;
|
||||
}
|
||||
|
||||
function CopyTextButton({ content }: { content: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error(t("common.copyFailed"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
<span className="ml-2">{t("common.copy")}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadButton({
|
||||
bytes,
|
||||
contentType,
|
||||
filename,
|
||||
}: {
|
||||
bytes: Uint8Array;
|
||||
contentType: string;
|
||||
filename: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const url = useObjectUrl(bytes, contentType);
|
||||
if (!url) return null;
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
|
||||
>
|
||||
<a href={url} download={filename}>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="ml-2">{t("common.download")}</span>
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function TextView({ content }: { content: string }) {
|
||||
// Plain monospace render. Line numbers would be nice but require a list of
|
||||
// keyed children for an immutable text dump; not worth the linter dance for
|
||||
// v1. The browser handles wrapping/scrolling via `<pre>`.
|
||||
return (
|
||||
<pre className="overflow-auto whitespace-pre rounded-lg border border-black/10 bg-white/50 p-4 font-mono text-sm leading-6 text-black dark:border-white/10 dark:bg-white/5 dark:text-white">
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageView({
|
||||
bytes,
|
||||
contentType,
|
||||
filename,
|
||||
}: {
|
||||
bytes: Uint8Array;
|
||||
contentType: string;
|
||||
filename: string;
|
||||
}) {
|
||||
const url = useObjectUrl(bytes, contentType);
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="flex justify-center rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
className="max-h-[80vh] max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a repo's HTML in a sandboxed iframe.
|
||||
*
|
||||
* SECURITY — the entire trust boundary is the `sandbox` attribute below.
|
||||
* `allow-scripts` lets the page's JS run; the deliberate ABSENCE of
|
||||
* `allow-same-origin` forces the frame to an opaque (`null`) origin, so its
|
||||
* scripts CANNOT read the parent's cookies, IndexedDB, localStorage, relay
|
||||
* session, or NIP-98 auth — even though we render on the same document origin.
|
||||
* Do not add `allow-same-origin`: that would hand pushed code the user's
|
||||
* session. `srcDoc` carries the asset-inlined doc; nothing reaches the network
|
||||
* for same-repo content.
|
||||
*/
|
||||
const RUN_SANDBOX = "allow-scripts";
|
||||
|
||||
function HtmlRunView({ doc }: { doc: string }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<iframe
|
||||
title={t("blob.sandboxTitle")}
|
||||
srcDoc={doc}
|
||||
sandbox={RUN_SANDBOX}
|
||||
className="h-[80vh] w-full rounded-lg border border-black/10 bg-white dark:border-white/10"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewerBody({
|
||||
view,
|
||||
filename,
|
||||
htmlDoc,
|
||||
}: {
|
||||
view: BlobView;
|
||||
filename: string;
|
||||
htmlDoc: string | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
switch (view.kind) {
|
||||
case "text":
|
||||
return <TextView content={view.content} />;
|
||||
case "markdown":
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{view.content}</Markdown>
|
||||
</div>
|
||||
);
|
||||
case "html":
|
||||
// `htmlDoc` is the asset-inlined doc, present only once the user opts in
|
||||
// via "Run"; until then (and while it resolves) we show the source.
|
||||
return htmlDoc !== null ? (
|
||||
<HtmlRunView doc={htmlDoc} />
|
||||
) : (
|
||||
<TextView content={view.content} />
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<ImageView
|
||||
bytes={view.bytes}
|
||||
contentType={view.contentType}
|
||||
filename={filename}
|
||||
/>
|
||||
);
|
||||
case "binary":
|
||||
return (
|
||||
<div className="rounded-lg border border-black/10 bg-white/50 p-6 text-sm text-black/60 dark:border-white/10 dark:bg-white/5 dark:text-white/60">
|
||||
{t("blob.binary", { size: formatBytes(view.sizeBytes) })}
|
||||
</div>
|
||||
);
|
||||
case "too-large":
|
||||
return (
|
||||
<div className="rounded-lg border border-black/10 bg-white/50 p-6 text-sm text-black/60 dark:border-white/10 dark:bg-white/5 dark:text-white/60">
|
||||
{t("blob.tooLarge", {
|
||||
size: formatBytes(view.sizeBytes),
|
||||
limit: formatBytes(view.limitBytes),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function RepoBlobPage() {
|
||||
const { t } = useI18n();
|
||||
const { repoId, _splat } = useParams({ from: "/repos/$repoId/blob/$" });
|
||||
const filepath = _splat ?? "";
|
||||
const preview =
|
||||
import.meta.env.DEV &&
|
||||
new URLSearchParams(window.location.search).get("preview") ===
|
||||
"repositories";
|
||||
const mockView = preview ? getMockBlob(repoId, filepath) : undefined;
|
||||
const showMockBlob = Boolean(mockView);
|
||||
const {
|
||||
owner,
|
||||
repoName,
|
||||
defaultRef,
|
||||
isLoading: ctxLoading,
|
||||
error: ctxError,
|
||||
} = useRepoContext(repoId, { preview: showMockBlob });
|
||||
|
||||
const browseOwner = showMockBlob ? "" : owner;
|
||||
|
||||
const {
|
||||
data: fetchedView,
|
||||
isLoading: isViewLoading,
|
||||
error,
|
||||
} = useGitBlob(browseOwner, repoName, defaultRef, filepath);
|
||||
const view = mockView ?? fetchedView;
|
||||
const isLoading = showMockBlob ? false : isViewLoading;
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const isHtml = view?.kind === "html";
|
||||
const { data: htmlDoc, isFetching: htmlFetching } = useGitHtmlDoc(
|
||||
owner,
|
||||
repoName,
|
||||
defaultRef,
|
||||
filepath,
|
||||
isHtml ? view.content : "",
|
||||
running && isHtml,
|
||||
);
|
||||
|
||||
const filename = basename(filepath);
|
||||
|
||||
if (ctxError) {
|
||||
return (
|
||||
<div className="flex-1 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
|
||||
<BackLink repoId={repoId} preview={showMockBlob} />
|
||||
<p className="mt-4 text-sm text-destructive">
|
||||
{t("blob.loadRepositoryFailed")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
|
||||
<BackLink repoId={repoId} preview={showMockBlob} />
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<FileText className="h-4 w-4 text-black/50 dark:text-white/50" />
|
||||
<h1 className="min-w-0 truncate font-mono text-sm">{filepath}</h1>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{view &&
|
||||
(view.kind === "text" ||
|
||||
view.kind === "markdown" ||
|
||||
view.kind === "html") && (
|
||||
<CopyTextButton content={view.content} />
|
||||
)}
|
||||
{isHtml && (
|
||||
<Button
|
||||
variant={running ? "secondary" : "default"}
|
||||
size="sm"
|
||||
className="bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
|
||||
onClick={() => setRunning((r) => !r)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="ml-2">
|
||||
{running ? t("blob.showSource") : t("blob.run")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{view &&
|
||||
view.kind !== "text" &&
|
||||
view.kind !== "markdown" &&
|
||||
view.kind !== "html" && (
|
||||
<DownloadButton
|
||||
bytes={view.bytes}
|
||||
contentType={
|
||||
view.kind === "image"
|
||||
? view.contentType
|
||||
: "application/octet-stream"
|
||||
}
|
||||
filename={filename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{ctxLoading || isLoading ? (
|
||||
<div className="h-32 animate-pulse rounded-lg bg-black/10 dark:bg-white/10" />
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">{t("blob.loadFileFailed")}</p>
|
||||
) : view ? (
|
||||
<ViewerBody
|
||||
view={view}
|
||||
filename={filename}
|
||||
htmlDoc={running && !htmlFetching ? (htmlDoc ?? null) : null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink({ repoId, preview }: { repoId: string; preview: boolean }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Link
|
||||
to="/repos/$repoId"
|
||||
params={{ repoId }}
|
||||
search={preview ? { preview: "repositories" } : undefined}
|
||||
className="inline-flex items-center gap-1 text-sm text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("blob.backToRepository")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { GitCommit } from "lucide-react";
|
||||
import { relativeTime } from "@/shared/lib/relative-time";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
import type { CommitInfo } from "../git-client";
|
||||
|
||||
function CommitRow({ commit }: { commit: CommitInfo }) {
|
||||
const { locale, t } = useI18n();
|
||||
const firstLine = commit.message.split("\n")[0];
|
||||
return (
|
||||
<div className="flex items-start gap-3 border-b border-black/10 px-3 py-2.5 text-sm text-black last:border-b-0 dark:border-white/10 dark:text-white">
|
||||
<GitCommit className="mt-0.5 h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{firstLine}</p>
|
||||
<p className="mt-0.5 text-xs text-black/50 dark:text-white/50">
|
||||
{t("repos.committed", {
|
||||
author: commit.author.name,
|
||||
time: relativeTime(commit.author.timestamp, locale),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<code className="shrink-0 self-center rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-black/50 dark:bg-white/10 dark:text-white/50">
|
||||
{commit.oid.slice(0, 7)}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepoCommitsSection({
|
||||
commits,
|
||||
isLoading,
|
||||
}: {
|
||||
commits: CommitInfo[] | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
{t("repos.recentCommits")}
|
||||
</h2>
|
||||
<div className="rounded-lg border border-black/10 dark:border-white/10">
|
||||
{["sk-1", "sk-2", "sk-3"].map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 border-b border-black/10 px-3 py-2.5 last:border-b-0 dark:border-white/10"
|
||||
>
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-3 w-32 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!commits || commits.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
{t("repos.recentCommits")}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-lg border border-black/10 bg-white/50 dark:border-white/10 dark:bg-white/5">
|
||||
{commits.map((commit) => (
|
||||
<CommitRow key={commit.oid} commit={commit} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookMarked,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { relativeTime } from "@/shared/lib/relative-time";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
import { useRepoRefs } from "../use-repo-refs";
|
||||
import { useRepo } from "../use-repos";
|
||||
import {
|
||||
getMockRepo,
|
||||
mockRepoCommits,
|
||||
mockRepoReadme,
|
||||
mockRepoTree,
|
||||
} from "../mock-repos";
|
||||
import type { CommitInfo, ReadmeResult, TreeEntry } from "../git-client";
|
||||
import { useGitTree, useGitLog, useGitReadme } from "../use-git-browse";
|
||||
import { ConnectButton } from "./ConnectButton";
|
||||
import { PubkeyAvatar } from "./PubkeyAvatar";
|
||||
import { RepoRefsSection } from "./RepoRefsSection";
|
||||
import { RepoTreeSection } from "./RepoTreeSection";
|
||||
import { RepoCommitsSection } from "./RepoCommitsSection";
|
||||
import { RepoReadmeSection } from "./RepoReadmeSection";
|
||||
|
||||
function CopyableUrl({ url }: { url: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error(t("common.copyFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-black/10 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
|
||||
<code className="min-w-0 flex-1 truncate text-sm text-black dark:text-white">
|
||||
{url}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 text-black/50 hover:text-black dark:text-white/50 dark:hover:text-white"
|
||||
aria-label={t("repos.copyCloneUrl")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 dark:bg-[#171717]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="h-5 w-24 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="mt-6 h-8 w-64 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="mt-3 h-5 w-96 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="mt-8 space-y-3">
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-10 w-full animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
<aside className="hidden w-72 shrink-0 lg:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackToRepositories({
|
||||
mockPreview = false,
|
||||
}: {
|
||||
mockPreview?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const className =
|
||||
"inline-flex items-center gap-1 text-sm text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white";
|
||||
const content = (
|
||||
<>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("repos.backToList")}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/"
|
||||
search={mockPreview ? { preview: "repositories" } : undefined}
|
||||
className={className}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type Tab = "code" | "commits";
|
||||
|
||||
function RepoTabs({
|
||||
repoId,
|
||||
treeEntries,
|
||||
treeLoading,
|
||||
commits,
|
||||
commitsLoading,
|
||||
readme,
|
||||
readmeLoading,
|
||||
preview,
|
||||
}: {
|
||||
repoId: string;
|
||||
treeEntries: TreeEntry[] | undefined;
|
||||
treeLoading: boolean;
|
||||
commits: CommitInfo[] | undefined;
|
||||
commitsLoading: boolean;
|
||||
readme: ReadmeResult | null | undefined;
|
||||
readmeLoading: boolean;
|
||||
preview: boolean;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("code");
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-black/10 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("code")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
tab === "code"
|
||||
? "border-b-2 border-black text-black dark:border-white dark:text-white"
|
||||
: "text-black/50 hover:text-black dark:text-white/50 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{t("repos.code")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("commits")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
tab === "commits"
|
||||
? "border-b-2 border-black text-black dark:border-white dark:text-white"
|
||||
: "text-black/50 hover:text-black dark:text-white/50 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{t("repos.commits")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === "code" && (
|
||||
<>
|
||||
<RepoTreeSection
|
||||
entries={treeEntries}
|
||||
isLoading={treeLoading}
|
||||
repoId={repoId}
|
||||
preview={preview}
|
||||
/>
|
||||
<RepoReadmeSection readme={readme} isLoading={readmeLoading} />
|
||||
</>
|
||||
)}
|
||||
{tab === "commits" && (
|
||||
<RepoCommitsSection commits={commits} isLoading={commitsLoading} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepoDetailPage() {
|
||||
const { locale, t } = useI18n();
|
||||
const { repoId } = useParams({ from: "/repos/$repoId" });
|
||||
const preview =
|
||||
import.meta.env.DEV &&
|
||||
new URLSearchParams(window.location.search).get("preview") ===
|
||||
"repositories";
|
||||
const mockRepo = preview ? getMockRepo(repoId) : undefined;
|
||||
const showMockRepo = Boolean(mockRepo);
|
||||
const {
|
||||
data: repo,
|
||||
isLoading,
|
||||
error,
|
||||
} = useRepo(repoId, {
|
||||
preview: showMockRepo,
|
||||
});
|
||||
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId, {
|
||||
preview: showMockRepo,
|
||||
});
|
||||
|
||||
const defaultRef = refs?.head?.ref ?? "main";
|
||||
const owner = repo?.owner ?? "";
|
||||
const repoName = repo?.id ?? "";
|
||||
const browseOwner = showMockRepo ? "" : owner;
|
||||
|
||||
const {
|
||||
data: fetchedTreeEntries,
|
||||
isLoading: isTreeLoading,
|
||||
error: treeError,
|
||||
} = useGitTree(browseOwner, repoName, defaultRef);
|
||||
const {
|
||||
data: fetchedCommits,
|
||||
isLoading: areCommitsLoading,
|
||||
error: commitsError,
|
||||
} = useGitLog(browseOwner, repoName, defaultRef);
|
||||
const { data: fetchedReadme, isLoading: isReadmeLoading } = useGitReadme(
|
||||
browseOwner,
|
||||
repoName,
|
||||
defaultRef,
|
||||
);
|
||||
const treeEntries = showMockRepo ? mockRepoTree : fetchedTreeEntries;
|
||||
const commits = showMockRepo ? mockRepoCommits : fetchedCommits;
|
||||
const readme = showMockRepo ? mockRepoReadme : fetchedReadme;
|
||||
const treeLoading = showMockRepo ? false : isTreeLoading;
|
||||
const commitsLoading = showMockRepo ? false : areCommitsLoading;
|
||||
const readmeLoading = showMockRepo ? false : isReadmeLoading;
|
||||
|
||||
// Surface clone/browse errors — these are otherwise silent
|
||||
const browseError = treeError || commitsError;
|
||||
useEffect(() => {
|
||||
if (browseError) {
|
||||
console.error("[git-browse]", browseError);
|
||||
}
|
||||
}, [browseError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("repos.loadRepositoryFailed"), {
|
||||
description: t("common.tryAgain"),
|
||||
});
|
||||
}
|
||||
}, [error, t]);
|
||||
|
||||
if (isLoading) return <DetailSkeleton />;
|
||||
|
||||
if (!repo) {
|
||||
return (
|
||||
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
|
||||
<div className="min-w-0 flex-1">
|
||||
<BackToRepositories />
|
||||
<div className="mt-12 text-center">
|
||||
<BookMarked className="mx-auto h-10 w-10 text-black/50 dark:text-white/50" />
|
||||
<h1 className="mt-4 text-xl font-semibold text-black dark:text-white">
|
||||
{t("repos.notFoundTitle")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-black/60 dark:text-white/60">
|
||||
{t("repos.notFoundDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="hidden w-72 shrink-0 lg:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
|
||||
{/* Main content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Back link */}
|
||||
<BackToRepositories mockPreview={preview} />
|
||||
|
||||
{/* Mobile-only connect button */}
|
||||
<div className="mt-4 lg:hidden">
|
||||
<ConnectButton className="w-full" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<BookMarked className="h-6 w-6 shrink-0 text-black/50 dark:text-white/50" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-black dark:text-white">
|
||||
{repo.name}
|
||||
</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-black/15 text-black/60 dark:border-white/15 dark:text-white/60"
|
||||
>
|
||||
{t("common.public")}
|
||||
</Badge>
|
||||
</div>
|
||||
{repo.description && (
|
||||
<p className="mt-2 text-sm leading-relaxed text-black/60 dark:text-white/60">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-black/50 dark:text-white/50">
|
||||
{t("common.updated", {
|
||||
time: relativeTime(repo.createdAt, locale),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Refs & HEAD */}
|
||||
<RepoRefsSection refs={refs} isLoading={refsLoading} />
|
||||
|
||||
{/* Clone/browse error banner */}
|
||||
{browseError && (
|
||||
<div className="mt-6 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{t("repos.loadContentsFailed")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<RepoTabs
|
||||
repoId={repoId}
|
||||
treeEntries={treeEntries}
|
||||
treeLoading={treeLoading}
|
||||
commits={commits}
|
||||
commitsLoading={commitsLoading}
|
||||
readme={readme}
|
||||
readmeLoading={readmeLoading}
|
||||
preview={showMockRepo}
|
||||
/>
|
||||
|
||||
{/* Clone URLs */}
|
||||
{repo.cloneUrls.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 text-sm font-semibold text-black dark:text-white">
|
||||
{t("repos.clone")}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{repo.cloneUrls.map((url) => (
|
||||
<CopyableUrl key={url} url={url} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External link — validate scheme to prevent javascript: XSS */}
|
||||
{(() => {
|
||||
if (!repo.webUrl) return null;
|
||||
let safe: string | null = null;
|
||||
try {
|
||||
safe = /^https?:/.test(new URL(repo.webUrl).protocol)
|
||||
? repo.webUrl
|
||||
: null;
|
||||
} catch {
|
||||
safe = null;
|
||||
}
|
||||
if (!safe) return null;
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
|
||||
asChild
|
||||
>
|
||||
<a href={safe} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t("repos.viewOnWeb")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Channel link */}
|
||||
{repo.channelId && (
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
|
||||
asChild
|
||||
>
|
||||
<a href={`/channels/${repo.channelId}`}>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{t("repos.viewChannel")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden w-72 shrink-0 border-l border-black/10 pl-8 dark:border-white/10 lg:block">
|
||||
<div className="space-y-6">
|
||||
{/* Open in Buzz */}
|
||||
<ConnectButton className="w-full" />
|
||||
|
||||
{/* People */}
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<Users className="h-4 w-4" />
|
||||
{t("common.people")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PubkeyAvatar pubkey={repo.owner} />
|
||||
{repo.contributors
|
||||
.filter((c) => c !== repo.owner)
|
||||
.map((c) => (
|
||||
<PubkeyAvatar key={c} pubkey={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { BookMarked } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { relativeTime } from "@/shared/lib/relative-time";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
import type { Repo } from "../use-repos";
|
||||
|
||||
export function RepoListItem({
|
||||
repo,
|
||||
preview = false,
|
||||
}: {
|
||||
repo: Repo;
|
||||
preview?: boolean;
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
return (
|
||||
<div className="py-6 text-black dark:text-white">
|
||||
{/* Row 1: Name + badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<BookMarked className="h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
|
||||
<Link
|
||||
to="/repos/$repoId"
|
||||
params={{ repoId: repo.id }}
|
||||
search={preview ? { preview: "repositories" } : undefined}
|
||||
className="text-lg font-semibold text-black underline-offset-4 hover:text-black/70 hover:underline dark:text-white dark:hover:text-white/70"
|
||||
>
|
||||
{repo.name}
|
||||
</Link>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 border-black/15 text-black/60 dark:border-white/15 dark:text-white/60"
|
||||
>
|
||||
{t("common.public")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Description */}
|
||||
{repo.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-black/60 dark:text-white/60">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Row 3: Metadata */}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-black/50 dark:text-white/50">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default font-mono">
|
||||
{truncatePubkey(repo.owner)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{repo.owner}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span>
|
||||
{t("common.updated", { time: relativeTime(repo.createdAt, locale) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BookOpen } from "lucide-react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ReadmeResult } from "../git-client";
|
||||
|
||||
export function RepoReadmeSection({
|
||||
readme,
|
||||
isLoading,
|
||||
}: {
|
||||
readme: ReadmeResult | null | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
README
|
||||
</h2>
|
||||
<div className="space-y-2 rounded-lg border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-4 w-5/6 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!readme) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
{readme.filename}
|
||||
</h2>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{readme.content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { GitBranch, Hash, Tag } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
import type { RepoRefs } from "../use-repo-refs";
|
||||
|
||||
export function RepoRefsSection({
|
||||
refs,
|
||||
isLoading,
|
||||
}: {
|
||||
refs: RepoRefs | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
if (isLoading) return null;
|
||||
|
||||
const hasRefs = refs && (refs.branches.length > 0 || refs.tags.length > 0);
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{hasRefs ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-black/60 dark:text-white/60">
|
||||
{refs.head && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-black/5 text-black dark:bg-white/10 dark:text-white"
|
||||
>
|
||||
<GitBranch className="mr-1 h-4 w-4" />
|
||||
{refs.head.ref}
|
||||
</Badge>
|
||||
{refs.head.sha && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-black/15 font-mono text-xs text-black/60 dark:border-white/15 dark:text-white/60"
|
||||
>
|
||||
<Hash className="mr-0.5 h-4 w-4" />
|
||||
{refs.head.sha.slice(0, 7)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-black/30 dark:text-white/30">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
{t(
|
||||
refs.branches.length === 1
|
||||
? "repos.branch.one"
|
||||
: "repos.branch.other",
|
||||
{ count: refs.branches.length },
|
||||
)}
|
||||
</span>
|
||||
<span className="text-black/30 dark:text-white/30">·</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag className="h-4 w-4" />
|
||||
{t(refs.tags.length === 1 ? "repos.tag.one" : "repos.tag.other", {
|
||||
count: refs.tags.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-black/60 dark:text-white/60">
|
||||
{t("repos.noCommits")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { File, Folder } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { TreeEntry } from "../git-client";
|
||||
|
||||
function TreeRow({
|
||||
entry,
|
||||
repoId,
|
||||
preview,
|
||||
}: {
|
||||
entry: TreeEntry;
|
||||
repoId: string;
|
||||
preview: boolean;
|
||||
}) {
|
||||
if (entry.type === "tree") {
|
||||
// Sub-tree navigation is deferred — show folders as visibly non-clickable
|
||||
// so the affordance matches the behaviour.
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-black/10 px-3 py-2 text-sm text-black/60 last:border-b-0 dark:border-white/10 dark:text-white/60"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<Folder className="h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
|
||||
<span className="font-medium">{entry.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/repos/$repoId/blob/$"
|
||||
params={{ repoId, _splat: entry.name }}
|
||||
search={preview ? { preview: "repositories" } : undefined}
|
||||
className="flex items-center gap-2 border-b border-black/10 px-3 py-2 text-sm text-black last:border-b-0 hover:bg-black/5 dark:border-white/10 dark:text-white dark:hover:bg-white/5"
|
||||
>
|
||||
<File className="h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
|
||||
<span>{entry.name}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepoTreeSection({
|
||||
entries,
|
||||
isLoading,
|
||||
repoId,
|
||||
preview = false,
|
||||
}: {
|
||||
entries: TreeEntry[] | undefined;
|
||||
isLoading: boolean;
|
||||
repoId: string;
|
||||
preview?: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="rounded-lg border border-black/10 dark:border-white/10">
|
||||
{["sk-1", "sk-2", "sk-3", "sk-4", "sk-5"].map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 border-b border-black/10 px-3 py-2 last:border-b-0 dark:border-white/10"
|
||||
>
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="overflow-hidden rounded-lg border border-black/10 bg-white/50 dark:border-white/10 dark:bg-white/5">
|
||||
{entries.map((entry) => (
|
||||
<TreeRow
|
||||
key={entry.name}
|
||||
entry={entry}
|
||||
repoId={repoId}
|
||||
preview={preview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { BookMarked, GitBranch } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import buzzAppIcon from "@/assets/app-icon@3x.png";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { mockRepos } from "../mock-repos";
|
||||
import { useRepos } from "../use-repos";
|
||||
import { ConnectButton } from "./ConnectButton";
|
||||
import { OrgSidebar } from "./OrgSidebar";
|
||||
import { RepoListItem } from "./RepoListItem";
|
||||
|
||||
type SortOrder = "newest" | "oldest" | "name";
|
||||
|
||||
function ListItemSkeleton() {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-5 w-48 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-5 w-14 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
<div className="mt-2 h-4 w-3/4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="mt-2 flex gap-4">
|
||||
<div className="h-3 w-24 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchEmptyState() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-black/5 dark:bg-white/10">
|
||||
<GitBranch className="h-7 w-7 text-black/50 dark:text-white/50" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-lg font-semibold text-black dark:text-white">
|
||||
{t("repos.searchEmptyTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-sm text-sm text-black/60 dark:text-white/60">
|
||||
{t("repos.searchEmptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityEmptyState({
|
||||
authRequired = false,
|
||||
}: {
|
||||
authRequired?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-[#F3F3F3] px-4 py-16 text-center dark:bg-[#171717]">
|
||||
<div className="flex w-full max-w-xl flex-col items-center px-6 py-10 sm:px-12 sm:py-12">
|
||||
<div
|
||||
className="h-16 w-16 overflow-hidden bg-black"
|
||||
style={{ borderRadius: "22.37%" }}
|
||||
>
|
||||
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-semibold tracking-tight text-black dark:text-white">
|
||||
{t(
|
||||
authRequired
|
||||
? "repos.authRequiredTitle"
|
||||
: "repos.communityEmptyTitle",
|
||||
)}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-black/60 dark:text-white/60">
|
||||
{t(
|
||||
authRequired
|
||||
? "repos.authRequiredDescription"
|
||||
: "repos.communityEmptyDescription",
|
||||
)}
|
||||
</p>
|
||||
<ConnectButton className="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReposPage() {
|
||||
const { locale, t } = useI18n();
|
||||
const preview = import.meta.env.DEV
|
||||
? new URLSearchParams(window.location.search).get("preview")
|
||||
: null;
|
||||
const showMockRepos = preview === "repositories";
|
||||
const showMockEmptyState = preview === "empty";
|
||||
const {
|
||||
data: fetchedRepos,
|
||||
isLoading: isLoadingRepos,
|
||||
error,
|
||||
} = useRepos({ enabled: !showMockRepos && !showMockEmptyState });
|
||||
const repos = showMockRepos
|
||||
? mockRepos
|
||||
: showMockEmptyState
|
||||
? []
|
||||
: fetchedRepos;
|
||||
const isLoading = preview ? false : isLoadingRepos;
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState<SortOrder>("newest");
|
||||
const authRequired =
|
||||
error instanceof Error && error.message.startsWith("auth-required");
|
||||
|
||||
useEffect(() => {
|
||||
if (error && !authRequired) {
|
||||
toast.error(t("repos.loadFailed"), {
|
||||
description: t("common.tryAgain"),
|
||||
});
|
||||
}
|
||||
}, [authRequired, error, t]);
|
||||
|
||||
const filteredRepos = useMemo(() => {
|
||||
if (!repos) return [];
|
||||
|
||||
const term = search.toLowerCase();
|
||||
let result = repos.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(term) ||
|
||||
r.description.toLowerCase().includes(term),
|
||||
);
|
||||
|
||||
switch (sort) {
|
||||
case "newest":
|
||||
result = result.sort((a, b) => b.createdAt - a.createdAt);
|
||||
break;
|
||||
case "oldest":
|
||||
result = result.sort((a, b) => a.createdAt - b.createdAt);
|
||||
break;
|
||||
case "name":
|
||||
result = result.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, locale, { sensitivity: "base" }),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [locale, repos, search, sort]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 dark:bg-[#171717]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-black dark:text-white">
|
||||
<BookMarked className="h-4 w-4" /> {t("repos.title")}
|
||||
</h2>
|
||||
<div className="divide-y">
|
||||
{["a", "b", "c", "d", "e"].map((key) => (
|
||||
<ListItemSkeleton key={key} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<aside className="hidden w-72 shrink-0 lg:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!repos || repos.length === 0) {
|
||||
return <CommunityEmptyState authRequired={authRequired} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 dark:bg-[#171717]">
|
||||
{/* Main content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Mobile-only connect button */}
|
||||
<div className="mb-4 lg:hidden">
|
||||
<ConnectButton className="w-full" />
|
||||
</div>
|
||||
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-black dark:text-white">
|
||||
<BookMarked className="h-4 w-4" /> {t("repos.title")}
|
||||
</h2>
|
||||
|
||||
{/* Search + Sort bar */}
|
||||
<div className="mb-4 flex gap-3">
|
||||
<Input
|
||||
placeholder={t("repos.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 border-black/10 bg-white text-black placeholder:text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/40"
|
||||
/>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortOrder)}
|
||||
aria-label={t("repos.sortLabel")}
|
||||
className="rounded-md border border-black/10 bg-white px-3 py-1 text-sm text-black shadow-xs focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-black dark:border-white/10 dark:bg-white/5 dark:text-white dark:focus-visible:ring-white"
|
||||
>
|
||||
<option value="newest">{t("repos.sort.newest")}</option>
|
||||
<option value="oldest">{t("repos.sort.oldest")}</option>
|
||||
<option value="name">{t("repos.sort.name")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Repo list */}
|
||||
{filteredRepos.length > 0 ? (
|
||||
<div className="divide-y divide-black/10 dark:divide-white/10">
|
||||
{filteredRepos.map((repo) => (
|
||||
<RepoListItem key={repo.id} repo={repo} preview={showMockRepos} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<SearchEmptyState />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden w-72 shrink-0 border-l border-black/10 pl-8 dark:border-white/10 lg:block">
|
||||
<OrgSidebar repos={repos} />
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* React Query hooks for browsing git repos via isomorphic-git.
|
||||
*
|
||||
* All hooks depend on `useGitClone` which ensures the repo is shallow-cloned
|
||||
* into IndexedDB before any reads happen.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { resolveRef } from "isomorphic-git";
|
||||
import {
|
||||
ensureClone,
|
||||
findReadme,
|
||||
getCommitLog,
|
||||
readBlobView,
|
||||
readTreeEntries,
|
||||
resolveHtmlAssets,
|
||||
} from "./git-client";
|
||||
|
||||
/**
|
||||
* Ensure the repo is cloned (or fetched) into IndexedDB.
|
||||
* Other hooks depend on this to get `fs` and `dir`.
|
||||
*/
|
||||
export function useGitClone(owner: string, repoName: string, ref: string) {
|
||||
return useQuery({
|
||||
queryKey: ["git-clone", owner, repoName, ref],
|
||||
queryFn: () => ensureClone(owner, repoName, ref),
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: !!owner && !!repoName && !!ref,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read tree entries at a path (or root). Directories first, then files, alphabetical. */
|
||||
export function useGitTree(
|
||||
owner: string,
|
||||
repoName: string,
|
||||
ref: string,
|
||||
path?: string,
|
||||
) {
|
||||
const cloneQuery = useGitClone(owner, repoName, ref);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["git-tree", owner, repoName, ref, path ?? ""],
|
||||
queryFn: async () => {
|
||||
if (!cloneQuery.data) throw new Error("unreachable: enabled guards data");
|
||||
const { fs, dir } = cloneQuery.data;
|
||||
const oid = await resolveRef({ fs, dir, ref });
|
||||
const entries = await readTreeEntries(fs, dir, oid, path || undefined);
|
||||
|
||||
// Sort: directories first, then files, alphabetical within each group
|
||||
return entries.sort((a, b) => {
|
||||
if (a.type === "tree" && b.type !== "tree") return -1;
|
||||
if (a.type !== "tree" && b.type === "tree") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
},
|
||||
enabled: !!cloneQuery.data,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get recent commits for the given ref. */
|
||||
export function useGitLog(owner: string, repoName: string, ref: string) {
|
||||
const cloneQuery = useGitClone(owner, repoName, ref);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["git-log", owner, repoName, ref],
|
||||
queryFn: async () => {
|
||||
if (!cloneQuery.data) throw new Error("unreachable: enabled guards data");
|
||||
const { fs, dir } = cloneQuery.data;
|
||||
return getCommitLog(fs, dir, ref);
|
||||
},
|
||||
enabled: !!cloneQuery.data,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Find and read the README from the repo root. */
|
||||
export function useGitReadme(owner: string, repoName: string, ref: string) {
|
||||
const cloneQuery = useGitClone(owner, repoName, ref);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["git-readme", owner, repoName, ref],
|
||||
queryFn: async () => {
|
||||
if (!cloneQuery.data) throw new Error("unreachable: enabled guards data");
|
||||
const { fs, dir } = cloneQuery.data;
|
||||
return findReadme(fs, dir, ref);
|
||||
},
|
||||
enabled: !!cloneQuery.data,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read a single file's content as a classified `BlobView`. */
|
||||
export function useGitBlob(
|
||||
owner: string,
|
||||
repoName: string,
|
||||
ref: string,
|
||||
filepath: string,
|
||||
) {
|
||||
const cloneQuery = useGitClone(owner, repoName, ref);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["git-blob", owner, repoName, ref, filepath],
|
||||
queryFn: async () => {
|
||||
if (!cloneQuery.data) throw new Error("unreachable: enabled guards data");
|
||||
const { fs, dir } = cloneQuery.data;
|
||||
const oid = await resolveRef({ fs, dir, ref });
|
||||
return readBlobView(fs, dir, oid, filepath);
|
||||
},
|
||||
enabled: !!cloneQuery.data && !!filepath,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an HTML file into a self-contained doc (relative assets inlined),
|
||||
* ready to drop into a sandboxed iframe. Lazy: `enabled` is caller-gated so
|
||||
* we only do the inlining work when the user clicks "Run". The decoded HTML
|
||||
* is passed in (the blob view already has it) to avoid a second read.
|
||||
*/
|
||||
export function useGitHtmlDoc(
|
||||
owner: string,
|
||||
repoName: string,
|
||||
ref: string,
|
||||
filepath: string,
|
||||
html: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const cloneQuery = useGitClone(owner, repoName, ref);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["git-html-doc", owner, repoName, ref, filepath],
|
||||
queryFn: async () => {
|
||||
if (!cloneQuery.data) throw new Error("unreachable: enabled guards data");
|
||||
const { fs, dir } = cloneQuery.data;
|
||||
const oid = await resolveRef({ fs, dir, ref });
|
||||
return resolveHtmlAssets(fs, dir, oid, filepath, html);
|
||||
},
|
||||
enabled: enabled && !!cloneQuery.data && !!filepath,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared resolver for the `(owner, repoName, defaultRef)` triple every
|
||||
* repo-scoped page needs. Combines the NIP-34 announcement (`useRepo`) with
|
||||
* the refs query (`useRepoRefs`) so callers don't duplicate that wiring.
|
||||
*
|
||||
* `defaultRef` falls back to `"main"` until refs load, matching the
|
||||
* pre-existing behaviour in `RepoDetailPage`.
|
||||
*/
|
||||
import { useRepo } from "./use-repos";
|
||||
import { useRepoRefs } from "./use-repo-refs";
|
||||
|
||||
export interface RepoContext {
|
||||
owner: string;
|
||||
repoName: string;
|
||||
defaultRef: string;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useRepoContext(
|
||||
repoId: string,
|
||||
{ preview = false } = {},
|
||||
): RepoContext {
|
||||
const {
|
||||
data: repo,
|
||||
isLoading: repoLoading,
|
||||
error: repoError,
|
||||
} = useRepo(repoId, { preview });
|
||||
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId, {
|
||||
preview,
|
||||
});
|
||||
|
||||
return {
|
||||
owner: repo?.owner ?? "",
|
||||
repoName: repo?.id ?? "",
|
||||
defaultRef: refs?.head?.ref ?? "main",
|
||||
isLoading: repoLoading || refsLoading,
|
||||
error: (repoError as Error | null) ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client";
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { dedup } from "./use-repos";
|
||||
|
||||
export interface RepoRefs {
|
||||
branches: string[];
|
||||
tags: string[];
|
||||
head: { ref: string; sha: string } | null;
|
||||
}
|
||||
|
||||
function parseRefs(events: NostrEvent[]): RepoRefs {
|
||||
const latest = dedup(events);
|
||||
const branches: string[] = [];
|
||||
const tags: string[] = [];
|
||||
let head: RepoRefs["head"] = null;
|
||||
|
||||
for (const event of latest) {
|
||||
for (const tag of event.tags) {
|
||||
const [name, value] = tag;
|
||||
if (!name || !value) continue;
|
||||
|
||||
if (name === "HEAD" && value.startsWith("ref: refs/heads/")) {
|
||||
// HEAD points to a branch ref — find its SHA from a matching branch tag
|
||||
const branchName = value.replace("ref: refs/heads/", "");
|
||||
head = { ref: branchName, sha: "" };
|
||||
} else if (name.startsWith("refs/heads/")) {
|
||||
branches.push(name.replace("refs/heads/", ""));
|
||||
} else if (name.startsWith("refs/tags/")) {
|
||||
tags.push(name.replace("refs/tags/", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve HEAD SHA from the matching branch
|
||||
if (head) {
|
||||
for (const event of latest) {
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === `refs/heads/${head.ref}` && tag[1]) {
|
||||
head = { ref: head.ref, sha: tag[1] };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (head.sha) break;
|
||||
}
|
||||
}
|
||||
|
||||
return { branches, tags, head };
|
||||
}
|
||||
|
||||
async function fetchRepoRefs(repoId: string): Promise<RepoRefs> {
|
||||
// TODO: Filter by `authors: [relayPubkey]` once the relay's own pubkey is
|
||||
// exposed to the client. Without this, a user with ReposWrite permission
|
||||
// could publish fake kind:30618 events with spoofed refs.
|
||||
const events = await queryEvents(relayWsUrl(), {
|
||||
kinds: [30618],
|
||||
"#d": [repoId],
|
||||
});
|
||||
return parseRefs(events);
|
||||
}
|
||||
|
||||
export function useRepoRefs(repoId: string, { preview = false } = {}) {
|
||||
const mockRefs: RepoRefs = {
|
||||
branches: ["main"],
|
||||
tags: ["v0.1.0"],
|
||||
head: { ref: "main", sha: "a".repeat(40) },
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryKey: preview ? ["repo-refs", "mock", repoId] : ["repo-refs", repoId],
|
||||
queryFn: preview ? async () => mockRefs : () => fetchRepoRefs(repoId),
|
||||
initialData: preview ? mockRefs : undefined,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client";
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { mockRepos } from "./mock-repos";
|
||||
|
||||
export interface Repo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cloneUrls: string[];
|
||||
webUrl: string | null;
|
||||
channelId: string | null;
|
||||
owner: string;
|
||||
contributors: string[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** Extract the first value for a given tag name from a Nostr event. */
|
||||
export function getTag(event: NostrEvent, name: string): string | undefined {
|
||||
return event.tags.find((t) => t[0] === name)?.[1];
|
||||
}
|
||||
|
||||
/** Extract all values for a given tag name from a Nostr event. */
|
||||
function getAllTags(event: NostrEvent, name: string): string[] {
|
||||
return event.tags.filter((t) => t[0] === name).map((t) => t[1]);
|
||||
}
|
||||
|
||||
function eventToRepo(event: NostrEvent): Repo {
|
||||
const d = getTag(event, "d") ?? event.id;
|
||||
const name = getTag(event, "name") || d;
|
||||
const description = getTag(event, "description") || event.content || "";
|
||||
const cloneUrls = getAllTags(event, "clone");
|
||||
const webUrl = getTag(event, "web") ?? null;
|
||||
const channelId = getTag(event, "buzz-channel") ?? null;
|
||||
const contributors = getAllTags(event, "p");
|
||||
const owner = event.pubkey;
|
||||
|
||||
return {
|
||||
id: d,
|
||||
name,
|
||||
description,
|
||||
cloneUrls,
|
||||
webUrl,
|
||||
channelId,
|
||||
owner,
|
||||
contributors,
|
||||
createdAt: event.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deduplicate NIP-33 parameterized replaceable events, keeping the latest per (pubkey, kind, d-tag). */
|
||||
export function dedup(events: NostrEvent[]): NostrEvent[] {
|
||||
const best = new Map<string, NostrEvent>();
|
||||
for (const e of events) {
|
||||
const d = getTag(e, "d") ?? "";
|
||||
const key = `${e.pubkey}:${e.kind}:${d}`;
|
||||
const prev = best.get(key);
|
||||
if (!prev || e.created_at > prev.created_at) {
|
||||
best.set(key, e);
|
||||
}
|
||||
}
|
||||
return [...best.values()];
|
||||
}
|
||||
|
||||
async function fetchRepos(): Promise<Repo[]> {
|
||||
const events = await queryEvents(relayWsUrl(), { kinds: [30617] });
|
||||
return dedup(events)
|
||||
.map(eventToRepo)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
export function useRepos({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ["repos"],
|
||||
queryFn: fetchRepos,
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRepo(repoId: string, { preview = false } = {}) {
|
||||
return useQuery({
|
||||
queryKey: preview ? ["repos", "mock"] : ["repos"],
|
||||
queryFn: preview ? async () => mockRepos : fetchRepos,
|
||||
initialData: preview ? mockRepos : undefined,
|
||||
staleTime: 60_000,
|
||||
select: (repos) => repos.find((r) => r.id === repoId),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { App } from "@/app/App";
|
||||
import "@fontsource-variable/inter/wght.css";
|
||||
import "@/shared/styles/globals.css";
|
||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||
import { Toaster } from "@/shared/ui/sonner";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { I18nProvider } from "@/shared/i18n/I18nProvider";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
networkMode: "always",
|
||||
gcTime: 5 * 60 * 1_000,
|
||||
},
|
||||
mutations: {
|
||||
networkMode: "always",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<App />
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { en, type TranslationKey, zhHans } from "./catalogs";
|
||||
|
||||
export type Locale = "en" | "zh-Hans";
|
||||
export type TranslationParams = Record<string, number | string>;
|
||||
|
||||
const DEFAULT_LOCALE: Locale = "zh-Hans";
|
||||
const STORAGE_KEY = "buzz.web.locale";
|
||||
const catalogs = { en, "zh-Hans": zhHans } as const;
|
||||
|
||||
function isLocale(value: string | null): value is Locale {
|
||||
return value === "en" || value === "zh-Hans";
|
||||
}
|
||||
|
||||
function initialLocale(): Locale {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return isLocale(stored) ? stored : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function translate(
|
||||
locale: Locale,
|
||||
key: TranslationKey,
|
||||
params: TranslationParams = {},
|
||||
): string {
|
||||
return catalogs[locale][key].replace(/\{(\w+)\}/g, (token, name) =>
|
||||
Object.keys(params).includes(name) ? String(params[name]) : token,
|
||||
);
|
||||
}
|
||||
|
||||
type I18nContextValue = {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: TranslationKey, params?: TranslationParams) => string;
|
||||
};
|
||||
|
||||
const I18nContext = React.createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [locale, setLocale] = React.useState<Locale>(initialLocale);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
window.localStorage.setItem(STORAGE_KEY, locale);
|
||||
}, [locale]);
|
||||
|
||||
const value = React.useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
t: (key, params) => translate(locale, key, params),
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const context = React.useContext(I18nContext);
|
||||
if (!context) throw new Error("useI18n must be used within I18nProvider");
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Languages } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
|
||||
export function LocaleToggle() {
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const nextLocale = locale === "zh-Hans" ? "en" : "zh-Hans";
|
||||
const label =
|
||||
nextLocale === "en"
|
||||
? t("locale.switchToEnglish")
|
||||
: t("locale.switchToChinese");
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="h-8 gap-1.5 border-black/10 bg-white/80 px-2.5 text-xs text-black shadow-sm backdrop-blur-sm hover:bg-white dark:border-white/10 dark:bg-black/70 dark:text-white dark:hover:bg-black"
|
||||
onClick={() => setLocale(nextLocale)}
|
||||
title={label}
|
||||
variant="outline"
|
||||
>
|
||||
<Languages aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
{nextLocale === "en" ? "EN" : "中文"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
export const en = {
|
||||
"common.close": "Close",
|
||||
"common.copy": "Copy",
|
||||
"common.download": "Download",
|
||||
"common.public": "Public",
|
||||
"common.people": "People",
|
||||
"common.openInBuzz": "Open in Buzz",
|
||||
"common.copied": "Copied to clipboard",
|
||||
"common.copyFailed": "Failed to copy to clipboard",
|
||||
"common.tryAgain": "Please try again later.",
|
||||
"common.updated": "Updated {time}",
|
||||
"locale.switchToEnglish": "Switch to English",
|
||||
"locale.switchToChinese": "切换为简体中文",
|
||||
"theme.switch": "Theme: {theme}. Click to switch.",
|
||||
"theme.light": "light",
|
||||
"theme.dark": "dark",
|
||||
"theme.system": "system",
|
||||
"invite.title": "You're invited to",
|
||||
"invite.joinInBrowser": "Join in browser",
|
||||
"invite.joining": "Joining…",
|
||||
"invite.accept": "Accept invite in Buzz",
|
||||
"invite.noApp": "Don't have the app?",
|
||||
"invite.downloadNow": "Download it now",
|
||||
"invite.ageLabel": "I am 18 years of age or older.",
|
||||
"invite.agreementLabel":
|
||||
"I agree to the Buzz Terms of Service and Privacy Policy.",
|
||||
"invite.agreementPrefix": "I agree to the Buzz ",
|
||||
"invite.agreementJoin": " and ",
|
||||
"invite.agreementSuffix": ".",
|
||||
"invite.terms": "Terms of Service",
|
||||
"invite.privacy": "Privacy Policy",
|
||||
"invite.mac.title": "Which Mac do you have?",
|
||||
"invite.mac.description": "Choose based on when your Mac was released.",
|
||||
"invite.mac.newer": "Newer Mac",
|
||||
"invite.mac.newerDescription":
|
||||
"2021 or later, or a late-2020 Mac with an Apple M1 chip",
|
||||
"invite.mac.older": "Older Mac",
|
||||
"invite.mac.olderDescription":
|
||||
"2019 or earlier, or a 2020 Mac with an Intel processor",
|
||||
"invite.mac.notSure": "Not sure?",
|
||||
"invite.mac.help":
|
||||
"Open the Apple menu and choose About This Mac. “Chip: Apple M…” means Newer Mac. “Processor: Intel” means Older Mac.",
|
||||
"invite.error.exhausted":
|
||||
"This invite has reached its use limit. Ask for a new invite.",
|
||||
"invite.error.expired": "This invite has expired. Ask for a new invite.",
|
||||
"invite.error.invalid":
|
||||
"This invite is invalid. Check the link or ask for a new invite.",
|
||||
"invite.error.claimFailed": "Could not claim this invite.",
|
||||
"repos.title": "Repositories",
|
||||
"repos.searchPlaceholder": "Find a repository...",
|
||||
"repos.sortLabel": "Sort repositories",
|
||||
"repos.sort.newest": "Newest",
|
||||
"repos.sort.oldest": "Oldest",
|
||||
"repos.sort.name": "Name",
|
||||
"repos.searchEmptyTitle": "No matching repositories",
|
||||
"repos.searchEmptyDescription": "Try adjusting your search term.",
|
||||
"repos.communityEmptyTitle": "This community is empty",
|
||||
"repos.communityEmptyDescription":
|
||||
"Repositories pushed to this community will show up here. Open this community in the Buzz desktop app to start pushing code.",
|
||||
"repos.authRequiredTitle": "Sign in to view repositories",
|
||||
"repos.authRequiredDescription":
|
||||
"This community requires member authentication. Open it in the Buzz desktop app or use a signed-in Nostr wallet.",
|
||||
"repos.loadFailed": "Failed to load repositories",
|
||||
"repos.backToList": "Back to repositories",
|
||||
"repos.notFoundTitle": "Repository not found",
|
||||
"repos.notFoundDescription":
|
||||
"This repository may have been removed or doesn't exist on this relay.",
|
||||
"repos.loadRepositoryFailed": "Failed to load repository",
|
||||
"repos.loadContentsFailed":
|
||||
"Failed to load repository contents. Please try again later.",
|
||||
"repos.code": "Code",
|
||||
"repos.commits": "Commits",
|
||||
"repos.clone": "Clone",
|
||||
"repos.viewOnWeb": "View on web",
|
||||
"repos.viewChannel": "View channel",
|
||||
"repos.copyCloneUrl": "Copy clone URL",
|
||||
"repos.recentCommits": "Recent commits",
|
||||
"repos.committed": "{author} committed {time}",
|
||||
"repos.branch.one": "{count} branch",
|
||||
"repos.branch.other": "{count} branches",
|
||||
"repos.tag.one": "{count} tag",
|
||||
"repos.tag.other": "{count} tags",
|
||||
"repos.noCommits": "No commits yet",
|
||||
"repos.peopleCount": "{count} people",
|
||||
"blob.sandboxTitle": "Repository page (sandboxed)",
|
||||
"blob.binary": "Binary file, {size}. Use Download above to save it.",
|
||||
"blob.tooLarge":
|
||||
"File is {size}, over the {limit} preview limit. Use Download above to save it.",
|
||||
"blob.showSource": "Show source",
|
||||
"blob.run": "Run",
|
||||
"blob.loadRepositoryFailed":
|
||||
"Failed to load repository. Please try again later.",
|
||||
"blob.loadFileFailed": "Failed to load file. Please try again later.",
|
||||
"blob.backToRepository": "Back to repository",
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof en;
|
||||
|
||||
export const zhHans = {
|
||||
"common.close": "关闭",
|
||||
"common.copy": "复制",
|
||||
"common.download": "下载",
|
||||
"common.public": "公开",
|
||||
"common.people": "成员",
|
||||
"common.openInBuzz": "在 Buzz 中打开",
|
||||
"common.copied": "已复制到剪贴板",
|
||||
"common.copyFailed": "复制失败",
|
||||
"common.tryAgain": "请稍后重试。",
|
||||
"common.updated": "更新于{time}",
|
||||
"locale.switchToEnglish": "切换为 English",
|
||||
"locale.switchToChinese": "切换为简体中文",
|
||||
"theme.switch": "当前主题:{theme}。点击切换。",
|
||||
"theme.light": "浅色",
|
||||
"theme.dark": "深色",
|
||||
"theme.system": "跟随系统",
|
||||
"invite.title": "你受邀加入",
|
||||
"invite.joinInBrowser": "在浏览器中加入",
|
||||
"invite.joining": "正在加入…",
|
||||
"invite.accept": "在 Buzz 中接受邀请",
|
||||
"invite.noApp": "还没有安装应用?",
|
||||
"invite.downloadNow": "立即下载",
|
||||
"invite.ageLabel": "我已年满 18 周岁。",
|
||||
"invite.agreementLabel": "我同意 Buzz 的服务条款和隐私政策。",
|
||||
"invite.agreementPrefix": "我同意 Buzz 的",
|
||||
"invite.agreementJoin": "和",
|
||||
"invite.agreementSuffix": "。",
|
||||
"invite.terms": "服务条款",
|
||||
"invite.privacy": "隐私政策",
|
||||
"invite.mac.title": "你的 Mac 是哪一款?",
|
||||
"invite.mac.description": "请根据 Mac 的发布年份选择。",
|
||||
"invite.mac.newer": "较新的 Mac",
|
||||
"invite.mac.newerDescription":
|
||||
"2021 年或之后发布,或配备 Apple M1 芯片的 2020 年末机型",
|
||||
"invite.mac.older": "较早的 Mac",
|
||||
"invite.mac.olderDescription":
|
||||
"2019 年或之前发布,或配备 Intel 处理器的 2020 年机型",
|
||||
"invite.mac.notSure": "不确定?",
|
||||
"invite.mac.help":
|
||||
"打开 Apple 菜单并选择“关于本机”。显示“芯片:Apple M…”请选择较新的 Mac;显示“处理器:Intel”请选择较早的 Mac。",
|
||||
"invite.error.exhausted": "此邀请已达到使用次数上限,请索取新邀请。",
|
||||
"invite.error.expired": "此邀请已过期,请索取新邀请。",
|
||||
"invite.error.invalid": "此邀请无效,请检查链接或索取新邀请。",
|
||||
"invite.error.claimFailed": "无法接受此邀请。",
|
||||
"repos.title": "代码仓库",
|
||||
"repos.searchPlaceholder": "查找代码仓库…",
|
||||
"repos.sortLabel": "代码仓库排序",
|
||||
"repos.sort.newest": "最新",
|
||||
"repos.sort.oldest": "最早",
|
||||
"repos.sort.name": "名称",
|
||||
"repos.searchEmptyTitle": "没有匹配的代码仓库",
|
||||
"repos.searchEmptyDescription": "请尝试调整搜索关键词。",
|
||||
"repos.communityEmptyTitle": "这个社区还是空的",
|
||||
"repos.communityEmptyDescription":
|
||||
"推送到此社区的代码仓库会显示在这里。请在 Buzz 桌面应用中打开此社区并开始推送代码。",
|
||||
"repos.authRequiredTitle": "登录后才能查看代码仓库",
|
||||
"repos.authRequiredDescription":
|
||||
"此社区需要成员认证,请在 Buzz 桌面应用中打开,或使用已登录的 Nostr 钱包。",
|
||||
"repos.loadFailed": "加载代码仓库失败",
|
||||
"repos.backToList": "返回代码仓库列表",
|
||||
"repos.notFoundTitle": "未找到代码仓库",
|
||||
"repos.notFoundDescription": "此代码仓库可能已被删除,或不存在于当前 Relay。",
|
||||
"repos.loadRepositoryFailed": "加载代码仓库失败",
|
||||
"repos.loadContentsFailed": "加载代码仓库内容失败,请稍后重试。",
|
||||
"repos.code": "代码",
|
||||
"repos.commits": "提交记录",
|
||||
"repos.clone": "克隆",
|
||||
"repos.viewOnWeb": "在网页中查看",
|
||||
"repos.viewChannel": "查看频道",
|
||||
"repos.copyCloneUrl": "复制克隆地址",
|
||||
"repos.recentCommits": "最近提交",
|
||||
"repos.committed": "{author} 提交于{time}",
|
||||
"repos.branch.one": "{count} 个分支",
|
||||
"repos.branch.other": "{count} 个分支",
|
||||
"repos.tag.one": "{count} 个标签",
|
||||
"repos.tag.other": "{count} 个标签",
|
||||
"repos.noCommits": "暂无提交",
|
||||
"repos.peopleCount": "{count} 位成员",
|
||||
"blob.sandboxTitle": "代码仓库页面(沙箱)",
|
||||
"blob.binary": "二进制文件,大小为 {size}。请使用上方的下载按钮保存。",
|
||||
"blob.tooLarge":
|
||||
"文件大小为 {size},超过 {limit} 的预览上限。请使用上方的下载按钮保存。",
|
||||
"blob.showSource": "显示源码",
|
||||
"blob.run": "运行",
|
||||
"blob.loadRepositoryFailed": "加载代码仓库失败,请稍后重试。",
|
||||
"blob.loadFileFailed": "加载文件失败,请稍后重试。",
|
||||
"blob.backToRepository": "返回代码仓库",
|
||||
} as const satisfies Record<TranslationKey, string>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Normalize Vite's public base URL for client-side routing. */
|
||||
export function normalizeAppBasePath(baseUrl: string): string {
|
||||
const pathname = new URL(baseUrl, "http://buzz.local").pathname;
|
||||
const trimmed = pathname.replace(/\/+$/, "");
|
||||
return trimmed || "/";
|
||||
}
|
||||
|
||||
/** Public path where the web SPA is mounted. */
|
||||
export function appBasePath(): string {
|
||||
return normalizeAppBasePath(import.meta.env.BASE_URL);
|
||||
}
|
||||
|
||||
/** Browser href for the SPA root, including its configured base path. */
|
||||
export function appRootHref(): string {
|
||||
const basePath = appBasePath();
|
||||
return basePath === "/" ? "/" : `${basePath}/`;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
export const BUZZ_RELEASES_URL = "https://github.com/block/buzz/releases";
|
||||
const BUZZ_RELEASES_API_URL =
|
||||
"https://api.github.com/repos/block/buzz/releases?per_page=10";
|
||||
const CACHE_KEY = "buzz.latestDownload.v1";
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
export type BuzzDownloadPlatform = {
|
||||
operatingSystem: "linux" | "macos" | "windows" | "unknown";
|
||||
architecture: "arm64" | "x64" | "unknown";
|
||||
};
|
||||
|
||||
type GitHubRelease = {
|
||||
draft: boolean;
|
||||
prerelease: boolean;
|
||||
assets: Array<{ name: string; browser_download_url: string }>;
|
||||
};
|
||||
|
||||
type UserAgentData = {
|
||||
platform?: string;
|
||||
mobile?: boolean;
|
||||
getHighEntropyValues?: (
|
||||
hints: string[],
|
||||
) => Promise<{ architecture?: string; bitness?: string }>;
|
||||
};
|
||||
|
||||
function normalizeOperatingSystem(
|
||||
navigatorValue: Navigator,
|
||||
userAgentData?: UserAgentData,
|
||||
): BuzzDownloadPlatform["operatingSystem"] {
|
||||
const userAgent = navigatorValue.userAgent.toLowerCase();
|
||||
const platform = (
|
||||
userAgentData?.platform ??
|
||||
navigatorValue.platform ??
|
||||
""
|
||||
).toLowerCase();
|
||||
|
||||
// Compatibility tokens are treacherous: iPadOS can report MacIntel and a
|
||||
// Macintosh UA, while Android and ChromeOS expose Linux platform strings.
|
||||
// Reject non-desktop devices before admitting desktop-looking signals.
|
||||
const isIPadDesktopMode =
|
||||
platform === "macintel" && navigatorValue.maxTouchPoints > 1;
|
||||
const isUnsupportedDevice =
|
||||
userAgentData?.mobile === true ||
|
||||
isIPadDesktopMode ||
|
||||
/android|iphone|ipad|ipod|mobile|tablet|windows phone|iemobile|opera mini|opera mobi|webos|blackberry|bb10|kindle|silk|kaios|cros/.test(
|
||||
userAgent,
|
||||
);
|
||||
if (isUnsupportedDevice) return "unknown";
|
||||
|
||||
if (
|
||||
platform === "macos" ||
|
||||
platform.startsWith("mac") ||
|
||||
userAgent.includes("macintosh")
|
||||
)
|
||||
return "macos";
|
||||
if (
|
||||
platform === "windows" ||
|
||||
platform.startsWith("win") ||
|
||||
userAgent.includes("windows nt")
|
||||
)
|
||||
return "windows";
|
||||
if (
|
||||
platform === "linux" ||
|
||||
platform.startsWith("linux") ||
|
||||
userAgent.includes("linux")
|
||||
)
|
||||
return "linux";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function normalizeArchitecture(
|
||||
value: string,
|
||||
): BuzzDownloadPlatform["architecture"] {
|
||||
const normalized = value.toLowerCase();
|
||||
if (/arm|aarch64/.test(normalized)) return "arm64";
|
||||
if (/x86|x64|amd64|64/.test(normalized)) return "x64";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function detectBuzzDownloadPlatform(
|
||||
navigatorValue: Navigator,
|
||||
): Promise<BuzzDownloadPlatform> {
|
||||
const userAgentData = (
|
||||
navigatorValue as Navigator & { userAgentData?: UserAgentData }
|
||||
).userAgentData;
|
||||
const operatingSystem = normalizeOperatingSystem(
|
||||
navigatorValue,
|
||||
userAgentData,
|
||||
);
|
||||
let architecture = normalizeArchitecture(navigatorValue.userAgent);
|
||||
|
||||
if (userAgentData?.getHighEntropyValues) {
|
||||
try {
|
||||
const values = await userAgentData.getHighEntropyValues([
|
||||
"architecture",
|
||||
"bitness",
|
||||
]);
|
||||
architecture = normalizeArchitecture(
|
||||
`${values.architecture ?? ""} ${values.bitness ?? ""}`,
|
||||
);
|
||||
} catch {
|
||||
// Privacy settings may reject high-entropy client hints. The matcher
|
||||
// below applies the safest compatible fallback for the detected OS.
|
||||
}
|
||||
}
|
||||
|
||||
return { operatingSystem, architecture };
|
||||
}
|
||||
|
||||
function assetPattern(platform: BuzzDownloadPlatform): RegExp | undefined {
|
||||
switch (platform.operatingSystem) {
|
||||
case "macos":
|
||||
if (platform.architecture === "arm64") return /_aarch64\.dmg$/i;
|
||||
if (platform.architecture === "x64") return /_x64\.dmg$/i;
|
||||
return undefined;
|
||||
case "windows":
|
||||
return /_x64-setup[^/]*\.exe$/i;
|
||||
case "linux":
|
||||
return platform.architecture === "arm64"
|
||||
? undefined
|
||||
: /_amd64\.AppImage$/i;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectBuzzDownloadUrl(
|
||||
releases: GitHubRelease[],
|
||||
platform: BuzzDownloadPlatform,
|
||||
): string | undefined {
|
||||
const pattern = assetPattern(platform);
|
||||
if (!pattern) return undefined;
|
||||
|
||||
for (const release of releases) {
|
||||
if (release.draft || release.prerelease) continue;
|
||||
const asset = release.assets.find(({ name }) => pattern.test(name));
|
||||
if (asset) return asset.browser_download_url;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveBuzzDownloadUrlForPlatform(
|
||||
platform: BuzzDownloadPlatform,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const cached = JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "null") as {
|
||||
expiresAt: number;
|
||||
platform: BuzzDownloadPlatform;
|
||||
url: string;
|
||||
} | null;
|
||||
if (
|
||||
cached &&
|
||||
cached.expiresAt > Date.now() &&
|
||||
cached.platform.operatingSystem === platform.operatingSystem &&
|
||||
cached.platform.architecture === platform.architecture
|
||||
) {
|
||||
return cached.url;
|
||||
}
|
||||
} catch {
|
||||
// Storage is only an optimization.
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(BUZZ_RELEASES_API_URL, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
if (!response.ok) return BUZZ_RELEASES_URL;
|
||||
const url = selectBuzzDownloadUrl(
|
||||
(await response.json()) as GitHubRelease[],
|
||||
platform,
|
||||
);
|
||||
if (!url) return BUZZ_RELEASES_URL;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
platform,
|
||||
url,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Storage is only an optimization.
|
||||
}
|
||||
return url;
|
||||
} catch {
|
||||
return BUZZ_RELEASES_URL;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveBuzzDownloadUrl(): Promise<string> {
|
||||
return resolveBuzzDownloadUrlForPlatform(
|
||||
await detectBuzzDownloadPlatform(navigator),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* NIP-98 HTTP Auth helper — signs a kind:27235 event for authenticating
|
||||
* HTTP requests to the relay (used by isomorphic-git for smart HTTP transport).
|
||||
*/
|
||||
|
||||
import { signNostrEvent } from "./nostr-signer";
|
||||
|
||||
async function sha256Hex(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(value),
|
||||
);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a NIP-98 Authorization header value.
|
||||
*
|
||||
* Signed POST bodies include the payload digest required by invite endpoints.
|
||||
*/
|
||||
export async function makeNip98AuthHeader(
|
||||
url: string,
|
||||
method: string,
|
||||
options?: { body?: string; requireNip07?: boolean },
|
||||
): Promise<string> {
|
||||
const tags = [
|
||||
["u", url],
|
||||
["method", method],
|
||||
];
|
||||
if (options?.body !== undefined) {
|
||||
tags.push(["payload", await sha256Hex(options.body)]);
|
||||
tags.push(["nonce", crypto.randomUUID()]);
|
||||
}
|
||||
const event = await signNostrEvent(
|
||||
{
|
||||
kind: 27235,
|
||||
tags,
|
||||
content: "",
|
||||
},
|
||||
{ requireNip07: options?.requireNip07 },
|
||||
);
|
||||
|
||||
const json = JSON.stringify(event);
|
||||
const base64 = btoa(json);
|
||||
return `Nostr ${base64}`;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Minimal Nostr client with NIP-01 queries and NIP-42 AUTH.
|
||||
*
|
||||
* Uses NIP-07 when a browser extension is available, with an ephemeral
|
||||
* page-lifetime identity as the fallback for read-only queries on open relays.
|
||||
*/
|
||||
|
||||
import { makeAuthEvent } from "nostr-tools/nip42";
|
||||
import {
|
||||
type SignedNostrEvent,
|
||||
signNostrEvent,
|
||||
} from "@/shared/lib/nostr-signer";
|
||||
|
||||
export interface NostrFilter {
|
||||
ids?: string[];
|
||||
authors?: string[];
|
||||
kinds?: number[];
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
[tag: `#${string}`]: string[] | undefined;
|
||||
}
|
||||
|
||||
export type NostrEvent = SignedNostrEvent;
|
||||
|
||||
const QUERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Open a WebSocket to `wsUrl`, authenticate via NIP-42 if challenged,
|
||||
* send a REQ with the given filter, collect EVENTs until EOSE, then
|
||||
* close and return them.
|
||||
*/
|
||||
export function queryEvents(
|
||||
wsUrl: string,
|
||||
filter: NostrFilter,
|
||||
): Promise<NostrEvent[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const events: NostrEvent[] = [];
|
||||
const subId = `q-${Date.now().toString(36)}`;
|
||||
let settled = false;
|
||||
let reqSent = false;
|
||||
let authEventId: string | null = null;
|
||||
let unauthenticatedReqTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ws.close();
|
||||
reject(new Error(`Relay query timed out after ${QUERY_TIMEOUT_MS}ms`));
|
||||
}
|
||||
}, QUERY_TIMEOUT_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
if (unauthenticatedReqTimer) {
|
||||
clearTimeout(unauthenticatedReqTimer);
|
||||
}
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const sendReq = () => {
|
||||
if (!reqSent) {
|
||||
reqSent = true;
|
||||
ws.send(JSON.stringify(["REQ", subId, filter]));
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
// Wait briefly for an AUTH challenge before sending REQ.
|
||||
// Buzz relays always send AUTH, but other relays may not.
|
||||
unauthenticatedReqTimer = setTimeout(() => sendReq(), 100);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", async (msg) => {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(String(msg.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(data)) return;
|
||||
|
||||
const [type] = data;
|
||||
|
||||
if (type === "AUTH" && typeof data[1] === "string") {
|
||||
// NIP-42: relay sent an AUTH challenge — sign and respond.
|
||||
if (unauthenticatedReqTimer) {
|
||||
clearTimeout(unauthenticatedReqTimer);
|
||||
unauthenticatedReqTimer = null;
|
||||
}
|
||||
const challenge = data[1];
|
||||
const template = makeAuthEvent(wsUrl, challenge);
|
||||
try {
|
||||
const signed = await signNostrEvent(template);
|
||||
if (settled) return;
|
||||
authEventId = signed.id;
|
||||
ws.send(JSON.stringify(["AUTH", signed]));
|
||||
} catch (error) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Failed to sign relay authentication."),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "OK" && data[1] === authEventId) {
|
||||
if (data[2] === true) {
|
||||
sendReq();
|
||||
} else if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
typeof data[3] === "string"
|
||||
? data[3]
|
||||
: "Relay authentication failed.",
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "EVENT" && data[1] === subId && data[2]) {
|
||||
events.push(data[2] as NostrEvent);
|
||||
} else if (type === "EOSE" && data[1] === subId) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(events);
|
||||
}
|
||||
} else if (type === "CLOSED" && data[1] === subId) {
|
||||
// Subscription was rejected (e.g. auth failed).
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
const reason =
|
||||
typeof data[2] === "string"
|
||||
? data[2]
|
||||
: "subscription closed by relay";
|
||||
reject(new Error(reason));
|
||||
}
|
||||
} else if (type === "NOTICE") {
|
||||
// Informational notice from relay — ignore for now.
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(events);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
} from "nostr-tools/pure";
|
||||
|
||||
export type UnsignedNostrEvent = {
|
||||
kind: number;
|
||||
created_at: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type SignedNostrEvent = UnsignedNostrEvent & {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
sig: string;
|
||||
};
|
||||
|
||||
type Nip07Provider = {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: UnsignedNostrEvent): Promise<SignedNostrEvent>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: Nip07Provider;
|
||||
}
|
||||
}
|
||||
|
||||
export class Nip07UnavailableError extends Error {
|
||||
constructor() {
|
||||
super("A NIP-07 browser extension is required to join in the browser.");
|
||||
this.name = "Nip07UnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
let ephemeralSecretKey: Uint8Array | null = null;
|
||||
|
||||
function getEphemeralSecretKey(): Uint8Array {
|
||||
if (!ephemeralSecretKey) {
|
||||
ephemeralSecretKey = generateSecretKey();
|
||||
}
|
||||
return ephemeralSecretKey;
|
||||
}
|
||||
|
||||
export function hasNip07Provider(): boolean {
|
||||
return typeof window !== "undefined" && window.nostr != null;
|
||||
}
|
||||
|
||||
function sameUnsignedEvent(
|
||||
expected: UnsignedNostrEvent,
|
||||
actual: SignedNostrEvent,
|
||||
): boolean {
|
||||
return (
|
||||
actual.kind === expected.kind &&
|
||||
actual.created_at === expected.created_at &&
|
||||
actual.content === expected.content &&
|
||||
JSON.stringify(actual.tags) === JSON.stringify(expected.tags)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign with NIP-07 when available, otherwise use a page-lifetime key.
|
||||
*
|
||||
* The ephemeral fallback preserves anonymous browsing on open relays. Flows
|
||||
* that create durable membership must set `requireNip07` so a reload cannot
|
||||
* orphan a relay-membership row.
|
||||
*/
|
||||
export async function signNostrEvent(
|
||||
template: Omit<UnsignedNostrEvent, "created_at"> & {
|
||||
created_at?: number;
|
||||
},
|
||||
options?: { requireNip07?: boolean },
|
||||
): Promise<SignedNostrEvent> {
|
||||
const unsigned: UnsignedNostrEvent = {
|
||||
...template,
|
||||
created_at: template.created_at ?? Math.floor(Date.now() / 1000),
|
||||
};
|
||||
const provider = typeof window === "undefined" ? undefined : window.nostr;
|
||||
|
||||
if (provider) {
|
||||
const expectedPubkey = await provider.getPublicKey();
|
||||
const signed = await provider.signEvent(unsigned);
|
||||
if (
|
||||
signed.pubkey !== expectedPubkey ||
|
||||
!sameUnsignedEvent(unsigned, signed) ||
|
||||
typeof signed.id !== "string" ||
|
||||
typeof signed.sig !== "string"
|
||||
) {
|
||||
throw new Error("The NIP-07 extension returned an invalid signed event.");
|
||||
}
|
||||
return signed;
|
||||
}
|
||||
|
||||
if (options?.requireNip07) {
|
||||
throw new Nip07UnavailableError();
|
||||
}
|
||||
|
||||
const secretKey = getEphemeralSecretKey();
|
||||
const signed = finalizeEvent(unsigned, secretKey);
|
||||
if (signed.pubkey !== getPublicKey(secretKey)) {
|
||||
throw new Error("Failed to create the ephemeral browser identity.");
|
||||
}
|
||||
return signed;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The ONE canonical compact display form for a pubkey: `abcd1234…wxyz`.
|
||||
* Mirrors desktop's `@/shared/lib/pubkey`. A truncated pubkey is a
|
||||
* recognition aid, never an identity proof — security decisions need the
|
||||
* full npub.
|
||||
*/
|
||||
export function truncatePubkey(pubkey: string): string {
|
||||
if (pubkey.length <= 12) {
|
||||
return pubkey;
|
||||
}
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Locale } from "@/shared/i18n/I18nProvider";
|
||||
|
||||
/** Format a Unix timestamp (seconds) using the active UI locale. */
|
||||
export function relativeTime(unix: number, locale: Locale = "zh-Hans"): string {
|
||||
const diff = Date.now() - unix * 1000;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
|
||||
if (days > 30) {
|
||||
const months = Math.floor(days / 30);
|
||||
return formatter.format(-months, "month");
|
||||
}
|
||||
if (days > 0) return formatter.format(-days, "day");
|
||||
if (hours > 0) return formatter.format(-hours, "hour");
|
||||
if (minutes > 0) return formatter.format(-minutes, "minute");
|
||||
return formatter.format(0, "second");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Convert a WebSocket relay URL to its HTTP equivalent. */
|
||||
export function relayHttpUrl(wsUrl: string): string {
|
||||
if (wsUrl.startsWith("wss://")) {
|
||||
return `https://${wsUrl.slice(6)}`;
|
||||
}
|
||||
if (wsUrl.startsWith("ws://")) {
|
||||
return `http://${wsUrl.slice(5)}`;
|
||||
}
|
||||
return wsUrl;
|
||||
}
|
||||
|
||||
/** Read the relay WebSocket URL from environment or derive from window.location. */
|
||||
export function relayWsUrl(): string {
|
||||
const envUrl = import.meta.env.VITE_RELAY_URL?.trim();
|
||||
if (envUrl) return envUrl.replace(/\/+$/, "");
|
||||
|
||||
// The relay and SPA share the Vite base path. This supports both a relay at
|
||||
// the origin root and reverse-proxy mounts such as https://host/buzz/.
|
||||
const url = new URL(import.meta.env.BASE_URL, window.location.origin);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** HTTP base URL for the relay (derived from the WS URL). */
|
||||
export function relayHttpBaseUrl(): string {
|
||||
return relayHttpUrl(relayWsUrl());
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@config "../../../tailwind.config.js";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Catppuccin Latte (mauve accent) */
|
||||
--radius: 0.625rem;
|
||||
--background: 220 23.08% 94.9%;
|
||||
--foreground: 234 16.02% 35.49%;
|
||||
--card: 220 23.08% 94.9%;
|
||||
--card-foreground: 234 16.02% 35.49%;
|
||||
--popover: 220 23.08% 94.9%;
|
||||
--popover-foreground: 234 16.02% 35.49%;
|
||||
--primary: 266 85.05% 58.04%;
|
||||
--primary-foreground: 220 23.08% 94.9%;
|
||||
--secondary: 223 15.91% 82.75%;
|
||||
--secondary-foreground: 234 16.02% 35.49%;
|
||||
--muted: 223 15.91% 82.75%;
|
||||
--muted-foreground: 233 12.8% 41.37%;
|
||||
--accent: 223 15.91% 82.75%;
|
||||
--accent-foreground: 234 16.02% 35.49%;
|
||||
--destructive: 347 86.67% 44.12%;
|
||||
--destructive-foreground: 220 23.08% 94.9%;
|
||||
--border: 225 13.56% 76.86%;
|
||||
--input: 225 13.56% 76.86%;
|
||||
--ring: 234 16.02% 35.49%;
|
||||
--chart-1: 345 57.65% 74.12%;
|
||||
--chart-2: 185 37.25% 70.59%;
|
||||
--chart-3: 219 81.96% 78.43%;
|
||||
--chart-4: 35 61.18% 76.86%;
|
||||
--chart-5: 113 32.94% 73.33%;
|
||||
--sidebar: 218 25.1% 93.73%;
|
||||
--sidebar-background: 218 25.1% 93.73%;
|
||||
--sidebar-foreground: 234 16.02% 35.49%;
|
||||
--sidebar-primary: 266 85.05% 58.04%;
|
||||
--sidebar-primary-foreground: 220 23.08% 94.9%;
|
||||
--sidebar-accent: 220 23.08% 94.9%;
|
||||
--sidebar-accent-foreground: 234 16.02% 35.49%;
|
||||
--sidebar-border: 225 13.56% 76.86%;
|
||||
--sidebar-ring: 225 13.56% 76.86%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Catppuccin Macchiato (mauve accent) */
|
||||
--radius: 0.625rem;
|
||||
--background: 232 23.4% 18.43%;
|
||||
--foreground: 227 68.25% 87.65%;
|
||||
--card: 232 23.4% 18.43%;
|
||||
--card-foreground: 227 68.25% 87.65%;
|
||||
--popover: 232 23.4% 18.43%;
|
||||
--popover-foreground: 227 68.25% 87.65%;
|
||||
--primary: 267 82.69% 79.61%;
|
||||
--primary-foreground: 232 23.4% 18.43%;
|
||||
--secondary: 230 18.8% 26.08%;
|
||||
--secondary-foreground: 227 68.25% 87.65%;
|
||||
--muted: 230 18.8% 26.08%;
|
||||
--muted-foreground: 228 39.22% 80%;
|
||||
--accent: 230 18.8% 26.08%;
|
||||
--accent-foreground: 227 68.25% 87.65%;
|
||||
--destructive: 351 73.91% 72.94%;
|
||||
--destructive-foreground: 232 23.4% 18.43%;
|
||||
--border: 231 15.61% 33.92%;
|
||||
--input: 231 15.61% 33.92%;
|
||||
--ring: 227 68.25% 87.65%;
|
||||
--chart-1: 333 20.78% 38.04%;
|
||||
--chart-2: 193 19.61% 38.04%;
|
||||
--chart-3: 224 26.67% 41.18%;
|
||||
--chart-4: 30 8.24% 42.35%;
|
||||
--chart-5: 139 10.98% 38.82%;
|
||||
--sidebar: 234 22.75% 17.25%;
|
||||
--sidebar-background: 234 22.75% 17.25%;
|
||||
--sidebar-foreground: 227 68.25% 87.65%;
|
||||
--sidebar-primary: 267 82.69% 79.61%;
|
||||
--sidebar-primary-foreground: 232 23.4% 18.43%;
|
||||
--sidebar-accent: 232 23.4% 18.43%;
|
||||
--sidebar-accent-foreground: 227 68.25% 87.65%;
|
||||
--sidebar-border: 231 15.61% 33.92%;
|
||||
--sidebar-ring: 231 15.61% 33.92%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family:
|
||||
"Inter Variable", Inter, "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans CJK SC",
|
||||
"Source Han Sans SC", "Avenir Next", "Segoe UI", sans-serif;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
isDark: boolean;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
function getSystemDark(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (!import.meta.env.DEV) return "system";
|
||||
|
||||
const previewTheme = new URLSearchParams(window.location.search).get(
|
||||
"previewTheme",
|
||||
);
|
||||
return previewTheme === "light" || previewTheme === "dark"
|
||||
? previewTheme
|
||||
: "system";
|
||||
}
|
||||
|
||||
function applyClass(isDark: boolean) {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(isDark ? "dark" : "light");
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
|
||||
const [isDark, setIsDark] = useState<boolean>(() => {
|
||||
const initialTheme = getInitialTheme();
|
||||
const dark =
|
||||
initialTheme === "system" ? getSystemDark() : initialTheme === "dark";
|
||||
applyClass(dark);
|
||||
return dark;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const dark = theme === "system" ? getSystemDark() : theme === "dark";
|
||||
applyClass(dark);
|
||||
setIsDark(dark);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== "system") return;
|
||||
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
applyClass(e.matches);
|
||||
setIsDark(e.matches);
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
setThemeState(t);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, isDark, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useI18n } from "@/shared/i18n/I18nProvider";
|
||||
|
||||
const icons = {
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
system: Monitor,
|
||||
} as const;
|
||||
|
||||
const next: Record<string, "dark" | "system" | "light"> = {
|
||||
light: "dark",
|
||||
dark: "system",
|
||||
system: "light",
|
||||
};
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const Icon = icons[theme];
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setTheme(next[theme])}
|
||||
aria-label={t("theme.switch", { theme: t(`theme.${theme}`) })}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-border/70 bg-card/80 text-card-foreground shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { type InputHTMLAttributes, forwardRef } from "react";
|
||||
|
||||
const Input = forwardRef<
|
||||
HTMLInputElement,
|
||||
InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={isDark ? "dark" : "light"}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,75 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'"Inter Variable"',
|
||||
"Inter",
|
||||
'"Avenir Next"',
|
||||
'"Segoe UI"',
|
||||
"sans-serif",
|
||||
],
|
||||
},
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
status: {
|
||||
added: "var(--status-added)",
|
||||
deleted: "var(--status-deleted)",
|
||||
modified: "var(--status-modified)",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "var(--ui-warning)",
|
||||
bg: "var(--ui-warning-bg)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,380 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("home page loads with Buzz branding", async ({ page }) => {
|
||||
await page.goto("./");
|
||||
await expect(
|
||||
page.getByRole("main").getByRole("img", { name: "Buzz" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("home page shows repositories section", async ({ page }) => {
|
||||
await page.goto("./");
|
||||
await expect(page.getByText("代码仓库")).toBeVisible();
|
||||
});
|
||||
|
||||
test("defaults to Simplified Chinese and persists the selected locale", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("./");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "zh-Hans");
|
||||
await expect(page.getByText("代码仓库")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "切换为 English" }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
await expect(page.getByText("Repositories")).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
await expect(page.getByText("Repositories")).toBeVisible();
|
||||
});
|
||||
|
||||
test("uses the /buzz base path for relay URLs and invite APIs", async ({
|
||||
page,
|
||||
}) => {
|
||||
let policyUrl = "";
|
||||
await page.route("**/buzz/api/join-policy", async (route) => {
|
||||
policyUrl = route.request().url();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ policy: null }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("./invite/base-path-code");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "在 Buzz 中接受邀请" }),
|
||||
).toHaveAttribute("href", /relay=ws%3A%2F%2F127\.0\.0\.1%3A4173%2Fbuzz/);
|
||||
expect(policyUrl).toContain("/buzz/api/join-policy");
|
||||
});
|
||||
|
||||
test("invite requires age and legal consent before opening Buzz", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/buzz/api/join-policy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
policy: {
|
||||
terms_markdown: "# Terms",
|
||||
privacy_markdown: "# Privacy",
|
||||
age_attestation_required: true,
|
||||
version: "policy-v1",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("https://api.github.com/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
body: JSON.stringify([
|
||||
{ draft: false, prerelease: false, assets: [] },
|
||||
{
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Buzz_0.4.9_aarch64.dmg",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_aarch64.dmg",
|
||||
},
|
||||
{
|
||||
name: "Buzz_0.4.9_x64.dmg",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_x64.dmg",
|
||||
},
|
||||
{
|
||||
name: "Buzz_0.4.9_amd64.AppImage",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_amd64.AppImage",
|
||||
},
|
||||
{
|
||||
name: "Buzz_0.4.9_x64-setup_alpha-unsigned.exe",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_x64-setup_alpha-unsigned.exe",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
await page.goto("./invite/demo-code");
|
||||
|
||||
await expect(page.getByRole("link", { name: "立即下载" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_x64-setup_alpha-unsigned.exe",
|
||||
);
|
||||
|
||||
const ageConfirmation = page.getByLabel("我已年满 18 周岁。");
|
||||
const agreementConfirmation = page.getByLabel(
|
||||
"我同意 Buzz 的服务条款和隐私政策。",
|
||||
);
|
||||
const acceptInvite = page.getByRole("button", {
|
||||
name: "在 Buzz 中接受邀请",
|
||||
});
|
||||
|
||||
await expect(ageConfirmation).toBeVisible();
|
||||
await expect(agreementConfirmation).toBeVisible();
|
||||
await expect(acceptInvite).toBeDisabled();
|
||||
|
||||
const termsLink = page.getByRole("button", { name: "服务条款" });
|
||||
const privacyLink = page.getByRole("button", { name: "隐私政策" });
|
||||
await expect(termsLink).toHaveCSS("text-decoration-line", "none");
|
||||
await expect(privacyLink).toHaveCSS("text-decoration-line", "none");
|
||||
await termsLink.hover();
|
||||
await expect(termsLink).toHaveCSS("text-decoration-line", "underline");
|
||||
await page.mouse.move(0, 0);
|
||||
await privacyLink.hover();
|
||||
await expect(privacyLink).toHaveCSS("text-decoration-line", "underline");
|
||||
|
||||
await page.locator("label").filter({ hasText: "我已年满 18 周岁。" }).click();
|
||||
await expect(ageConfirmation).toBeChecked();
|
||||
await expect(acceptInvite).toBeDisabled();
|
||||
await page
|
||||
.locator("label")
|
||||
.filter({
|
||||
hasText: "我同意 Buzz 的服务条款和隐私政策。",
|
||||
})
|
||||
.click({ position: { x: 8, y: 8 } });
|
||||
await expect(agreementConfirmation).toBeChecked();
|
||||
await expect(acceptInvite).toBeEnabled();
|
||||
|
||||
const consentBox = await page
|
||||
.getByTestId("invite-join-policy-notice")
|
||||
.boundingBox();
|
||||
const acceptButtonBox = await acceptInvite.boundingBox();
|
||||
expect(consentBox?.y).toBeLessThan(acceptButtonBox?.y ?? 0);
|
||||
expect(consentBox?.width).toBe(acceptButtonBox?.width);
|
||||
});
|
||||
|
||||
test("invite can enroll a NIP-07 identity for browser access", async ({
|
||||
page,
|
||||
}) => {
|
||||
const pubkey = "ab".repeat(32);
|
||||
await page.addInitScript((extensionPubkey) => {
|
||||
(
|
||||
window as Window & {
|
||||
nostr?: {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(
|
||||
event: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>>;
|
||||
};
|
||||
}
|
||||
).nostr = {
|
||||
async getPublicKey() {
|
||||
return extensionPubkey;
|
||||
},
|
||||
async signEvent(event) {
|
||||
return {
|
||||
...event,
|
||||
id: "cd".repeat(32),
|
||||
pubkey: extensionPubkey,
|
||||
sig: "ef".repeat(64),
|
||||
};
|
||||
},
|
||||
};
|
||||
}, pubkey);
|
||||
await page.route("**/buzz/api/join-policy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ policy: null }),
|
||||
});
|
||||
});
|
||||
|
||||
let claimObserved = false;
|
||||
await page.route("**/buzz/api/invites/claim", async (route) => {
|
||||
claimObserved = true;
|
||||
const request = route.request();
|
||||
const body = request.postData() ?? "";
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
code: "browser-code",
|
||||
});
|
||||
|
||||
const authorization = request.headers().authorization;
|
||||
expect(authorization).toMatch(/^Nostr /);
|
||||
const event = JSON.parse(
|
||||
Buffer.from(authorization.slice("Nostr ".length), "base64").toString(
|
||||
"utf8",
|
||||
),
|
||||
) as {
|
||||
pubkey: string;
|
||||
tags: string[][];
|
||||
};
|
||||
expect(event.pubkey).toBe(pubkey);
|
||||
expect(event.tags).toContainEqual(["u", request.url()]);
|
||||
expect(event.tags).toContainEqual(["method", "POST"]);
|
||||
expect(event.tags).toContainEqual([
|
||||
"payload",
|
||||
createHash("sha256").update(body).digest("hex"),
|
||||
]);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "joined",
|
||||
community_id: "community-id",
|
||||
host: "127.0.0.1",
|
||||
role: "member",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("./invite/browser-code");
|
||||
await page.getByRole("button", { name: "在浏览器中加入" }).click();
|
||||
await expect(page).toHaveURL(/\/buzz\/$/);
|
||||
expect(claimObserved).toBe(true);
|
||||
});
|
||||
|
||||
test("invite asks Safari users to choose their Mac download", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Version/26.5 Safari/605.1.15",
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperties(navigator, {
|
||||
platform: { configurable: true, value: "MacIntel" },
|
||||
maxTouchPoints: { configurable: true, value: 0 },
|
||||
userAgentData: { configurable: true, value: undefined },
|
||||
});
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.route("**/buzz/api/join-policy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ policy: null }),
|
||||
});
|
||||
});
|
||||
await page.route("https://api.github.com/**", async (route) => {
|
||||
await route.fulfill({ status: 500 });
|
||||
});
|
||||
|
||||
await page.goto("./invite/demo-code");
|
||||
const download = page.getByRole("link", { name: "立即下载" });
|
||||
await expect(download).toHaveAttribute("aria-haspopup", "dialog");
|
||||
await download.click();
|
||||
|
||||
const chooser = page.getByRole("dialog", {
|
||||
name: "你的 Mac 是哪一款?",
|
||||
});
|
||||
await expect(chooser).toBeVisible();
|
||||
await expect(chooser.getByRole("link", { name: /较新的 Mac/ })).toContainText(
|
||||
"2021 年或之后发布",
|
||||
);
|
||||
await expect(chooser.getByRole("link", { name: /较早的 Mac/ })).toContainText(
|
||||
"2019 年或之前发布",
|
||||
);
|
||||
await expect(chooser.getByText("关于本机")).toBeVisible();
|
||||
|
||||
const openedPagePromise = context.waitForEvent("page");
|
||||
await chooser.getByRole("link", { name: /较新的 Mac/ }).click();
|
||||
const openedPage = await openedPagePromise;
|
||||
await expect(chooser).toBeHidden();
|
||||
await expect(openedPage).toHaveURL("https://github.com/block/buzz/releases");
|
||||
await expect(page).toHaveURL(/\/buzz\/invite\/demo-code$/);
|
||||
await openedPage.close();
|
||||
|
||||
await download.click();
|
||||
await expect(chooser).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(chooser).toBeHidden();
|
||||
await expect(download).toBeFocused();
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("invite download falls back for mobile and non-desktop devices", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const unsupportedDevices = [
|
||||
{
|
||||
name: "iPhone Safari",
|
||||
platform: "iPhone",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15",
|
||||
maxTouchPoints: 5,
|
||||
},
|
||||
{
|
||||
name: "iPadOS desktop mode",
|
||||
platform: "MacIntel",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15",
|
||||
maxTouchPoints: 5,
|
||||
},
|
||||
{
|
||||
name: "Android phone",
|
||||
platform: "Linux armv8l",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro) AppleWebKit/537.36 Mobile",
|
||||
maxTouchPoints: 5,
|
||||
},
|
||||
{
|
||||
name: "ChromeOS",
|
||||
platform: "Linux x86_64",
|
||||
userAgent: "Mozilla/5.0 (X11; CrOS x86_64 16093.68.0) AppleWebKit/537.36",
|
||||
maxTouchPoints: 0,
|
||||
},
|
||||
];
|
||||
|
||||
for (const device of unsupportedDevices) {
|
||||
const context = await browser.newContext({ userAgent: device.userAgent });
|
||||
await context.addInitScript(({ platform, maxTouchPoints }) => {
|
||||
Object.defineProperties(navigator, {
|
||||
platform: { configurable: true, value: platform },
|
||||
maxTouchPoints: { configurable: true, value: maxTouchPoints },
|
||||
userAgentData: {
|
||||
configurable: true,
|
||||
value: { platform, mobile: maxTouchPoints > 0 },
|
||||
},
|
||||
});
|
||||
}, device);
|
||||
const page = await context.newPage();
|
||||
await page.route("**/buzz/api/join-policy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ policy: null }),
|
||||
});
|
||||
});
|
||||
await page.route("https://api.github.com/**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
body: JSON.stringify([
|
||||
{
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Buzz_0.4.9_x64.dmg",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_x64.dmg",
|
||||
},
|
||||
{
|
||||
name: "Buzz_0.4.9_amd64.AppImage",
|
||||
browser_download_url:
|
||||
"https://github.com/block/buzz/releases/download/v0.4.9/Buzz_0.4.9_amd64.AppImage",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("./invite/demo-code");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "立即下载" }),
|
||||
device.name,
|
||||
).toHaveAttribute("href", "https://github.com/block/buzz/releases");
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
// The relay normally serves the SPA at `/`. Deployments mounted below a
|
||||
// reverse-proxy prefix can set `VITE_BASE_PATH=/buzz/` at build time.
|
||||
base: process.env.VITE_BASE_PATH || "/",
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
target: "react",
|
||||
routesDirectory: "./src/app/routes",
|
||||
generatedRouteTree: "./src/app/routeTree.gen.ts",
|
||||
virtualRouteConfig: "./src/app/routes.ts",
|
||||
quoteStyle: "double",
|
||||
semicolons: true,
|
||||
routeTreeFileHeader: [
|
||||
"// biome-ignore-all lint: generated by TanStack Router",
|
||||
],
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: parseInt(process.env.VITE_PORT || "5173", 10),
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user