mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(web): browse git repos in-browser via isomorphic-git (#554)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,16 +16,22 @@
|
||||
"test:e2e:smoke": "pnpm build && playwright test --project=smoke"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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.37.6",
|
||||
"lucide-react": "^0.577.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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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(`sprout-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`;
|
||||
}
|
||||
|
||||
function authHeaders(owner: string, repoName: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: 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 = 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 });
|
||||
|
||||
// Check first 512 bytes for NUL to detect binary
|
||||
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 };
|
||||
}
|
||||
|
||||
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,70 @@
|
||||
import { GitCommit } from "lucide-react";
|
||||
import { relativeTime } from "@/shared/lib/relative-time";
|
||||
import type { CommitInfo } from "../git-client";
|
||||
|
||||
function CommitRow({ commit }: { commit: CommitInfo }) {
|
||||
const firstLine = commit.message.split("\n")[0];
|
||||
return (
|
||||
<div className="flex items-start gap-3 border-b border-border px-3 py-2.5 text-sm last:border-b-0">
|
||||
<GitCommit className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{firstLine}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{commit.author.name} committed {relativeTime(commit.author.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<code className="shrink-0 self-center rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
{commit.oid.slice(0, 7)}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepoCommitsSection({
|
||||
commits,
|
||||
isLoading,
|
||||
}: {
|
||||
commits: CommitInfo[] | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
Recent commits
|
||||
</h2>
|
||||
<div className="rounded-lg border border-border">
|
||||
{["sk-1", "sk-2", "sk-3"].map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 border-b border-border px-3 py-2.5 last:border-b-0"
|
||||
>
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
|
||||
</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">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
Recent commits
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{commits.map((commit) => (
|
||||
<CommitRow key={commit.oid} commit={commit} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -12,28 +13,17 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { relativeTime } from "@/shared/lib/relative-time";
|
||||
import { useRepoRefs } from "../use-repo-refs";
|
||||
import { useRepo } from "../use-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";
|
||||
|
||||
function relativeTime(unix: number): string {
|
||||
const now = Date.now();
|
||||
const diff = 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);
|
||||
|
||||
if (days > 30) {
|
||||
const months = Math.floor(days / 30);
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`;
|
||||
}
|
||||
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
|
||||
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
|
||||
if (minutes > 0)
|
||||
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
|
||||
return "just now";
|
||||
}
|
||||
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);
|
||||
@@ -70,14 +60,78 @@ function CopyableUrl({ url }: { url: string }) {
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
||||
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-6 h-8 w-64 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-3 h-5 w-96 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-8 space-y-3">
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
<div className="h-10 w-full animate-pulse rounded bg-muted" />
|
||||
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-6 h-8 w-64 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-3 h-5 w-96 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-8 space-y-3">
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
<div className="h-10 w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<aside className="hidden w-72 shrink-0 lg:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Tab = "code" | "commits";
|
||||
|
||||
function RepoTabs({
|
||||
treeEntries,
|
||||
treeLoading,
|
||||
commits,
|
||||
commitsLoading,
|
||||
readme,
|
||||
readmeLoading,
|
||||
}: {
|
||||
treeEntries: TreeEntry[] | undefined;
|
||||
treeLoading: boolean;
|
||||
commits: CommitInfo[] | undefined;
|
||||
commitsLoading: boolean;
|
||||
readme: ReadmeResult | null | undefined;
|
||||
readmeLoading: boolean;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("code");
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("code")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
tab === "code"
|
||||
? "border-b-2 border-foreground text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
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-foreground text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Commits
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === "code" && (
|
||||
<>
|
||||
<RepoTreeSection entries={treeEntries} isLoading={treeLoading} />
|
||||
<RepoReadmeSection readme={readme} isLoading={readmeLoading} />
|
||||
</>
|
||||
)}
|
||||
{tab === "commits" && (
|
||||
<RepoCommitsSection commits={commits} isLoading={commitsLoading} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -85,6 +139,35 @@ function DetailSkeleton() {
|
||||
export function RepoDetailPage() {
|
||||
const { repoId } = useParams({ from: "/repos/$repoId" });
|
||||
const { data: repo, isLoading, error } = useRepo(repoId);
|
||||
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId);
|
||||
|
||||
const defaultRef = refs?.head?.ref ?? "main";
|
||||
const owner = repo?.owner ?? "";
|
||||
const repoName = repo?.id ?? "";
|
||||
|
||||
const {
|
||||
data: treeEntries,
|
||||
isLoading: treeLoading,
|
||||
error: treeError,
|
||||
} = useGitTree(owner, repoName, defaultRef);
|
||||
const {
|
||||
data: commits,
|
||||
isLoading: commitsLoading,
|
||||
error: commitsError,
|
||||
} = useGitLog(owner, repoName, defaultRef);
|
||||
const { data: readme, isLoading: readmeLoading } = useGitReadme(
|
||||
owner,
|
||||
repoName,
|
||||
defaultRef,
|
||||
);
|
||||
|
||||
// Surface clone/browse errors — these are otherwise silent
|
||||
const browseError = treeError || commitsError;
|
||||
useEffect(() => {
|
||||
if (browseError) {
|
||||
console.error("[git-browse]", browseError);
|
||||
}
|
||||
}, [browseError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -98,7 +181,34 @@ export function RepoDetailPage() {
|
||||
|
||||
if (!repo) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to repositories
|
||||
</Link>
|
||||
<div className="mt-12 text-center">
|
||||
<BookMarked className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||
<h1 className="mt-4 text-xl font-semibold">Repository not found</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
This repository may have been removed or doesn't exist on this
|
||||
relay.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="hidden w-72 shrink-0 lg:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
|
||||
{/* Main content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
@@ -106,94 +216,126 @@ export function RepoDetailPage() {
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to repositories
|
||||
</Link>
|
||||
<div className="mt-12 text-center">
|
||||
<BookMarked className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||
<h1 className="mt-4 text-xl font-semibold">Repository not found</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
This repository may have been removed or doesn't exist on this
|
||||
relay.
|
||||
|
||||
{/* 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-muted-foreground" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{repo.name}
|
||||
</h1>
|
||||
<Badge variant="outline">Public</Badge>
|
||||
</div>
|
||||
{repo.description && (
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Updated {relativeTime(repo.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to repositories
|
||||
</Link>
|
||||
{/* Refs & HEAD */}
|
||||
<RepoRefsSection refs={refs} isLoading={refsLoading} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<BookMarked className="h-6 w-6 shrink-0 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{repo.name}</h1>
|
||||
<Badge variant="outline">Public</Badge>
|
||||
</div>
|
||||
{repo.description && (
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{repo.description}
|
||||
</p>
|
||||
{/* 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">
|
||||
Failed to load repository contents:{" "}
|
||||
{browseError instanceof Error
|
||||
? browseError.message
|
||||
: String(browseError)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<RepoTabs
|
||||
treeEntries={treeEntries}
|
||||
treeLoading={treeLoading}
|
||||
commits={commits}
|
||||
commitsLoading={commitsLoading}
|
||||
readme={readme}
|
||||
readmeLoading={readmeLoading}
|
||||
/>
|
||||
|
||||
{/* Clone URLs */}
|
||||
{repo.cloneUrls.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 text-sm font-semibold">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" asChild>
|
||||
<a href={safe} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View on web
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Channel link */}
|
||||
{repo.channelId && (
|
||||
<div className="mt-8">
|
||||
<Button variant="outline" asChild>
|
||||
<a href={`/channels/${repo.channelId}`}>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
View channel
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Updated {relativeTime(repo.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Clone URLs */}
|
||||
{repo.cloneUrls.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 text-sm font-semibold">Clone</h2>
|
||||
<div className="space-y-2">
|
||||
{repo.cloneUrls.map((url) => (
|
||||
<CopyableUrl key={url} url={url} />
|
||||
))}
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden w-72 shrink-0 border-l border-border pl-8 lg:block">
|
||||
<div className="space-y-6">
|
||||
{/* Open in Sprout */}
|
||||
<ConnectButton className="w-full" />
|
||||
|
||||
{/* People */}
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<Users className="h-4 w-4" />
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* External link */}
|
||||
{repo.webUrl && (
|
||||
<div className="mt-6">
|
||||
<Button variant="outline" asChild>
|
||||
<a href={repo.webUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View on web
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner & Contributors */}
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<Users className="h-4 w-4" />
|
||||
People
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
{/* Open in Sprout CTA */}
|
||||
<div className="mt-8 rounded-lg border border-border bg-muted/30 p-6 text-center">
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Open this relay in the Sprout desktop app to push code and
|
||||
collaborate.
|
||||
</p>
|
||||
<ConnectButton />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 type { Repo } from "../use-repos";
|
||||
|
||||
function truncateHex(hex: string): string {
|
||||
@@ -10,25 +11,6 @@ function truncateHex(hex: string): string {
|
||||
return `${hex.slice(0, 8)}...${hex.slice(-4)}`;
|
||||
}
|
||||
|
||||
function relativeTime(unix: number): string {
|
||||
const now = Date.now();
|
||||
const diff = 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);
|
||||
|
||||
if (days > 30) {
|
||||
const months = Math.floor(days / 30);
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`;
|
||||
}
|
||||
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
|
||||
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
|
||||
if (minutes > 0)
|
||||
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
|
||||
return "just now";
|
||||
}
|
||||
|
||||
export function RepoListItem({ repo }: { repo: Repo }) {
|
||||
return (
|
||||
<div className="py-6">
|
||||
|
||||
@@ -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">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
README
|
||||
</h2>
|
||||
<div className="space-y-2 rounded-lg border border-border p-4">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-5/6 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!readme) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
{readme.filename}
|
||||
</h2>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border p-4">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{readme.content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { GitBranch, Hash, Tag } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import type { RepoRefs } from "../use-repo-refs";
|
||||
|
||||
export function RepoRefsSection({
|
||||
refs,
|
||||
isLoading,
|
||||
}: {
|
||||
refs: RepoRefs | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
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-muted-foreground">
|
||||
{refs.head && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="secondary">
|
||||
<GitBranch className="mr-1 h-3 w-3" />
|
||||
{refs.head.ref}
|
||||
</Badge>
|
||||
{refs.head.sha && (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
<Hash className="mr-0.5 h-3 w-3" />
|
||||
{refs.head.sha.slice(0, 7)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
{refs.branches.length}{" "}
|
||||
{refs.branches.length === 1 ? "branch" : "branches"}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
{refs.tags.length} {refs.tags.length === 1 ? "tag" : "tags"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No commits yet</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { File, Folder } from "lucide-react";
|
||||
import type { TreeEntry } from "../git-client";
|
||||
|
||||
function TreeRow({ entry }: { entry: TreeEntry }) {
|
||||
const isDir = entry.type === "tree";
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm last:border-b-0">
|
||||
{isDir ? (
|
||||
<Folder className="h-4 w-4 shrink-0 text-blue-400" />
|
||||
) : (
|
||||
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className={isDir ? "font-medium" : ""}>{entry.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepoTreeSection({
|
||||
entries,
|
||||
isLoading,
|
||||
}: {
|
||||
entries: TreeEntry[] | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="rounded-lg border border-border">
|
||||
{["sk-1", "sk-2", "sk-3", "sk-4", "sk-5"].map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 border-b border-border px-3 py-2 last:border-b-0"
|
||||
>
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{entries.map((entry) => (
|
||||
<TreeRow key={entry.name} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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,
|
||||
readFileContent,
|
||||
readTreeEntries,
|
||||
} 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 () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
const { fs, dir } = cloneQuery.data!;
|
||||
return findReadme(fs, dir, ref);
|
||||
},
|
||||
enabled: !!cloneQuery.data,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read a single file's content. */
|
||||
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 () => {
|
||||
const { fs, dir } = cloneQuery.data!;
|
||||
const oid = await resolveRef({ fs, dir, ref });
|
||||
return readFileContent(fs, dir, oid, filepath);
|
||||
},
|
||||
enabled: !!cloneQuery.data && !!filepath,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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) {
|
||||
return useQuery({
|
||||
queryKey: ["repo-refs", repoId],
|
||||
queryFn: () => fetchRepoRefs(repoId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -8,13 +8,14 @@ export interface Repo {
|
||||
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. */
|
||||
function getTag(event: NostrEvent, name: string): string | undefined {
|
||||
export function getTag(event: NostrEvent, name: string): string | undefined {
|
||||
return event.tags.find((t) => t[0] === name)?.[1];
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ function eventToRepo(event: NostrEvent): Repo {
|
||||
const description = getTag(event, "description") || event.content || "";
|
||||
const cloneUrls = getAllTags(event, "clone");
|
||||
const webUrl = getTag(event, "web") ?? null;
|
||||
const channelId = getTag(event, "sprout-channel") ?? null;
|
||||
const contributors = getAllTags(event, "p");
|
||||
const owner = event.pubkey;
|
||||
|
||||
@@ -38,6 +40,7 @@ function eventToRepo(event: NostrEvent): Repo {
|
||||
description,
|
||||
cloneUrls,
|
||||
webUrl,
|
||||
channelId,
|
||||
owner,
|
||||
contributors,
|
||||
createdAt: event.created_at,
|
||||
@@ -45,7 +48,7 @@ function eventToRepo(event: NostrEvent): Repo {
|
||||
}
|
||||
|
||||
/** Deduplicate NIP-33 parameterized replaceable events, keeping the latest per (pubkey, kind, d-tag). */
|
||||
function dedup(events: NostrEvent[]): NostrEvent[] {
|
||||
export function dedup(events: NostrEvent[]): NostrEvent[] {
|
||||
const best = new Map<string, NostrEvent>();
|
||||
for (const e of events) {
|
||||
const d = getTag(e, "d") ?? "";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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 { finalizeEvent } from "nostr-tools/pure";
|
||||
import { getEphemeralKey } from "./nostr-client";
|
||||
|
||||
/**
|
||||
* Build a NIP-98 Authorization header value.
|
||||
*
|
||||
* Creates a kind:27235 event with `u` and `method` tags, signs it with the
|
||||
* session's ephemeral key, base64-encodes the JSON, and returns
|
||||
* `"Nostr <base64>"`.
|
||||
*/
|
||||
export function makeNip98AuthHeader(url: string, method: string): string {
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["u", url],
|
||||
["method", method],
|
||||
],
|
||||
content: "",
|
||||
},
|
||||
getEphemeralKey(),
|
||||
);
|
||||
|
||||
const json = JSON.stringify(event);
|
||||
const base64 = btoa(json);
|
||||
return `Nostr ${base64}`;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ const QUERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Lazily-generated ephemeral keypair for NIP-42 AUTH. */
|
||||
let _secretKey: Uint8Array | null = null;
|
||||
function getEphemeralKey(): Uint8Array {
|
||||
export function getEphemeralKey(): Uint8Array {
|
||||
if (!_secretKey) {
|
||||
_secretKey = generateSecretKey();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Format a Unix timestamp (seconds) as a human-readable relative time string. */
|
||||
export function relativeTime(unix: number): 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);
|
||||
|
||||
if (days > 30) {
|
||||
const months = Math.floor(days / 30);
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`;
|
||||
}
|
||||
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
|
||||
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
|
||||
if (minutes > 0)
|
||||
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
|
||||
return "just now";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import tailwindcssAnimate from "tailwindcss-animate";
|
||||
import tailwindcssTypography from "@tailwindcss/typography";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
@@ -67,5 +68,5 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [tailwindcssAnimate],
|
||||
plugins: [tailwindcssAnimate, tailwindcssTypography],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user