Adds the Vectrum app (repositories, code browsing, editing, issues, settings, SSH and HTTPS git) and styles the whole UI with MetalUI (@unlocalhosted/metalui): bone and graphite colorways, Sidebar, Menu, CommandPalette search, Tabs, Switcher, Select, Popover, Dialog, AlertDialog, DropZone, Attachment, Field, Textarea, EmptyState, Toasts and MetalUI icons. Pieces without a MetalUI component are drawn with its theme utilities (materials, wells, engraved labels, type roles). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 131 changed files with 11757 additions and 111 deletions.
| @@ -39,3 +39,6 @@ yarn-error.log* | ||
| 39 | 39 | # typescript |
| 40 | 40 | *.tsbuildinfo |
| 41 | 41 | next-env.d.ts |
| 42 | + | |
| 43 | + # git repositories and issue data | |
| 44 | + /data/ | |
| @@ -1,36 +1,94 @@ | ||
| 1 | - This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). | |
| 1 | + # Vectrum | |
| 2 | 2 | |
| 3 | - ## Getting Started | |
| 3 | + [vectrum.cc](https://vectrum.cc) · A self-hosted, multi-user GitHub built with Next.js. Repositories are real bare | |
| 4 | + git repositories on disk, and you push to them with plain git over **SSH** or | |
| 5 | + **HTTPS**. | |
| 4 | 6 | |
| 5 | - First, run the development server: | |
| 7 | + ## Features | |
| 8 | + | |
| 9 | + - **Accounts**: sign up, sign in, profiles, password change (scrypt-hashed passwords, database-backed sessions) | |
| 10 | + - **Repositories**: public or private, rename, delete, optional README on creation | |
| 11 | + - **Collaborators**: give other users push access to a repository | |
| 12 | + - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys* | |
| 13 | + - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens | |
| 14 | + - **API tokens**: read-only or read & write, for all repositories or selected ones, with expiry. Only a hash is stored | |
| 15 | + - **Web editor**: CodeMirror with syntax highlighting and markdown preview. Create, edit, rename, upload and delete files, committing to the current branch or a new one | |
| 16 | + - **Code browsing**: file tree, highlighting (Shiki), rendered READMEs (sanitized HTML), raw files, image previews | |
| 17 | + - **History**: commit log per branch or file, commit diffs, branches overview, recent-activity dashboard | |
| 18 | + - **Issues as a Linear-style project tracker**, per repository: | |
| 19 | + - Workflow statuses (Backlog → Todo → In Progress → In Review → Done / Canceled), priorities, assignees, labels, estimates and due dates | |
| 20 | + - List view grouped by status, priority or assignee, and a kanban board with drag and drop | |
| 21 | + - Filters (assignee, priority, label, "My issues"), scopes (All / Active / Backlog) and orderings, kept in the URL so views can be shared | |
| 22 | + - Quick-create dialog and keyboard shortcuts: `C` new issue, `J`/`K` move, `Enter` open, `S`/`P`/`A`/`L` set status, priority, assignee, labels | |
| 23 | + - Inline editing of every property, with an activity feed of changes next to the markdown comments | |
| 24 | + - Anyone who can read a repository can open issues and comment, but only collaborators and the owner can change an issue (title, description, status and all other properties). Only an issue's creator and the repository owner can delete it. Labels are managed under *Settings → Labels* or created from the label menu | |
| 25 | + - Light and dark mode | |
| 26 | + | |
| 27 | + ## Getting started | |
| 28 | + | |
| 29 | + Requires Node.js 22.5+ (for the built-in `node:sqlite`) and `git` on the `PATH`. | |
| 6 | 30 | |
| 7 | 31 | ```bash |
| 8 | - npm run dev | |
| 9 | - # or | |
| 10 | - yarn dev | |
| 11 | - # or | |
| 12 | - pnpm dev | |
| 13 | - # or | |
| 32 | + bun install | |
| 14 | 33 | bun dev |
| 15 | 34 | ``` |
| 16 | 35 | |
| 17 | - Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. | |
| 36 | + Open http://localhost:3000, sign up, and create a repository. | |
| 37 | + | |
| 38 | + ### Pushing over SSH | |
| 39 | + | |
| 40 | + 1. Add your public key (`~/.ssh/id_ed25519.pub`) under **Settings → SSH keys**. | |
| 41 | + 2. Check the connection: `ssh -T -p 2222 git@localhost` | |
| 42 | + 3. Push: | |
| 43 | + ```bash | |
| 44 | + git remote add origin ssh://git@localhost:2222/<you>/<repo>.git | |
| 45 | + git push -u origin main | |
| 46 | + ``` | |
| 47 | + | |
| 48 | + ### Pushing over HTTPS | |
| 18 | 49 | |
| 19 | - You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. | |
| 50 | + Generate a token under **Settings → API tokens** and use it as the password | |
| 51 | + when git asks (any username works): | |
| 20 | 52 | |
| 21 | - This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. | |
| 53 | + ```bash | |
| 54 | + git clone http://localhost:3000/<you>/<repo>.git | |
| 55 | + # Username: <you> Password: vec_… | |
| 56 | + ``` | |
| 57 | + | |
| 58 | + Anonymous users can clone public repositories without a token. | |
| 22 | 59 | |
| 23 | - ## Learn More | |
| 60 | + ## Configuration | |
| 24 | 61 | |
| 25 | - To learn more about Next.js, take a look at the following resources: | |
| 62 | + | Variable | Default | Purpose | | |
| 63 | + | ----------------- | ----------- | --------------------------------------------------------- | | |
| 64 | + | `DATA_ROOT` | `./data` | Where repositories, the database and the SSH host key live | | |
| 65 | + | `SSH_PORT` | `2222` | Port of the built-in SSH server | | |
| 66 | + | `SSH_LISTEN_HOST` | `0.0.0.0` | Interface the SSH server binds to | | |
| 67 | + | `SSH_HOST` | request host | Host name shown in SSH clone URLs, e.g. `vectrum.cc` | | |
| 68 | + | `SSH_DISABLED` | — | Set to `1` to not start the SSH server | | |
| 26 | 69 | |
| 27 | - - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. | |
| 28 | - - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. | |
| 70 | + Data layout: | |
| 71 | + | |
| 72 | + ``` | |
| 73 | + data/ | |
| 74 | + app.db users, sessions, keys, tokens, repos, issues (SQLite) | |
| 75 | + ssh_host_ed25519_key generated on first start | |
| 76 | + repos/<owner>/<repo>.git bare git repositories | |
| 77 | + ``` | |
| 29 | 78 | |
| 30 | - You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! | |
| 79 | + ## Security notes | |
| 31 | 80 | |
| 32 | - ## Deploy on Vercel | |
| 81 | + - Serve the web UI over HTTPS if it's reachable beyond localhost. Session cookies become `Secure` when `X-Forwarded-Proto: https` is set. | |
| 82 | + - Private repositories show a 404 page to anyone without access, so their existence isn't revealed. | |
| 83 | + - Raw files are served with `nosniff` and a sandboxing CSP, so repository HTML/SVG can't run scripts in the app's origin. | |
| 33 | 84 | |
| 34 | - The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. | |
| 85 | + ## Layout | |
| 35 | 86 | |
| 36 | - Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. | |
| 87 | + - `lib/db.ts`: SQLite schema and connection | |
| 88 | + - `lib/users.ts`, `lib/auth.ts`: accounts, password hashing, sessions | |
| 89 | + - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators | |
| 90 | + - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/` | |
| 91 | + - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens | |
| 92 | + - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories | |
| 93 | + - `lib/git-http.ts`, `lib/ssh-server.ts`: git transports (started from `instrumentation.ts`) | |
| 94 | + - `app/actions/*`: server actions, each re-checking the session and permissions | |
| @@ -0,0 +1,111 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import { notFound, redirect } from "next/navigation"; | |
| 3 | + import { BranchSelect } from "@/components/branch-select"; | |
| 4 | + import { Breadcrumbs } from "@/components/breadcrumbs"; | |
| 5 | + import { CopyButton } from "@/components/clone-button"; | |
| 6 | + import { CodeView } from "@/components/code-view"; | |
| 7 | + import { FileActions } from "@/components/editor-file-actions"; | |
| 8 | + import { LatestCommitBar } from "@/components/file-table"; | |
| 9 | + import { Markdown } from "@/components/markdown"; | |
| 10 | + import { Box, Segmented, segmentClass } from "@/components/ui"; | |
| 11 | + import { lastCommit, listBranches, objectType, readBlob } from "@/lib/git"; | |
| 12 | + import { encodePath, formatBytes } from "@/lib/format"; | |
| 13 | + import { languageFor } from "@/lib/languages"; | |
| 14 | + import { getRefAndPath, getRepo } from "@/lib/repo"; | |
| 15 | + import { IMAGE_TYPES } from "@/lib/mime"; | |
| 16 | + | |
| 17 | + export default async function BlobPage({ params, searchParams }: PageProps<"/[owner]/[repo]/blob/[...segments]">) { | |
| 18 | + const { owner, repo: repoParam, segments } = await params; | |
| 19 | + const { plain } = await searchParams; | |
| 20 | + const repo = await getRepo(owner, repoParam); | |
| 21 | + const { ref, path } = await getRefAndPath(repo, segments); | |
| 22 | + const type = await objectType(repo.dir, ref, path); | |
| 23 | + if (type === "tree") redirect(`${repo.base}/tree/${encodePath(ref)}/${encodePath(path)}`); | |
| 24 | + if (type !== "blob") notFound(); | |
| 25 | + | |
| 26 | + const [blob, commit, branches] = await Promise.all([ | |
| 27 | + readBlob(repo.dir, ref, path), | |
| 28 | + lastCommit(repo.dir, ref, path), | |
| 29 | + listBranches(repo.dir), | |
| 30 | + ]); | |
| 31 | + const filename = path.split("/").pop()!; | |
| 32 | + const ext = filename.split(".").pop()!.toLowerCase(); | |
| 33 | + const rawHref = `${repo.base}/raw/${encodePath(ref)}/${encodePath(path)}`; | |
| 34 | + const text = blob.binary || blob.tooLarge ? null : blob.content.toString("utf8"); | |
| 35 | + const isMarkdown = /\.(md|markdown)$/i.test(filename); | |
| 36 | + const showRendered = isMarkdown && plain !== "1"; | |
| 37 | + const lines = text === null ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0); | |
| 38 | + const editable = repo.canWrite && branches.some((b) => b.name === ref); | |
| 39 | + const selfHref = `${repo.base}/blob/${encodePath(ref)}/${encodePath(path)}`; | |
| 40 | + const language = languageFor(filename)?.name; | |
| 41 | + const meta = [text !== null && `${lines} line${lines === 1 ? "" : "s"}`, formatBytes(blob.size), language].filter(Boolean).join(" · "); | |
| 42 | + | |
| 43 | + let content; | |
| 44 | + if (ext in IMAGE_TYPES) { | |
| 45 | + content = ( | |
| 46 | + <div className="p-3"> | |
| 47 | + <div className="well flex justify-center rounded-plate bg-[repeating-conic-gradient(var(--mu-s-lo)_0_25%,var(--mu-s-hi)_0_50%)] bg-[length:20px_20px] p-8"> | |
| 48 | + {/* eslint-disable-next-line @next/next/no-img-element */} | |
| 49 | + <img src={rawHref} alt={filename} className="max-w-full rounded-row shadow-lift" /> | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + ); | |
| 53 | + } else if (text === null) { | |
| 54 | + content = ( | |
| 55 | + <p className="px-6 py-12 text-center type-body text-ink2"> | |
| 56 | + {blob.tooLarge ? "This file is too large to display." : "Binary file not shown."}{" "} | |
| 57 | + <Link href={rawHref} prefetch={false} className="text-ink underline decoration-rule underline-offset-2 hover:decoration-current"> | |
| 58 | + View raw | |
| 59 | + </Link> | |
| 60 | + </p> | |
| 61 | + ); | |
| 62 | + } else if (showRendered) { | |
| 63 | + const dir = path.split("/").slice(0, -1).join("/"); | |
| 64 | + content = ( | |
| 65 | + <div className="px-6 py-8 md:px-10"> | |
| 66 | + <Markdown context={{ base: repo.base, gitRef: ref, dir }}>{text}</Markdown> | |
| 67 | + </div> | |
| 68 | + ); | |
| 69 | + } else { | |
| 70 | + content = <CodeView code={text} filename={filename} />; | |
| 71 | + } | |
| 72 | + | |
| 73 | + return ( | |
| 74 | + <div> | |
| 75 | + <div className="mb-4 flex flex-wrap items-center gap-3"> | |
| 76 | + <BranchSelect base={repo.base} kind="blob" current={ref} branches={branches.map((b) => b.name)} path={path} /> | |
| 77 | + <Breadcrumbs base={repo.base} name={repo.name} gitRef={ref} path={path} /> | |
| 78 | + <CopyButton text={path} label="Copy path" /> | |
| 79 | + </div> | |
| 80 | + {commit && ( | |
| 81 | + <div className="mb-4"> | |
| 82 | + <LatestCommitBar repo={repo} commit={commit} gitRef={ref} path={path} /> | |
| 83 | + </div> | |
| 84 | + )} | |
| 85 | + <Box className="overflow-hidden"> | |
| 86 | + <div className="rule-b flex min-h-12 flex-wrap items-center gap-3 px-3 py-2"> | |
| 87 | + {isMarkdown && ( | |
| 88 | + <Segmented> | |
| 89 | + <Link href={selfHref} className={segmentClass(showRendered)} aria-current={showRendered ? "page" : undefined}> | |
| 90 | + Preview | |
| 91 | + </Link> | |
| 92 | + <Link href={`${selfHref}?plain=1`} className={segmentClass(!showRendered)} aria-current={showRendered ? undefined : "page"}> | |
| 93 | + Code | |
| 94 | + </Link> | |
| 95 | + </Segmented> | |
| 96 | + )} | |
| 97 | + <span className="px-1 type-readout text-ink2 tabular-nums">{meta}</span> | |
| 98 | + <div className="ml-auto"> | |
| 99 | + <FileActions | |
| 100 | + rawHref={rawHref} | |
| 101 | + text={text} | |
| 102 | + editHref={editable && text !== null ? `${repo.base}/edit/${encodePath(ref)}/${encodePath(path)}` : undefined} | |
| 103 | + deleteHref={editable ? `${repo.base}/delete/${encodePath(ref)}/${encodePath(path)}` : undefined} | |
| 104 | + /> | |
| 105 | + </div> | |
| 106 | + </div> | |
| 107 | + {content} | |
| 108 | + </Box> | |
| 109 | + </div> | |
| 110 | + ); | |
| 111 | + } | |
| @@ -0,0 +1,57 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import type { Metadata } from "next"; | |
| 3 | + import { BranchIcon, HistoryIcon } from "@/components/icons"; | |
| 4 | + import { liftRowClass, plateHeadingClass } from "@/components/repo-rows"; | |
| 5 | + import { Blankslate, Box, ButtonLink, Label, PageTitle, RefChip } from "@/components/ui"; | |
| 6 | + import { listBranches } from "@/lib/git"; | |
| 7 | + import { encodePath, timeAgo } from "@/lib/format"; | |
| 8 | + import { getRepo } from "@/lib/repo"; | |
| 9 | + | |
| 10 | + export const metadata: Metadata = { title: "Branches" }; | |
| 11 | + | |
| 12 | + export default async function BranchesPage({ params }: PageProps<"/[owner]/[repo]/branches">) { | |
| 13 | + const { owner, repo: repoParam } = await params; | |
| 14 | + const repo = await getRepo(owner, repoParam); | |
| 15 | + const branches = await listBranches(repo.dir); | |
| 16 | + | |
| 17 | + return ( | |
| 18 | + <div> | |
| 19 | + <PageTitle title="Branches" /> | |
| 20 | + <Box className="p-1.5"> | |
| 21 | + <h2 className={plateHeadingClass}> | |
| 22 | + All branches <span className="type-readout text-ink3 [text-shadow:none]">{branches.length}</span> | |
| 23 | + </h2> | |
| 24 | + {branches.length === 0 && ( | |
| 25 | + <Blankslate icon={<BranchIcon />} title="No branches yet"> | |
| 26 | + Push to this repository to create one. | |
| 27 | + </Blankslate> | |
| 28 | + )} | |
| 29 | + <ul> | |
| 30 | + {branches.map((b) => ( | |
| 31 | + <li key={b.name} className={`${liftRowClass} flex min-h-13 items-center gap-3 px-3 py-2`}> | |
| 32 | + <span className="icon-tile size-8 shrink-0 rounded-row"> | |
| 33 | + <BranchIcon width={15} height={15} /> | |
| 34 | + </span> | |
| 35 | + <Link href={`${repo.base}/tree/${encodePath(b.name)}`} className="shrink-0 rounded-[7px] transition-opacity hover:opacity-80 focus-visible:focus-ring"> | |
| 36 | + <RefChip>{b.name}</RefChip> | |
| 37 | + </Link> | |
| 38 | + {b.name === repo.defaultBranch && <Label tone="accent">default</Label>} | |
| 39 | + <span className="min-w-0 truncate type-ui text-ink2">{b.subject}</span> | |
| 40 | + <span className="ml-auto hidden shrink-0 type-meta text-ink3 sm:inline"> | |
| 41 | + Updated <time dateTime={new Date(b.date).toISOString()} className="type-readout">{timeAgo(b.date)}</time> | |
| 42 | + </span> | |
| 43 | + <ButtonLink | |
| 44 | + href={`${repo.base}/commits/${encodePath(b.name)}`} | |
| 45 | + variant="invisible" | |
| 46 | + icon={<HistoryIcon width={14} height={14} />} | |
| 47 | + className="shrink-0 max-sm:ml-auto" | |
| 48 | + > | |
| 49 | + Commits | |
| 50 | + </ButtonLink> | |
| 51 | + </li> | |
| 52 | + ))} | |
| 53 | + </ul> | |
| 54 | + </Box> | |
| 55 | + </div> | |
| 56 | + ); | |
| 57 | + } | |
| @@ -0,0 +1,165 @@ | ||
| 1 | + import { notFound } from "next/navigation"; | |
| 2 | + import { ShaChip } from "@/components/file-table"; | |
| 3 | + import { CodeIcon, FileIcon } from "@/components/icons"; | |
| 4 | + import { Avatar, Box, ButtonLink, Label, RefChip } from "@/components/ui"; | |
| 5 | + import { diffCommit, getCommit, type FileDiff } from "@/lib/git"; | |
| 6 | + import { encodePath, shortSha, timeAgo } from "@/lib/format"; | |
| 7 | + import { getRepo } from "@/lib/repo"; | |
| 8 | + | |
| 9 | + const MAX_RENDERED_LINES = 3000; | |
| 10 | + | |
| 11 | + const lineStyles = { | |
| 12 | + add: { row: "bg-[var(--diff-add)]", num: "bg-[var(--diff-add-num)]", sign: "+" }, | |
| 13 | + del: { row: "bg-[var(--diff-del)]", num: "bg-[var(--diff-del-num)]", sign: "-" }, | |
| 14 | + ctx: { row: "", num: "", sign: " " }, | |
| 15 | + }; | |
| 16 | + | |
| 17 | + /** The change as five lamps: green for the added share, red for the deleted, dark when nothing changed. */ | |
| 18 | + function DiffStat({ additions, deletions }: { additions: number; deletions: number }) { | |
| 19 | + const total = additions + deletions; | |
| 20 | + const greens = total ? Math.round((additions / total) * 5) : 0; | |
| 21 | + return ( | |
| 22 | + <span className="flex shrink-0 items-center gap-2 type-readout"> | |
| 23 | + <span className="text-success-emphasis">+{additions}</span> | |
| 24 | + <span className="text-danger">−{deletions}</span> | |
| 25 | + <span aria-hidden className="flex gap-[2px]"> | |
| 26 | + {Array.from({ length: 5 }, (_, i) => ( | |
| 27 | + <span | |
| 28 | + key={i} | |
| 29 | + className={`size-2 rounded-[2.5px] ${i < greens ? "bg-success-emphasis" : i < (total ? 5 : 0) ? "bg-danger" : "material-well"}`} | |
| 30 | + /> | |
| 31 | + ))} | |
| 32 | + </span> | |
| 33 | + </span> | |
| 34 | + ); | |
| 35 | + } | |
| 36 | + | |
| 37 | + function FileDiffView({ file, base, sha, budget }: { file: FileDiff; base: string; sha: string; budget: number }) { | |
| 38 | + const name = file.status === "renamed" ? `${file.oldPath} → ${file.newPath}` : file.newPath; | |
| 39 | + const truncated = file.lines.length > budget; | |
| 40 | + return ( | |
| 41 | + <section id={encodeURIComponent(file.newPath)} aria-label={name} className="mb-4 scroll-mt-20"> | |
| 42 | + <Box className="overflow-clip"> | |
| 43 | + <div className="rule-b sticky top-14 z-10 flex min-h-11 items-center gap-3 bg-[var(--mu-frost-strong)] py-1.5 pr-2 pl-4 backdrop-blur-md"> | |
| 44 | + <DiffStat additions={file.additions} deletions={file.deletions} /> | |
| 45 | + <span className="min-w-0 truncate font-mono text-[11.5px] font-medium text-ink">{name}</span> | |
| 46 | + {file.status !== "modified" && ( | |
| 47 | + <Label tone={file.status === "added" ? "success" : file.status === "deleted" ? "danger" : "default"}>{file.status}</Label> | |
| 48 | + )} | |
| 49 | + {file.status !== "deleted" && ( | |
| 50 | + <ButtonLink | |
| 51 | + href={`${base}/blob/${sha}/${encodePath(file.newPath)}`} | |
| 52 | + variant="invisible" | |
| 53 | + icon={<FileIcon width={14} height={14} />} | |
| 54 | + className="ml-auto shrink-0" | |
| 55 | + > | |
| 56 | + View File | |
| 57 | + </ButtonLink> | |
| 58 | + )} | |
| 59 | + </div> | |
| 60 | + {file.binary ? ( | |
| 61 | + <p className="p-6 text-center type-ui text-ink3">Binary file not shown.</p> | |
| 62 | + ) : file.lines.length === 0 ? ( | |
| 63 | + <p className="p-6 text-center type-ui text-ink3">File mode or name changed only.</p> | |
| 64 | + ) : truncated ? ( | |
| 65 | + <p className="p-6 text-center type-ui text-ink3">Large diffs are not rendered by default.</p> | |
| 66 | + ) : ( | |
| 67 | + <div className="overflow-x-auto"> | |
| 68 | + <table className="w-full border-collapse font-mono text-[11.5px] leading-[22px] text-ink"> | |
| 69 | + <tbody> | |
| 70 | + {file.lines.map((line, i) => | |
| 71 | + line.kind === "hunk" ? ( | |
| 72 | + <tr key={i} className="bg-[var(--diff-hunk)] text-ink3"> | |
| 73 | + <td colSpan={3} className="px-4 py-1.5"> | |
| 74 | + {line.text} | |
| 75 | + </td> | |
| 76 | + </tr> | |
| 77 | + ) : ( | |
| 78 | + <tr key={i} className={lineStyles[line.kind].row}> | |
| 79 | + <td className={`w-12 min-w-12 select-none px-2 text-right text-ink3 tabular-nums ${lineStyles[line.kind].num}`}> | |
| 80 | + {line.oldNo} | |
| 81 | + </td> | |
| 82 | + <td className={`w-12 min-w-12 select-none px-2 text-right text-ink3 tabular-nums ${lineStyles[line.kind].num}`}> | |
| 83 | + {line.newNo} | |
| 84 | + </td> | |
| 85 | + <td className="px-2 whitespace-pre"> | |
| 86 | + <span className="select-none text-ink3">{lineStyles[line.kind].sign} </span> | |
| 87 | + {line.text} | |
| 88 | + </td> | |
| 89 | + </tr> | |
| 90 | + ), | |
| 91 | + )} | |
| 92 | + </tbody> | |
| 93 | + </table> | |
| 94 | + </div> | |
| 95 | + )} | |
| 96 | + </Box> | |
| 97 | + </section> | |
| 98 | + ); | |
| 99 | + } | |
| 100 | + | |
| 101 | + export default async function CommitPage({ params }: PageProps<"/[owner]/[repo]/commit/[sha]">) { | |
| 102 | + const { owner, repo: repoParam, sha } = await params; | |
| 103 | + const repo = await getRepo(owner, repoParam); | |
| 104 | + const commit = await getCommit(repo.dir, sha); | |
| 105 | + if (!commit) notFound(); | |
| 106 | + const files = await diffCommit(repo.dir, commit); | |
| 107 | + const additions = files.reduce((n, f) => n + f.additions, 0); | |
| 108 | + const deletions = files.reduce((n, f) => n + f.deletions, 0); | |
| 109 | + | |
| 110 | + // Each file may render only the diff lines left over from the files above it. | |
| 111 | + const budgets = files.map((_, i) => MAX_RENDERED_LINES - files.slice(0, i).reduce((n, f) => n + f.lines.length, 0)); | |
| 112 | + | |
| 113 | + return ( | |
| 114 | + <div> | |
| 115 | + <Box className="mb-6 overflow-hidden"> | |
| 116 | + <div className="flex flex-wrap items-start gap-4 p-6"> | |
| 117 | + <div className="min-w-0 flex-1"> | |
| 118 | + <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">{commit.subject}</h1> | |
| 119 | + {commit.body && <pre className="mt-3 font-mono text-[11.5px] leading-relaxed whitespace-pre-wrap text-ink2">{commit.body}</pre>} | |
| 120 | + </div> | |
| 121 | + <ButtonLink href={`${repo.base}/tree/${commit.sha}`} icon={<CodeIcon />}> | |
| 122 | + Browse Files | |
| 123 | + </ButtonLink> | |
| 124 | + </div> | |
| 125 | + <div className="rule-t flex flex-wrap items-center gap-2 px-6 py-3 type-ui"> | |
| 126 | + <Avatar name={commit.authorName} size={22} /> | |
| 127 | + <span className="font-medium text-ink" title={commit.authorEmail}> | |
| 128 | + {commit.authorName} | |
| 129 | + </span> | |
| 130 | + <span className="text-ink2"> | |
| 131 | + committed <time dateTime={new Date(commit.date).toISOString()}>{timeAgo(commit.date)}</time> | |
| 132 | + </span> | |
| 133 | + <span className="ml-auto flex flex-wrap items-center gap-1.5 type-meta text-ink3"> | |
| 134 | + {commit.parents.length > 0 && ( | |
| 135 | + <> | |
| 136 | + <span className="type-label engraved"> | |
| 137 | + {commit.parents.length} parent{commit.parents.length > 1 && "s"} | |
| 138 | + </span> | |
| 139 | + {commit.parents.map((p) => ( | |
| 140 | + <ShaChip key={p} sha={p} href={`${repo.base}/commit/${p}`} className="mr-1.5" /> | |
| 141 | + ))} | |
| 142 | + </> | |
| 143 | + )} | |
| 144 | + <span className="type-label engraved">commit</span> | |
| 145 | + <RefChip>{shortSha(commit.sha)}</RefChip> | |
| 146 | + </span> | |
| 147 | + </div> | |
| 148 | + </Box> | |
| 149 | + | |
| 150 | + <p className="mb-4 px-1 type-ui text-ink2"> | |
| 151 | + Showing <strong className="font-semibold text-ink">{files.length} changed file{files.length === 1 ? "" : "s"}</strong> with{" "} | |
| 152 | + <strong className="font-semibold text-success-emphasis">{additions} addition{additions === 1 ? "" : "s"}</strong> and{" "} | |
| 153 | + <strong className="font-semibold text-danger">{deletions} deletion{deletions === 1 ? "" : "s"}</strong>. | |
| 154 | + </p> | |
| 155 | + | |
| 156 | + {commit.parents.length > 1 && ( | |
| 157 | + <p className="mb-4 px-1 type-meta text-ink3">Merge commit — showing changes against the first parent.</p> | |
| 158 | + )} | |
| 159 | + | |
| 160 | + {files.map((file, i) => ( | |
| 161 | + <FileDiffView key={file.newPath} file={file} base={repo.base} sha={commit.sha} budget={budgets[i]} /> | |
| 162 | + ))} | |
| 163 | + </div> | |
| 164 | + ); | |
| 165 | + } | |
| @@ -0,0 +1,117 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import { Tooltip } from "@unlocalhosted/metalui"; | |
| 3 | + import { BranchSelect } from "@/components/branch-select"; | |
| 4 | + import { CopyButton } from "@/components/clone-button"; | |
| 5 | + import { ShaChip } from "@/components/file-table"; | |
| 6 | + import { ChevronLeftIcon, ChevronRightIcon, CodeIcon, CommitIcon, HistoryIcon } from "@/components/icons"; | |
| 7 | + import { ghostIconLinkClass, liftRowClass } from "@/components/repo-rows"; | |
| 8 | + import { Avatar, Blankslate, Box, ButtonLink, RefChip } from "@/components/ui"; | |
| 9 | + import { listBranches, log, type Commit } from "@/lib/git"; | |
| 10 | + import { encodePath, formatDate, timeAgo } from "@/lib/format"; | |
| 11 | + import { getRefAndPath, getRepo } from "@/lib/repo"; | |
| 12 | + | |
| 13 | + const PAGE_SIZE = 35; | |
| 14 | + | |
| 15 | + function groupByDay(commits: Commit[]) { | |
| 16 | + const groups = new Map<string, Commit[]>(); | |
| 17 | + for (const c of commits) { | |
| 18 | + const day = formatDate(c.date); | |
| 19 | + groups.set(day, [...(groups.get(day) ?? []), c]); | |
| 20 | + } | |
| 21 | + return [...groups]; | |
| 22 | + } | |
| 23 | + | |
| 24 | + export default async function CommitsPage({ params, searchParams }: PageProps<"/[owner]/[repo]/commits/[...segments]">) { | |
| 25 | + const { owner, repo: repoParam, segments } = await params; | |
| 26 | + const page = Math.max(1, Number((await searchParams).page) || 1); | |
| 27 | + const repo = await getRepo(owner, repoParam); | |
| 28 | + const { ref, path } = await getRefAndPath(repo, segments); | |
| 29 | + | |
| 30 | + const [commits, branches] = await Promise.all([ | |
| 31 | + log(repo.dir, ref, { path, limit: PAGE_SIZE + 1, skip: (page - 1) * PAGE_SIZE }), | |
| 32 | + listBranches(repo.dir), | |
| 33 | + ]); | |
| 34 | + const hasNext = commits.length > PAGE_SIZE; | |
| 35 | + const selfHref = `${repo.base}/commits/${encodePath(ref)}${path ? `/${encodePath(path)}` : ""}`; | |
| 36 | + | |
| 37 | + return ( | |
| 38 | + <div> | |
| 39 | + <div className="mb-6 flex flex-wrap items-center gap-3"> | |
| 40 | + <h1 className="mr-2 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Commits</h1> | |
| 41 | + <BranchSelect base={repo.base} kind="commits" current={ref} branches={branches.map((b) => b.name)} path={path} /> | |
| 42 | + {path && ( | |
| 43 | + <span className="flex items-center gap-1.5 type-ui text-ink2"> | |
| 44 | + History for <RefChip>{path}</RefChip> | |
| 45 | + </span> | |
| 46 | + )} | |
| 47 | + </div> | |
| 48 | + | |
| 49 | + {commits.length === 0 && ( | |
| 50 | + <Box> | |
| 51 | + <Blankslate icon={<HistoryIcon />} title="No commits found" /> | |
| 52 | + </Box> | |
| 53 | + )} | |
| 54 | + | |
| 55 | + <div className="space-y-6"> | |
| 56 | + {groupByDay(commits.slice(0, PAGE_SIZE)).map(([day, dayCommits]) => ( | |
| 57 | + <section key={day}> | |
| 58 | + <h2 className="mb-2 flex items-center gap-2 px-3 type-label engraved"> | |
| 59 | + <CommitIcon width={13} height={13} className="text-ink3" /> | |
| 60 | + {day} | |
| 61 | + </h2> | |
| 62 | + <Box className="p-1.5"> | |
| 63 | + <ul> | |
| 64 | + {dayCommits.map((c) => ( | |
| 65 | + <li key={c.sha} className={`${liftRowClass} flex items-center gap-3.5 px-3 py-2.5`}> | |
| 66 | + <Avatar name={c.authorName} size={32} className="hidden sm:inline-flex" /> | |
| 67 | + <div className="min-w-0 flex-1"> | |
| 68 | + <Link | |
| 69 | + href={`${repo.base}/commit/${c.sha}`} | |
| 70 | + className="block truncate type-title text-ink decoration-ink3 underline-offset-4 hover:underline" | |
| 71 | + > | |
| 72 | + {c.subject} | |
| 73 | + </Link> | |
| 74 | + <div className="mt-0.5 flex items-center gap-1 type-meta text-ink3"> | |
| 75 | + <span className="font-medium text-ink2">{c.authorName}</span> committed{" "} | |
| 76 | + <time dateTime={new Date(c.date).toISOString()}>{timeAgo(c.date)}</time> | |
| 77 | + </div> | |
| 78 | + </div> | |
| 79 | + <div className="flex shrink-0 items-center gap-1"> | |
| 80 | + <ShaChip sha={c.sha} href={`${repo.base}/commit/${c.sha}`} className="mr-1" /> | |
| 81 | + <CopyButton text={c.sha} label="Copy full SHA" /> | |
| 82 | + <Tooltip label="Browse files"> | |
| 83 | + <Link | |
| 84 | + href={`${repo.base}/tree/${c.sha}`} | |
| 85 | + aria-label="Browse the repository at this point in the history" | |
| 86 | + className={ghostIconLinkClass} | |
| 87 | + > | |
| 88 | + <CodeIcon /> | |
| 89 | + </Link> | |
| 90 | + </Tooltip> | |
| 91 | + </div> | |
| 92 | + </li> | |
| 93 | + ))} | |
| 94 | + </ul> | |
| 95 | + </Box> | |
| 96 | + </section> | |
| 97 | + ))} | |
| 98 | + </div> | |
| 99 | + | |
| 100 | + {(page > 1 || hasNext) && ( | |
| 101 | + <div className="mt-8 flex justify-center gap-2"> | |
| 102 | + {page > 1 ? ( | |
| 103 | + <ButtonLink href={`${selfHref}?page=${page - 1}`} icon={<ChevronLeftIcon />}> | |
| 104 | + Newer | |
| 105 | + </ButtonLink> | |
| 106 | + ) : null} | |
| 107 | + {hasNext ? ( | |
| 108 | + <ButtonLink href={`${selfHref}?page=${page + 1}`}> | |
| 109 | + Older | |
| 110 | + <ChevronRightIcon /> | |
| 111 | + </ButtonLink> | |
| 112 | + ) : null} | |
| 113 | + </div> | |
| 114 | + )} | |
| 115 | + </div> | |
| 116 | + ); | |
| 117 | + } | |
| @@ -0,0 +1,16 @@ | ||
| 1 | + import { redirect } from "next/navigation"; | |
| 2 | + import { HistoryIcon } from "@/components/icons"; | |
| 3 | + import { Blankslate, Box } from "@/components/ui"; | |
| 4 | + import { encodePath } from "@/lib/format"; | |
| 5 | + import { getRepo } from "@/lib/repo"; | |
| 6 | + | |
| 7 | + export default async function CommitsIndex({ params }: PageProps<"/[owner]/[repo]/commits">) { | |
| 8 | + const { owner, repo: repoParam } = await params; | |
| 9 | + const repo = await getRepo(owner, repoParam); | |
| 10 | + if (repo.defaultBranch) redirect(`${repo.base}/commits/${encodePath(repo.defaultBranch)}`); | |
| 11 | + return ( | |
| 12 | + <Box> | |
| 13 | + <Blankslate icon={<HistoryIcon />} title="No commits yet">This repository is empty.</Blankslate> | |
| 14 | + </Box> | |
| 15 | + ); | |
| 16 | + } | |
| @@ -0,0 +1,26 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { notFound } from "next/navigation"; | |
| 3 | + import { deleteFileAction } from "@/app/actions/files"; | |
| 4 | + import { DeleteFileForm } from "@/components/delete-file-form"; | |
| 5 | + import { objectType, revParse } from "@/lib/git"; | |
| 6 | + import { encodePath } from "@/lib/format"; | |
| 7 | + import { getBranchAndPath, getWritableRepo } from "@/lib/repo"; | |
| 8 | + | |
| 9 | + export const metadata: Metadata = { title: "Delete file" }; | |
| 10 | + | |
| 11 | + export default async function DeleteFilePage({ params }: PageProps<"/[owner]/[repo]/delete/[...segments]">) { | |
| 12 | + const { owner, repo: repoParam, segments } = await params; | |
| 13 | + const repo = await getWritableRepo(owner, repoParam); | |
| 14 | + const { branch, path } = await getBranchAndPath(repo, segments); | |
| 15 | + if (!path || (await objectType(repo.dir, branch, path)) !== "blob") notFound(); | |
| 16 | + | |
| 17 | + return ( | |
| 18 | + <DeleteFileForm | |
| 19 | + action={deleteFileAction.bind(null, repo.owner, repo.name)} | |
| 20 | + branch={branch} | |
| 21 | + baseSha={(await revParse(repo.dir, `refs/heads/${branch}`))!} | |
| 22 | + path={path} | |
| 23 | + cancelHref={`${repo.base}/blob/${encodePath(branch)}/${encodePath(path)}`} | |
| 24 | + /> | |
| 25 | + ); | |
| 26 | + } | |
| @@ -0,0 +1,37 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { notFound } from "next/navigation"; | |
| 3 | + import { saveFileAction } from "@/app/actions/files"; | |
| 4 | + import { FileEditor } from "@/components/file-editor"; | |
| 5 | + import { FileIcon } from "@/components/icons"; | |
| 6 | + import { Blankslate } from "@/components/ui"; | |
| 7 | + import { objectType, readBlob, revParse } from "@/lib/git"; | |
| 8 | + import { encodePath } from "@/lib/format"; | |
| 9 | + import { getBranchAndPath, getWritableRepo } from "@/lib/repo"; | |
| 10 | + | |
| 11 | + export const metadata: Metadata = { title: "Editing" }; | |
| 12 | + | |
| 13 | + export default async function EditPage({ params }: PageProps<"/[owner]/[repo]/edit/[...segments]">) { | |
| 14 | + const { owner, repo: repoParam, segments } = await params; | |
| 15 | + const repo = await getWritableRepo(owner, repoParam); | |
| 16 | + const { branch, path } = await getBranchAndPath(repo, segments); | |
| 17 | + if (!path || (await objectType(repo.dir, branch, path)) !== "blob") notFound(); | |
| 18 | + | |
| 19 | + const blob = await readBlob(repo.dir, branch, path); | |
| 20 | + if (blob.binary || blob.tooLarge) { | |
| 21 | + return <Blankslate icon={<FileIcon />} title="This file can’t be edited in the browser">Binary and very large files can only be changed with git.</Blankslate>; | |
| 22 | + } | |
| 23 | + const baseSha = (await revParse(repo.dir, `refs/heads/${branch}`))!; | |
| 24 | + | |
| 25 | + return ( | |
| 26 | + <FileEditor | |
| 27 | + action={saveFileAction.bind(null, repo.owner, repo.name)} | |
| 28 | + repoName={repo.name} | |
| 29 | + branch={branch} | |
| 30 | + baseSha={baseSha} | |
| 31 | + originalPath={path} | |
| 32 | + initialPath={path} | |
| 33 | + initialContent={blob.content.toString("utf8")} | |
| 34 | + cancelHref={`${repo.base}/blob/${encodePath(branch)}/${encodePath(path)}`} | |
| 35 | + /> | |
| 36 | + ); | |
| 37 | + } | |
| @@ -0,0 +1,6 @@ | ||
| 1 | + import { handleServiceRpc } from "@/lib/git-http"; | |
| 2 | + | |
| 3 | + export async function POST(request: Request, ctx: RouteContext<"/[owner]/[repo]/git-receive-pack">) { | |
| 4 | + const { owner, repo } = await ctx.params; | |
| 5 | + return handleServiceRpc(request, owner, repo, "git-receive-pack"); | |
| 6 | + } | |
| @@ -0,0 +1,6 @@ | ||
| 1 | + import { handleServiceRpc } from "@/lib/git-http"; | |
| 2 | + | |
| 3 | + export async function POST(request: Request, ctx: RouteContext<"/[owner]/[repo]/git-upload-pack">) { | |
| 4 | + const { owner, repo } = await ctx.params; | |
| 5 | + return handleServiceRpc(request, owner, repo, "git-upload-pack"); | |
| 6 | + } | |
| @@ -0,0 +1,22 @@ | ||
| 1 | + import { getViewer } from "@/lib/auth"; | |
| 2 | + import { normalizeRepoName } from "@/lib/git"; | |
| 3 | + import { findRepo, getRepoImage, repoAccess } from "@/lib/repos"; | |
| 4 | + | |
| 5 | + /** The repository's uploaded image. URLs carry `?v=<upload time>`, so responses can be cached for good. */ | |
| 6 | + export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/image">) { | |
| 7 | + const { owner, repo: repoParam } = await ctx.params; | |
| 8 | + const repo = findRepo(owner, normalizeRepoName(repoParam)); | |
| 9 | + const viewer = await getViewer(); | |
| 10 | + if (!repo || !repoAccess(repo, viewer?.id)) return new Response("Not found", { status: 404 }); | |
| 11 | + | |
| 12 | + const image = getRepoImage(repo.id); | |
| 13 | + if (!image) return new Response("Not found", { status: 404 }); | |
| 14 | + return new Response(new Uint8Array(image.data), { | |
| 15 | + headers: { | |
| 16 | + "Content-Type": image.contentType, | |
| 17 | + "X-Content-Type-Options": "nosniff", | |
| 18 | + "Cache-Control": `${repo.visibility === "private" ? "private" : "public"}, max-age=31536000, immutable`, | |
| 19 | + "Content-Security-Policy": "default-src 'none'; sandbox", | |
| 20 | + }, | |
| 21 | + }); | |
| 22 | + } | |
| @@ -0,0 +1,6 @@ | ||
| 1 | + import { handleInfoRefs } from "@/lib/git-http"; | |
| 2 | + | |
| 3 | + export async function GET(request: Request, ctx: RouteContext<"/[owner]/[repo]/info/refs">) { | |
| 4 | + const { owner, repo } = await ctx.params; | |
| 5 | + return handleInfoRefs(request, owner, repo); | |
| 6 | + } | |
| @@ -0,0 +1,227 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import { notFound } from "next/navigation"; | |
| 3 | + import type { ReactNode } from "react"; | |
| 4 | + import { commentAction, deleteIssueAction } from "@/app/actions/issues"; | |
| 5 | + import { Surface, Textarea } from "@unlocalhosted/metalui"; | |
| 6 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 7 | + import { ArrowRightIcon, CommentIcon, TrashIcon } from "@/components/icons"; | |
| 8 | + import { IssueDetail } from "@/components/issues/issue-detail"; | |
| 9 | + import { PriorityIcon, StatusIcon } from "@/components/issues/issue-icons"; | |
| 10 | + import { TrackerProvider } from "@/components/issues/tracker-context"; | |
| 11 | + import { Markdown } from "@/components/markdown"; | |
| 12 | + import { Avatar, Button, ButtonLink, Label } from "@/components/ui"; | |
| 13 | + import { formatDate, timeAgo } from "@/lib/format"; | |
| 14 | + import { STATUS_IDS, formatDay, priorityLabel, statusLabel, type Status } from "@/lib/issue-meta"; | |
| 15 | + import { getIssue, listComments, listEvents, type IssueEvent } from "@/lib/issues"; | |
| 16 | + import { getRepo } from "@/lib/repo"; | |
| 17 | + import { trackerProps } from "@/lib/tracker"; | |
| 18 | + | |
| 19 | + export async function generateMetadata({ params }: PageProps<"/[owner]/[repo]/issues/[number]">) { | |
| 20 | + const { owner, repo: repoParam, number } = await params; | |
| 21 | + const repo = await getRepo(owner, repoParam); | |
| 22 | + const issue = getIssue(repo.id, Number(number)); | |
| 23 | + return { title: issue ? `${issue.title} · Issue #${issue.number}` : "Issue" }; | |
| 24 | + } | |
| 25 | + | |
| 26 | + function CommentBox({ author, createdAt, body, isAuthor }: { author: string; createdAt: number; body: string; isAuthor?: boolean }) { | |
| 27 | + return ( | |
| 28 | + <div className="flex gap-3"> | |
| 29 | + <Link href={`/${author}`} className="shrink-0"> | |
| 30 | + <Avatar name={author} size={32} /> | |
| 31 | + </Link> | |
| 32 | + <Surface material="raise-lite" radius="plate" className="min-w-0 flex-1"> | |
| 33 | + <div className="rule-b flex h-10 items-center gap-2 px-4"> | |
| 34 | + <Link href={`/${author}`} className="type-ui text-ink hover:underline"> | |
| 35 | + {author} | |
| 36 | + </Link> | |
| 37 | + <span className="type-label engraved" title={formatDate(createdAt)}> | |
| 38 | + commented {timeAgo(createdAt)} | |
| 39 | + </span> | |
| 40 | + {isAuthor && ( | |
| 41 | + <span className="ml-auto"> | |
| 42 | + <Label tone="accent">Author</Label> | |
| 43 | + </span> | |
| 44 | + )} | |
| 45 | + </div> | |
| 46 | + <div className="px-5 py-4"> | |
| 47 | + <Markdown>{body}</Markdown> | |
| 48 | + </div> | |
| 49 | + </Surface> | |
| 50 | + </div> | |
| 51 | + ); | |
| 52 | + } | |
| 53 | + | |
| 54 | + /** A point on the activity rail: a small sunk well on the page, holding the event's glyph or an unlit dot. */ | |
| 55 | + function TimelineDot({ children }: { children?: ReactNode }) { | |
| 56 | + return ( | |
| 57 | + <span className="well flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full -ml-[3px]"> | |
| 58 | + {children ?? <span className="h-1.5 w-1.5 rounded-full bg-ink3" />} | |
| 59 | + </span> | |
| 60 | + ); | |
| 61 | + } | |
| 62 | + | |
| 63 | + const isStatus = (value: string | null): value is Status => !!value && (STATUS_IDS as string[]).includes(value); | |
| 64 | + | |
| 65 | + function StatusName({ value }: { value: string | null }) { | |
| 66 | + if (!isStatus(value)) return <strong className="font-medium text-ink">{value ?? "none"}</strong>; | |
| 67 | + return ( | |
| 68 | + <strong className="inline-flex items-center gap-1 align-middle font-medium text-ink"> | |
| 69 | + <StatusIcon status={value} size={12} /> {statusLabel(value)} | |
| 70 | + </strong> | |
| 71 | + ); | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** One line in the activity feed describing a property change. */ | |
| 75 | + function describe(e: IssueEvent): { icon: ReactNode; text: ReactNode } { | |
| 76 | + const strong = (s: ReactNode) => <strong className="font-medium text-ink">{s}</strong>; | |
| 77 | + switch (e.field) { | |
| 78 | + case "status": | |
| 79 | + return { | |
| 80 | + icon: isStatus(e.newValue) ? <StatusIcon status={e.newValue} size={12} /> : null, | |
| 81 | + text: ( | |
| 82 | + <> | |
| 83 | + changed status from <StatusName value={e.oldValue} /> to <StatusName value={e.newValue} /> | |
| 84 | + </> | |
| 85 | + ), | |
| 86 | + }; | |
| 87 | + case "priority": | |
| 88 | + return { | |
| 89 | + icon: <PriorityIcon priority={Number(e.newValue)} size={12} />, | |
| 90 | + text: e.newValue === "0" ? <>removed the priority</> : <>set priority to {strong(priorityLabel(Number(e.newValue)))}</>, | |
| 91 | + }; | |
| 92 | + case "assignee": | |
| 93 | + return { | |
| 94 | + icon: <Avatar name={e.newValue ?? e.oldValue ?? "?"} size={14} />, | |
| 95 | + text: !e.newValue ? ( | |
| 96 | + <>unassigned {strong(e.oldValue)}</> | |
| 97 | + ) : e.newValue === e.actor && !e.oldValue ? ( | |
| 98 | + <>self-assigned the issue</> | |
| 99 | + ) : e.oldValue ? ( | |
| 100 | + <> | |
| 101 | + reassigned from {strong(e.oldValue)} to {strong(e.newValue)} | |
| 102 | + </> | |
| 103 | + ) : ( | |
| 104 | + <>assigned {strong(e.newValue)}</> | |
| 105 | + ), | |
| 106 | + }; | |
| 107 | + case "label": | |
| 108 | + return { icon: null, text: e.newValue ? <>added label {strong(e.newValue)}</> : <>removed label {strong(e.oldValue)}</> }; | |
| 109 | + case "estimate": | |
| 110 | + return { icon: null, text: e.newValue ? <>set the estimate to {strong(`${e.newValue} points`)}</> : <>removed the estimate</> }; | |
| 111 | + case "due_date": | |
| 112 | + return { icon: null, text: e.newValue ? <>set the due date to {strong(formatDay(e.newValue, true))}</> : <>removed the due date</> }; | |
| 113 | + case "title": | |
| 114 | + return { icon: null, text: <>renamed this from {strong(`“${e.oldValue}”`)}</> }; | |
| 115 | + default: | |
| 116 | + return { icon: null, text: <>changed {e.field}</> }; | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + export default async function IssuePage({ params }: PageProps<"/[owner]/[repo]/issues/[number]">) { | |
| 121 | + const { owner, repo: repoParam, number } = await params; | |
| 122 | + const repo = await getRepo(owner, repoParam); | |
| 123 | + const issue = getIssue(repo.id, Number(number)); | |
| 124 | + if (!issue) notFound(); | |
| 125 | + const viewer = repo.viewer; | |
| 126 | + const action = commentAction.bind(null, repo.owner, repo.name, issue.number); | |
| 127 | + | |
| 128 | + // Comments and property changes, oldest first. | |
| 129 | + const activity = [ | |
| 130 | + ...listComments(issue.id).map((c) => ({ kind: "comment" as const, at: c.createdAt, comment: c })), | |
| 131 | + ...listEvents(issue.id).map((e) => ({ kind: "event" as const, at: e.createdAt, event: e })), | |
| 132 | + ].sort((a, b) => a.at - b.at); | |
| 133 | + | |
| 134 | + return ( | |
| 135 | + <div className="mx-auto max-w-6xl"> | |
| 136 | + <TrackerProvider {...trackerProps(repo)}> | |
| 137 | + <IssueDetail | |
| 138 | + issue={issue} | |
| 139 | + description={<Markdown>{issue.body}</Markdown>} | |
| 140 | + sidebarFooter={ | |
| 141 | + <div className="space-y-2"> | |
| 142 | + <p className="flex items-center gap-1.5"> | |
| 143 | + <Avatar name={issue.author} size={16} /> | |
| 144 | + <Link href={`/${issue.author}`} className="font-medium text-ink hover:underline"> | |
| 145 | + {issue.author} | |
| 146 | + </Link> | |
| 147 | + opened {timeAgo(issue.createdAt)} | |
| 148 | + </p> | |
| 149 | + <p title={formatDate(issue.updatedAt)}>Updated {timeAgo(issue.updatedAt)}</p> | |
| 150 | + {(repo.isAdmin || viewer?.id === issue.authorId) && ( | |
| 151 | + <div className="pt-1.5"> | |
| 152 | + <ConfirmButton | |
| 153 | + action={deleteIssueAction.bind(null, repo.owner, repo.name, issue.number)} | |
| 154 | + message={`Delete issue #${issue.number}? This can't be undone.`} | |
| 155 | + > | |
| 156 | + <TrashIcon /> Delete issue | |
| 157 | + </ConfirmButton> | |
| 158 | + </div> | |
| 159 | + )} | |
| 160 | + </div> | |
| 161 | + } | |
| 162 | + > | |
| 163 | + <h2 className="rule-t mb-4 pt-6 type-label engraved">Activity</h2> | |
| 164 | + <div className="relative space-y-4 before:absolute before:top-2 before:bottom-2 before:left-4 before:w-px before:bg-line"> | |
| 165 | + <p className="relative flex items-center gap-2 pl-2.5 type-body text-ink2"> | |
| 166 | + <TimelineDot /> | |
| 167 | + <strong className="font-medium text-ink">{issue.author}</strong> created the issue · {timeAgo(issue.createdAt)} | |
| 168 | + </p> | |
| 169 | + {activity.map((item) => { | |
| 170 | + if (item.kind === "comment") { | |
| 171 | + const c = item.comment; | |
| 172 | + return ( | |
| 173 | + <div key={`c${c.id}`} className="relative"> | |
| 174 | + <CommentBox author={c.author} createdAt={c.createdAt} body={c.body} isAuthor={c.author === issue.author} /> | |
| 175 | + </div> | |
| 176 | + ); | |
| 177 | + } | |
| 178 | + const { icon, text } = describe(item.event); | |
| 179 | + return ( | |
| 180 | + <p key={`e${item.event.id}`} className="relative flex flex-wrap items-center gap-x-1.5 gap-y-1 pl-2.5 type-body text-ink2"> | |
| 181 | + <TimelineDot>{icon}</TimelineDot> | |
| 182 | + <strong className="font-medium text-ink">{item.event.actor}</strong> {text} · {timeAgo(item.event.createdAt)} | |
| 183 | + </p> | |
| 184 | + ); | |
| 185 | + })} | |
| 186 | + | |
| 187 | + <div className="relative flex gap-3 pt-2"> | |
| 188 | + {viewer ? ( | |
| 189 | + <> | |
| 190 | + <Avatar name={viewer.username} size={32} /> | |
| 191 | + <Surface material="raise-lite" radius="plate" className="min-w-0 flex-1 p-3"> | |
| 192 | + <form action={action} className="space-y-3"> | |
| 193 | + <Textarea | |
| 194 | + name="body" | |
| 195 | + minRows={4} | |
| 196 | + maxRows={16} | |
| 197 | + required | |
| 198 | + aria-label="Comment" | |
| 199 | + placeholder="Leave a comment… Markdown is supported." | |
| 200 | + /> | |
| 201 | + <div className="flex justify-end"> | |
| 202 | + <Button type="submit" variant="primary" icon={<CommentIcon />}> | |
| 203 | + Comment | |
| 204 | + </Button> | |
| 205 | + </div> | |
| 206 | + </form> | |
| 207 | + </Surface> | |
| 208 | + </> | |
| 209 | + ) : ( | |
| 210 | + <Surface material="raise-lite" radius="plate" className="flex w-full flex-wrap items-center justify-between gap-3 p-5"> | |
| 211 | + <span className="type-body text-ink2">Sign in to join the conversation.</span> | |
| 212 | + <ButtonLink | |
| 213 | + href={`/login?next=${encodeURIComponent(`${repo.base}/issues/${issue.number}`)}`} | |
| 214 | + variant="primary" | |
| 215 | + icon={<ArrowRightIcon />} | |
| 216 | + > | |
| 217 | + Sign in to comment | |
| 218 | + </ButtonLink> | |
| 219 | + </Surface> | |
| 220 | + )} | |
| 221 | + </div> | |
| 222 | + </div> | |
| 223 | + </IssueDetail> | |
| 224 | + </TrackerProvider> | |
| 225 | + </div> | |
| 226 | + ); | |
| 227 | + } | |
| @@ -0,0 +1,16 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useRouter } from "next/navigation"; | |
| 4 | + import { IssueComposer } from "@/components/issues/issue-composer"; | |
| 5 | + import { useTracker } from "@/components/issues/tracker-context"; | |
| 6 | + import { Box } from "@/components/ui"; | |
| 7 | + | |
| 8 | + export function NewIssueForm() { | |
| 9 | + const router = useRouter(); | |
| 10 | + const { base } = useTracker(); | |
| 11 | + return ( | |
| 12 | + <Box> | |
| 13 | + <IssueComposer onCreated={(number) => router.push(`${base}/issues/${number}`)} onCancel={() => router.push(`${base}/issues`)} /> | |
| 14 | + </Box> | |
| 15 | + ); | |
| 16 | + } | |
| @@ -0,0 +1,23 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { TrackerProvider } from "@/components/issues/tracker-context"; | |
| 3 | + import { PageTitle } from "@/components/ui"; | |
| 4 | + import { requireViewer } from "@/lib/auth"; | |
| 5 | + import { getRepo } from "@/lib/repo"; | |
| 6 | + import { trackerProps } from "@/lib/tracker"; | |
| 7 | + import { NewIssueForm } from "./form"; | |
| 8 | + | |
| 9 | + export const metadata: Metadata = { title: "New issue" }; | |
| 10 | + | |
| 11 | + export default async function NewIssuePage({ params }: PageProps<"/[owner]/[repo]/issues/new">) { | |
| 12 | + const { owner, repo: repoParam } = await params; | |
| 13 | + const repo = await getRepo(owner, repoParam); | |
| 14 | + await requireViewer(`${repo.base}/issues/new`); | |
| 15 | + return ( | |
| 16 | + <div className="max-w-3xl"> | |
| 17 | + <PageTitle title="Create new issue" /> | |
| 18 | + <TrackerProvider {...trackerProps(repo)}> | |
| 19 | + <NewIssueForm /> | |
| 20 | + </TrackerProvider> | |
| 21 | + </div> | |
| 22 | + ); | |
| 23 | + } | |
| @@ -0,0 +1,20 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { IssueTracker } from "@/components/issues/issue-tracker"; | |
| 3 | + import { TrackerProvider } from "@/components/issues/tracker-context"; | |
| 4 | + import { parseView } from "@/components/issues/view"; | |
| 5 | + import { listIssues } from "@/lib/issues"; | |
| 6 | + import { getRepo } from "@/lib/repo"; | |
| 7 | + import { trackerProps } from "@/lib/tracker"; | |
| 8 | + | |
| 9 | + export const metadata: Metadata = { title: "Issues" }; | |
| 10 | + | |
| 11 | + export default async function IssuesPage({ params, searchParams }: PageProps<"/[owner]/[repo]/issues">) { | |
| 12 | + const { owner, repo: repoParam } = await params; | |
| 13 | + const repo = await getRepo(owner, repoParam); | |
| 14 | + | |
| 15 | + return ( | |
| 16 | + <TrackerProvider {...trackerProps(repo)}> | |
| 17 | + <IssueTracker issues={listIssues(repo.id)} initialView={parseView(await searchParams)} /> | |
| 18 | + </TrackerProvider> | |
| 19 | + ); | |
| 20 | + } | |
| @@ -0,0 +1,46 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import { RepoTabs } from "@/components/repo-tabs"; | |
| 3 | + import { Label, RepoAvatar, VisibilityLabel } from "@/components/ui"; | |
| 4 | + import { openIssueCount } from "@/lib/issues"; | |
| 5 | + import { getRepo } from "@/lib/repo"; | |
| 6 | + | |
| 7 | + export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]">) { | |
| 8 | + const { owner, repo } = await params; | |
| 9 | + const r = await getRepo(owner, repo); | |
| 10 | + return { | |
| 11 | + title: { default: `${r.owner}/${r.name}`, template: `%s · ${r.owner}/${r.name}` }, | |
| 12 | + description: r.description || undefined, | |
| 13 | + }; | |
| 14 | + } | |
| 15 | + | |
| 16 | + export default async function RepoLayout({ params, children }: LayoutProps<"/[owner]/[repo]">) { | |
| 17 | + const { owner, repo: repoParam } = await params; | |
| 18 | + const repo = await getRepo(owner, repoParam); | |
| 19 | + | |
| 20 | + return ( | |
| 21 | + <> | |
| 22 | + <div className="rule-b"> | |
| 23 | + <div className="flex min-h-12 flex-wrap items-center gap-x-6 gap-y-1.5 px-4 py-1.5 md:px-5 xl:grid xl:grid-cols-[1fr_auto_1fr]"> | |
| 24 | + <div className="flex min-w-0 items-center gap-2"> | |
| 25 | + <Link href={repo.base} aria-label={`${repo.owner}/${repo.name}`} className="shrink-0 rounded-key transition-opacity hover:opacity-80 focus-visible:focus-ring"> | |
| 26 | + <RepoAvatar repo={repo} size={22} /> | |
| 27 | + </Link> | |
| 28 | + <Link href={`/${repo.owner}`} className="hidden shrink-0 text-[15px] text-ink2 transition-colors hover:text-ink sm:inline"> | |
| 29 | + {repo.owner} | |
| 30 | + </Link> | |
| 31 | + <span aria-hidden className="hidden text-[15px] text-ink3 sm:inline"> | |
| 32 | + / | |
| 33 | + </span> | |
| 34 | + <Link href={repo.base} className="truncate text-[15px] font-semibold tracking-[-0.01em] text-ink transition-opacity hover:opacity-80"> | |
| 35 | + {repo.name} | |
| 36 | + </Link> | |
| 37 | + <VisibilityLabel visibility={repo.visibility} /> | |
| 38 | + {repo.access === "write" && <Label tone="accent">Collaborator</Label>} | |
| 39 | + </div> | |
| 40 | + <RepoTabs base={repo.base} openIssues={openIssueCount(repo.id)} showSettings={repo.isAdmin} /> | |
| 41 | + </div> | |
| 42 | + </div> | |
| 43 | + <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div> | |
| 44 | + </> | |
| 45 | + ); | |
| 46 | + } | |
| @@ -0,0 +1,30 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { notFound } from "next/navigation"; | |
| 3 | + import { saveFileAction } from "@/app/actions/files"; | |
| 4 | + import { FileEditor } from "@/components/file-editor"; | |
| 5 | + import { objectType, revParse } from "@/lib/git"; | |
| 6 | + import { encodePath } from "@/lib/format"; | |
| 7 | + import { getBranchAndPath, getWritableRepo } from "@/lib/repo"; | |
| 8 | + | |
| 9 | + export const metadata: Metadata = { title: "New file" }; | |
| 10 | + | |
| 11 | + export default async function NewFilePage({ params }: PageProps<"/[owner]/[repo]/new/[...segments]">) { | |
| 12 | + const { owner, repo: repoParam, segments } = await params; | |
| 13 | + const repo = await getWritableRepo(owner, repoParam); | |
| 14 | + const { branch, path: dir, exists } = await getBranchAndPath(repo, segments); | |
| 15 | + if (exists && dir && (await objectType(repo.dir, branch, dir)) !== "tree") notFound(); | |
| 16 | + const baseSha = exists ? ((await revParse(repo.dir, `refs/heads/${branch}`)) ?? "") : ""; | |
| 17 | + | |
| 18 | + return ( | |
| 19 | + <FileEditor | |
| 20 | + action={saveFileAction.bind(null, repo.owner, repo.name)} | |
| 21 | + repoName={repo.name} | |
| 22 | + branch={branch} | |
| 23 | + baseSha={baseSha} | |
| 24 | + originalPath="" | |
| 25 | + initialPath={dir ? `${dir}/` : ""} | |
| 26 | + initialContent="" | |
| 27 | + cancelHref={exists ? `${repo.base}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}` : repo.base} | |
| 28 | + /> | |
| 29 | + ); | |
| 30 | + } | |
| @@ -0,0 +1,204 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import { AvatarGroup } from "@unlocalhosted/metalui"; | |
| 3 | + import { CloneUrlTabs, CopyButton } from "@/components/clone-button"; | |
| 4 | + import { | |
| 5 | + BranchIcon, | |
| 6 | + CalendarIcon, | |
| 7 | + HistoryIcon, | |
| 8 | + IssueOpenedIcon, | |
| 9 | + PeopleIcon, | |
| 10 | + PlusIcon, | |
| 11 | + SparkleIcon, | |
| 12 | + TagIcon, | |
| 13 | + UploadIcon, | |
| 14 | + } from "@/components/icons"; | |
| 15 | + import { TreeView } from "@/components/tree-view"; | |
| 16 | + import { Avatar, Blankslate, Box, ButtonLink } from "@/components/ui"; | |
| 17 | + import { encodePath, formatDate } from "@/lib/format"; | |
| 18 | + import { commitCount, listBranches, listContributors, listFilesWithSizes, listTags } from "@/lib/git"; | |
| 19 | + import { openIssueCount } from "@/lib/issues"; | |
| 20 | + import { languageBreakdown, type LanguageShare } from "@/lib/languages"; | |
| 21 | + import { cloneUrls, getRepo, type Repo } from "@/lib/repo"; | |
| 22 | + import { listCollaborators } from "@/lib/repos"; | |
| 23 | + | |
| 24 | + function CommandBlock({ title, commands }: { title: string; commands: string }) { | |
| 25 | + return ( | |
| 26 | + <div className="rule-t px-6 py-5"> | |
| 27 | + <h3 className="mb-3 type-title text-ink">{title}</h3> | |
| 28 | + <div className="flex items-start gap-2"> | |
| 29 | + <pre className="well flex-1 overflow-x-auto rounded-plate px-4 py-3 font-mono text-[11.5px] leading-6 text-ink">{commands}</pre> | |
| 30 | + <CopyButton text={commands} label="Copy commands" /> | |
| 31 | + </div> | |
| 32 | + </div> | |
| 33 | + ); | |
| 34 | + } | |
| 35 | + | |
| 36 | + async function QuickSetup({ repo }: { repo: Repo }) { | |
| 37 | + const urls = await cloneUrls(repo); | |
| 38 | + if (!repo.canWrite) { | |
| 39 | + return ( | |
| 40 | + <Box> | |
| 41 | + <Blankslate icon={<SparkleIcon />} title="This repository is empty"> | |
| 42 | + The owner hasn’t pushed anything yet. | |
| 43 | + </Blankslate> | |
| 44 | + </Box> | |
| 45 | + ); | |
| 46 | + } | |
| 47 | + return ( | |
| 48 | + <Box className="overflow-hidden"> | |
| 49 | + <div className="p-6"> | |
| 50 | + <h3 className="type-content font-semibold text-ink">Let’s get this repository started</h3> | |
| 51 | + <p className="mt-1 mb-5 type-lead text-ink2">Add a file in the browser, or push from your machine.</p> | |
| 52 | + <div className="mb-6 flex flex-wrap gap-2"> | |
| 53 | + <ButtonLink href={`${repo.base}/new/main`} variant="primary" icon={<PlusIcon />}> | |
| 54 | + Create New File | |
| 55 | + </ButtonLink> | |
| 56 | + <ButtonLink href={`${repo.base}/upload/main`} icon={<UploadIcon />}> | |
| 57 | + Upload Files | |
| 58 | + </ButtonLink> | |
| 59 | + </div> | |
| 60 | + <div className="max-w-xl"> | |
| 61 | + <CloneUrlTabs urls={urls} /> | |
| 62 | + </div> | |
| 63 | + </div> | |
| 64 | + <CommandBlock | |
| 65 | + title="…or create a new repository on the command line" | |
| 66 | + commands={`echo "# ${repo.name}" >> README.md\ngit init\ngit add README.md\ngit commit -m "first commit"\ngit branch -M main\ngit remote add origin ${urls.ssh}\ngit push -u origin main`} | |
| 67 | + /> | |
| 68 | + <CommandBlock | |
| 69 | + title="…or push an existing repository from the command line" | |
| 70 | + commands={`git remote add origin ${urls.ssh}\ngit branch -M main\ngit push -u origin main`} | |
| 71 | + /> | |
| 72 | + </Box> | |
| 73 | + ); | |
| 74 | + } | |
| 75 | + | |
| 76 | + /** An engraved heading over a sidebar group, with an optional readout at its right. */ | |
| 77 | + function SectionLabel({ children, count }: { children: string; count?: number }) { | |
| 78 | + return ( | |
| 79 | + <h2 className="mb-2 flex items-center justify-between type-label engraved"> | |
| 80 | + {children} | |
| 81 | + {count !== undefined && <span className="type-readout text-ink3 [text-shadow:none]">{count}</span>} | |
| 82 | + </h2> | |
| 83 | + ); | |
| 84 | + } | |
| 85 | + | |
| 86 | + function Stat({ href, icon: Icon, label, value }: { href: string; icon: typeof BranchIcon; label: string; value: number }) { | |
| 87 | + return ( | |
| 88 | + <li> | |
| 89 | + <Link | |
| 90 | + href={href} | |
| 91 | + className="group -mx-2 flex h-8 items-center gap-2.5 rounded-row px-2 type-ui text-ink outline-none hover:recipe-row-list-hover focus-visible:focus-ring" | |
| 92 | + > | |
| 93 | + <Icon width={14} height={14} className="text-ink3 transition-colors group-hover:text-ink2" /> | |
| 94 | + {label} | |
| 95 | + <span className="ml-auto type-readout text-ink2">{value}</span> | |
| 96 | + </Link> | |
| 97 | + </li> | |
| 98 | + ); | |
| 99 | + } | |
| 100 | + | |
| 101 | + function Languages({ languages }: { languages: LanguageShare[] }) { | |
| 102 | + // Tiny shares are unreadable in the bar and the legend; fold them into "Other". | |
| 103 | + const shown = languages.filter((l) => l.percent >= 0.1).slice(0, 8); | |
| 104 | + const other = 100 - shown.reduce((sum, l) => sum + l.percent, 0); | |
| 105 | + const items = other >= 0.1 ? [...shown, { name: "Other", color: "var(--mu-ink3)", percent: other }] : shown; | |
| 106 | + return ( | |
| 107 | + <section> | |
| 108 | + <SectionLabel>Languages</SectionLabel> | |
| 109 | + {/* A sunk track, each language a lit segment in it. */} | |
| 110 | + <div | |
| 111 | + role="img" | |
| 112 | + aria-label={items.map((l) => `${l.name} ${l.percent.toFixed(1)}%`).join(", ")} | |
| 113 | + className="material-well flex h-3 gap-[2px] overflow-hidden rounded-pill p-[3px]" | |
| 114 | + > | |
| 115 | + {items.map((l) => ( | |
| 116 | + <span | |
| 117 | + key={l.name} | |
| 118 | + className="min-w-[3px] rounded-pill shadow-[inset_0_1px_0_rgb(255_255_255/0.25)]" | |
| 119 | + style={{ width: `${l.percent}%`, background: l.color }} | |
| 120 | + title={`${l.name} ${l.percent.toFixed(1)}%`} | |
| 121 | + /> | |
| 122 | + ))} | |
| 123 | + </div> | |
| 124 | + <ul className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5 type-ui"> | |
| 125 | + {items.map((l) => ( | |
| 126 | + <li key={l.name} className="flex min-w-0 items-center gap-2"> | |
| 127 | + <span aria-hidden className="size-1.5 shrink-0 rounded-full" style={{ background: l.color }} /> | |
| 128 | + <span className="truncate text-ink">{l.name}</span> | |
| 129 | + <span className="type-readout text-ink3">{l.percent.toFixed(1)}%</span> | |
| 130 | + </li> | |
| 131 | + ))} | |
| 132 | + </ul> | |
| 133 | + </section> | |
| 134 | + ); | |
| 135 | + } | |
| 136 | + | |
| 137 | + export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">) { | |
| 138 | + const { owner, repo: repoParam } = await params; | |
| 139 | + const repo = await getRepo(owner, repoParam); | |
| 140 | + const ref = repo.defaultBranch; | |
| 141 | + const [branches, tags, commits, files, contributors] = await Promise.all([ | |
| 142 | + listBranches(repo.dir), | |
| 143 | + listTags(repo.dir), | |
| 144 | + ref ? commitCount(repo.dir, ref) : 0, | |
| 145 | + ref ? listFilesWithSizes(repo.dir, ref) : [], | |
| 146 | + ref ? listContributors(repo.dir, ref) : [], | |
| 147 | + ]); | |
| 148 | + const collaborators = listCollaborators(repo.id); | |
| 149 | + const languages = languageBreakdown(files); | |
| 150 | + const historyHref = ref ? `${repo.base}/commits/${encodePath(ref)}` : `${repo.base}/commits`; | |
| 151 | + | |
| 152 | + return ( | |
| 153 | + <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_260px] xl:gap-10"> | |
| 154 | + <div className="min-w-0"> | |
| 155 | + {ref ? <TreeView repo={repo} gitRef={ref} path="" /> : <QuickSetup repo={repo} />} | |
| 156 | + </div> | |
| 157 | + <aside className="flex flex-col gap-7"> | |
| 158 | + <section> | |
| 159 | + <SectionLabel>About</SectionLabel> | |
| 160 | + <p className={`type-body ${repo.description ? "text-ink" : "text-ink3 italic"}`}> | |
| 161 | + {repo.description || "No description provided."} | |
| 162 | + </p> | |
| 163 | + <p className="mt-2 flex items-center gap-1.5 type-meta text-ink3"> | |
| 164 | + <CalendarIcon width={12} height={12} /> Created {formatDate(repo.createdAt)} | |
| 165 | + </p> | |
| 166 | + </section> | |
| 167 | + | |
| 168 | + <section> | |
| 169 | + <SectionLabel>Stats</SectionLabel> | |
| 170 | + <ul> | |
| 171 | + <Stat href={historyHref} icon={HistoryIcon} label="Commits" value={commits} /> | |
| 172 | + <Stat href={`${repo.base}/branches`} icon={BranchIcon} label="Branches" value={branches.length} /> | |
| 173 | + <Stat href={`${repo.base}/branches`} icon={TagIcon} label="Tags" value={tags.length} /> | |
| 174 | + <Stat href={`${repo.base}/issues`} icon={IssueOpenedIcon} label="Open issues" value={openIssueCount(repo.id)} /> | |
| 175 | + <Stat | |
| 176 | + href={repo.isAdmin ? `${repo.base}/settings/access` : `/${repo.owner}`} | |
| 177 | + icon={PeopleIcon} | |
| 178 | + label="Collaborators" | |
| 179 | + value={collaborators.length} | |
| 180 | + /> | |
| 181 | + </ul> | |
| 182 | + </section> | |
| 183 | + | |
| 184 | + {languages.length > 0 && <Languages languages={languages} />} | |
| 185 | + | |
| 186 | + <section> | |
| 187 | + <SectionLabel count={contributors.length || 1}>Contributors</SectionLabel> | |
| 188 | + {contributors.length ? ( | |
| 189 | + <AvatarGroup | |
| 190 | + aria-label="Contributors" | |
| 191 | + people={contributors.slice(0, 24).map((c) => ({ name: c.name }))} | |
| 192 | + max={8} | |
| 193 | + size="regular" | |
| 194 | + /> | |
| 195 | + ) : ( | |
| 196 | + <Link href={`/${repo.owner}`} title={repo.owner} className="inline-flex rounded-full focus-visible:focus-ring"> | |
| 197 | + <Avatar name={repo.owner} size={32} /> | |
| 198 | + </Link> | |
| 199 | + )} | |
| 200 | + </section> | |
| 201 | + </aside> | |
| 202 | + </div> | |
| 203 | + ); | |
| 204 | + } | |
| @@ -0,0 +1,29 @@ | ||
| 1 | + import { getViewer } from "@/lib/auth"; | |
| 2 | + import { normalizeRepoName, objectType, readBlob, resolveRefAndPath } from "@/lib/git"; | |
| 3 | + import { contentTypeFor } from "@/lib/mime"; | |
| 4 | + import { findRepo, repoAccess } from "@/lib/repos"; | |
| 5 | + | |
| 6 | + export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/raw/[...segments]">) { | |
| 7 | + const { owner, repo: repoParam, segments } = await ctx.params; | |
| 8 | + const repo = findRepo(owner, normalizeRepoName(repoParam)); | |
| 9 | + const viewer = await getViewer(); | |
| 10 | + if (!repo || !repoAccess(repo, viewer?.id)) return new Response("Not found", { status: 404 }); | |
| 11 | + | |
| 12 | + const resolved = await resolveRefAndPath(repo.dir, segments); | |
| 13 | + if (!resolved || (await objectType(repo.dir, resolved.ref, resolved.path)) !== "blob") { | |
| 14 | + return new Response("Not found", { status: 404 }); | |
| 15 | + } | |
| 16 | + const blob = await readBlob(repo.dir, resolved.ref, resolved.path); | |
| 17 | + if (blob.tooLarge) return new Response("File too large", { status: 413 }); | |
| 18 | + | |
| 19 | + const filename = resolved.path.split("/").pop()!; | |
| 20 | + return new Response(new Uint8Array(blob.content), { | |
| 21 | + headers: { | |
| 22 | + "Content-Type": contentTypeFor(filename, blob.binary), | |
| 23 | + "X-Content-Type-Options": "nosniff", | |
| 24 | + "Cache-Control": repo.visibility === "private" ? "private, no-store" : "no-cache", | |
| 25 | + // Sandbox the response so things like scripted SVGs can't run in our origin. | |
| 26 | + "Content-Security-Policy": "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox", | |
| 27 | + }, | |
| 28 | + }); | |
| 29 | + } | |
| @@ -0,0 +1,32 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useActionState } from "react"; | |
| 4 | + import type { FormState } from "@/app/actions/types"; | |
| 5 | + import { PersonIcon, PlusIcon } from "@/components/icons"; | |
| 6 | + import { TextField } from "@/components/settings-fields"; | |
| 7 | + import { PlateBody } from "@/components/settings-plate"; | |
| 8 | + import { Alert, Button } from "@/components/ui"; | |
| 9 | + | |
| 10 | + export function AddCollaboratorForm({ action }: { action: (prev: FormState, formData: FormData) => Promise<FormState> }) { | |
| 11 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 12 | + return ( | |
| 13 | + <form action={formAction}> | |
| 14 | + <PlateBody> | |
| 15 | + <div className="flex max-w-xl flex-wrap items-end gap-2"> | |
| 16 | + <TextField | |
| 17 | + className="min-w-60 flex-1" | |
| 18 | + label="Username" | |
| 19 | + icon={<PersonIcon />} | |
| 20 | + name="username" | |
| 21 | + required | |
| 22 | + /> | |
| 23 | + <Button variant="primary" icon={<PlusIcon />} disabled={pending}> | |
| 24 | + {pending ? "Adding…" : "Add Collaborator"} | |
| 25 | + </Button> | |
| 26 | + </div> | |
| 27 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 28 | + {state?.success && <Alert kind="success">{state.success}</Alert>} | |
| 29 | + </PlateBody> | |
| 30 | + </form> | |
| 31 | + ); | |
| 32 | + } | |
| @@ -0,0 +1,79 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import Link from "next/link"; | |
| 3 | + import { Label as MuLabel } from "@unlocalhosted/metalui"; | |
| 4 | + import { addCollaboratorAction, removeCollaboratorAction } from "@/app/actions/repo"; | |
| 5 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 6 | + import { PeopleIcon } from "@/components/icons"; | |
| 7 | + import { SettingsSection } from "@/components/metal"; | |
| 8 | + import { ListRow, PlateSection } from "@/components/settings-plate"; | |
| 9 | + import { Avatar, Blankslate, Label, PageTitle } from "@/components/ui"; | |
| 10 | + import { timeAgo } from "@/lib/format"; | |
| 11 | + import { getRepo } from "@/lib/repo"; | |
| 12 | + import { listCollaborators } from "@/lib/repos"; | |
| 13 | + import { AddCollaboratorForm } from "./form"; | |
| 14 | + | |
| 15 | + export const metadata: Metadata = { title: "Collaborators" }; | |
| 16 | + | |
| 17 | + export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/settings/access">) { | |
| 18 | + const { owner, repo: repoParam } = await params; | |
| 19 | + const repo = await getRepo(owner, repoParam); | |
| 20 | + const collaborators = listCollaborators(repo.id); | |
| 21 | + | |
| 22 | + return ( | |
| 23 | + <div> | |
| 24 | + <PageTitle title="Collaborators" description="Collaborators can see this repository, push to it and edit files in the browser." /> | |
| 25 | + <div className="flex flex-col gap-7"> | |
| 26 | + <SettingsSection title={`People with access · ${collaborators.length + 1}`}> | |
| 27 | + <ListRow | |
| 28 | + lead={<Avatar name={repo.owner} size={32} />} | |
| 29 | + title={ | |
| 30 | + <> | |
| 31 | + <Link href={`/${repo.owner}`} className="truncate hover:underline"> | |
| 32 | + {repo.owner} | |
| 33 | + </Link> | |
| 34 | + <Label tone="accent">Owner</Label> | |
| 35 | + </> | |
| 36 | + } | |
| 37 | + /> | |
| 38 | + {collaborators.map((c) => ( | |
| 39 | + <ListRow | |
| 40 | + key={c.id} | |
| 41 | + lead={<Avatar name={c.username} size={32} />} | |
| 42 | + title={ | |
| 43 | + <> | |
| 44 | + <Link href={`/${c.username}`} className="truncate hover:underline"> | |
| 45 | + {c.username} | |
| 46 | + </Link> | |
| 47 | + <Label>Write</Label> | |
| 48 | + </> | |
| 49 | + } | |
| 50 | + meta={ | |
| 51 | + <MuLabel variant="engraved"> | |
| 52 | + {c.name && ( | |
| 53 | + <> | |
| 54 | + <b>{c.name}</b> ·{" "} | |
| 55 | + </> | |
| 56 | + )} | |
| 57 | + Added <b>{timeAgo(c.addedAt)}</b> | |
| 58 | + </MuLabel> | |
| 59 | + } | |
| 60 | + > | |
| 61 | + <ConfirmButton | |
| 62 | + action={removeCollaboratorAction.bind(null, repo.owner, repo.name, c.id)} | |
| 63 | + message={`Remove ${c.username} from ${repo.owner}/${repo.name}?`} | |
| 64 | + variant="default" | |
| 65 | + > | |
| 66 | + Remove | |
| 67 | + </ConfirmButton> | |
| 68 | + </ListRow> | |
| 69 | + ))} | |
| 70 | + {collaborators.length === 0 && <Blankslate icon={<PeopleIcon />} title="You haven’t invited any collaborators yet" />} | |
| 71 | + </SettingsSection> | |
| 72 | + | |
| 73 | + <PlateSection title="Add a collaborator"> | |
| 74 | + <AddCollaboratorForm action={addCollaboratorAction.bind(null, repo.owner, repo.name)} /> | |
| 75 | + </PlateSection> | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + ); | |
| 79 | + } | |
| @@ -0,0 +1,185 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useActionState, useEffect, useState } from "react"; | |
| 4 | + import type { FormState } from "@/app/actions/types"; | |
| 5 | + import { AlertIcon, GlobeIcon, LockIcon, PencilIcon, TrashIcon, UndoIcon, UploadIcon } from "@/components/icons"; | |
| 6 | + import { ChoiceCards, TextField } from "@/components/settings-fields"; | |
| 7 | + import { PlateBody, PlateFooter } from "@/components/settings-plate"; | |
| 8 | + import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui"; | |
| 9 | + | |
| 10 | + type Action = (prev: FormState, formData: FormData) => Promise<FormState>; | |
| 11 | + | |
| 12 | + export function ImageForm({ | |
| 13 | + action, | |
| 14 | + resetAction, | |
| 15 | + repo, | |
| 16 | + }: { | |
| 17 | + action: Action; | |
| 18 | + resetAction: () => Promise<void>; | |
| 19 | + repo: RepoImageRef; | |
| 20 | + }) { | |
| 21 | + const [preview, setPreview] = useState<string | null>(null); | |
| 22 | + const [state, formAction, pending] = useActionState(async (prev: FormState, formData: FormData) => { | |
| 23 | + const result = await action(prev, formData); | |
| 24 | + if (result?.success) setPreview(null); | |
| 25 | + return result; | |
| 26 | + }, undefined); | |
| 27 | + useEffect(() => () => { | |
| 28 | + if (preview) URL.revokeObjectURL(preview); | |
| 29 | + }, [preview]); | |
| 30 | + | |
| 31 | + return ( | |
| 32 | + <PlateBody> | |
| 33 | + <div className="flex flex-wrap items-start gap-5"> | |
| 34 | + <RepoAvatar repo={repo} src={preview ?? undefined} size={72} /> | |
| 35 | + <div className="flex min-w-60 flex-1 flex-col gap-3"> | |
| 36 | + <p className="type-body text-ink2"> | |
| 37 | + Shown next to the repository everywhere. Until you upload one, it’s the owner’s avatar. PNG, JPEG, GIF or WebP, up to 1 MB. | |
| 38 | + </p> | |
| 39 | + <div className="flex flex-wrap items-center gap-2"> | |
| 40 | + <form action={formAction} className="flex items-center gap-2"> | |
| 41 | + {/* The file input stays a real input; its label is drawn as MetalUI's standard cap. */} | |
| 42 | + <label className={buttonClass("default", "has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-green-deep has-focus-visible:outline-solid")}> | |
| 43 | + <UploadIcon /> {repo.imageVersion ? "Replace Image" : "Upload Image"} | |
| 44 | + <input | |
| 45 | + type="file" | |
| 46 | + name="image" | |
| 47 | + accept="image/png,image/jpeg,image/gif,image/webp" | |
| 48 | + required | |
| 49 | + className="sr-only" | |
| 50 | + onChange={(e) => { | |
| 51 | + const file = e.target.files?.[0]; | |
| 52 | + setPreview(file ? URL.createObjectURL(file) : null); | |
| 53 | + }} | |
| 54 | + /> | |
| 55 | + </label> | |
| 56 | + {preview && ( | |
| 57 | + <Button variant="primary" disabled={pending}> | |
| 58 | + {pending ? "Saving…" : "Save Image"} | |
| 59 | + </Button> | |
| 60 | + )} | |
| 61 | + </form> | |
| 62 | + {repo.imageVersion && !preview && ( | |
| 63 | + <form action={resetAction}> | |
| 64 | + <Button variant="invisible" icon={<UndoIcon />}> | |
| 65 | + Reset to Owner’s Avatar | |
| 66 | + </Button> | |
| 67 | + </form> | |
| 68 | + )} | |
| 69 | + </div> | |
| 70 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 71 | + {state?.success && !preview && <Alert kind="success">{state.success}</Alert>} | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + </PlateBody> | |
| 75 | + ); | |
| 76 | + } | |
| 77 | + | |
| 78 | + export function RenameForm({ action, name }: { action: Action; name: string }) { | |
| 79 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 80 | + return ( | |
| 81 | + <form action={formAction}> | |
| 82 | + <PlateBody> | |
| 83 | + <div className="flex max-w-xl flex-wrap items-end gap-2"> | |
| 84 | + <TextField className="min-w-60 flex-1" label="Repository name" name="name" defaultValue={name} required /> | |
| 85 | + <Button icon={<PencilIcon />} disabled={pending}> | |
| 86 | + Rename | |
| 87 | + </Button> | |
| 88 | + </div> | |
| 89 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 90 | + </PlateBody> | |
| 91 | + </form> | |
| 92 | + ); | |
| 93 | + } | |
| 94 | + | |
| 95 | + export function GeneralForm({ | |
| 96 | + action, | |
| 97 | + description, | |
| 98 | + visibility, | |
| 99 | + }: { | |
| 100 | + action: Action; | |
| 101 | + description: string; | |
| 102 | + visibility: "public" | "private"; | |
| 103 | + }) { | |
| 104 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 105 | + return ( | |
| 106 | + <form action={formAction}> | |
| 107 | + <PlateBody className="max-w-xl"> | |
| 108 | + <TextField label="Description" name="description" defaultValue={description} maxLength={350} /> | |
| 109 | + <div className="flex flex-col gap-1.5"> | |
| 110 | + <span id="repo-visibility" className="type-ui text-ink"> | |
| 111 | + Visibility | |
| 112 | + </span> | |
| 113 | + <ChoiceCards | |
| 114 | + name="visibility" | |
| 115 | + aria-labelledby="repo-visibility" | |
| 116 | + defaultValue={visibility} | |
| 117 | + columns={1} | |
| 118 | + choices={[ | |
| 119 | + { value: "public", icon: <GlobeIcon />, title: "Public", description: "Anyone can see and clone this repository." }, | |
| 120 | + { value: "private", icon: <LockIcon />, title: "Private", description: "Only you and collaborators can see it." }, | |
| 121 | + ]} | |
| 122 | + /> | |
| 123 | + </div> | |
| 124 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 125 | + {state?.success && <Alert kind="success">{state.success}</Alert>} | |
| 126 | + </PlateBody> | |
| 127 | + <PlateFooter> | |
| 128 | + <Button variant="primary" disabled={pending}> | |
| 129 | + {pending ? "Saving…" : "Save Changes"} | |
| 130 | + </Button> | |
| 131 | + </PlateFooter> | |
| 132 | + </form> | |
| 133 | + ); | |
| 134 | + } | |
| 135 | + | |
| 136 | + /** Deleting the repository: one red cap opens the type-to-confirm form, whose red cap does it. */ | |
| 137 | + export function DangerZone({ action, fullName }: { action: Action; fullName: string }) { | |
| 138 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 139 | + const [confirm, setConfirm] = useState(""); | |
| 140 | + const [open, setOpen] = useState(false); | |
| 141 | + return ( | |
| 142 | + <> | |
| 143 | + <div className="flex min-h-[52px] flex-wrap items-center gap-4 px-6 py-4"> | |
| 144 | + <div className="flex min-w-0 flex-1 flex-col gap-0.5"> | |
| 145 | + <p className="type-title text-ink">Delete this repository</p> | |
| 146 | + <p className="type-body text-ink2">Once you delete a repository, there is no going back. Please be certain.</p> | |
| 147 | + </div> | |
| 148 | + {!open && ( | |
| 149 | + <Button type="button" variant="danger" icon={<TrashIcon />} onClick={() => setOpen(true)}> | |
| 150 | + Delete Repository | |
| 151 | + </Button> | |
| 152 | + )} | |
| 153 | + </div> | |
| 154 | + {open && ( | |
| 155 | + <form action={formAction}> | |
| 156 | + <PlateBody className="pt-0"> | |
| 157 | + <p className="flex items-start gap-2 type-body text-red"> | |
| 158 | + <AlertIcon className="mt-px shrink-0" /> | |
| 159 | + <span> | |
| 160 | + This will permanently delete <strong>{fullName}</strong>, its history, issues and access settings. | |
| 161 | + </span> | |
| 162 | + </p> | |
| 163 | + <TextField | |
| 164 | + className="max-w-xl" | |
| 165 | + label={`To confirm, type “${fullName}” below:`} | |
| 166 | + name="confirm" | |
| 167 | + value={confirm} | |
| 168 | + onChange={(e) => setConfirm(e.target.value)} | |
| 169 | + autoFocus | |
| 170 | + /> | |
| 171 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 172 | + </PlateBody> | |
| 173 | + <PlateFooter> | |
| 174 | + <Button type="button" onClick={() => setOpen(false)}> | |
| 175 | + Cancel | |
| 176 | + </Button> | |
| 177 | + <Button variant="danger" icon={<TrashIcon />} disabled={confirm !== fullName || pending}> | |
| 178 | + {pending ? "Deleting…" : "I Understand, Delete This Repository"} | |
| 179 | + </Button> | |
| 180 | + </PlateFooter> | |
| 181 | + </form> | |
| 182 | + )} | |
| 183 | + </> | |
| 184 | + ); | |
| 185 | + } | |
| @@ -0,0 +1,68 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useActionState, useState } from "react"; | |
| 4 | + import type { FormState } from "@/app/actions/types"; | |
| 5 | + import { PlusIcon } from "@/components/icons"; | |
| 6 | + import { LabelChip } from "@/components/issues/issue-icons"; | |
| 7 | + import { TextField } from "@/components/settings-fields"; | |
| 8 | + import { PlateBody } from "@/components/settings-plate"; | |
| 9 | + import { Alert, Button } from "@/components/ui"; | |
| 10 | + import { LABEL_COLORS } from "@/lib/issue-meta"; | |
| 11 | + | |
| 12 | + export function NewLabelForm({ action }: { action: (prev: FormState, formData: FormData) => Promise<FormState> }) { | |
| 13 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 14 | + const [name, setName] = useState(""); | |
| 15 | + const [color, setColor] = useState<string>(LABEL_COLORS[1]); | |
| 16 | + return ( | |
| 17 | + <form action={formAction}> | |
| 18 | + <PlateBody> | |
| 19 | + <div className="flex max-w-xl flex-wrap items-end gap-2"> | |
| 20 | + <TextField | |
| 21 | + className="min-w-60 flex-1" | |
| 22 | + label="Name" | |
| 23 | + name="name" | |
| 24 | + required | |
| 25 | + maxLength={50} | |
| 26 | + value={name} | |
| 27 | + onChange={(e) => setName(e.target.value)} | |
| 28 | + placeholder="e.g. bug" | |
| 29 | + /> | |
| 30 | + <Button variant="primary" icon={<PlusIcon />} disabled={pending}> | |
| 31 | + {pending ? "Creating…" : "Create Label"} | |
| 32 | + </Button> | |
| 33 | + </div> | |
| 34 | + <div className="flex flex-wrap items-center gap-x-6 gap-y-3"> | |
| 35 | + <fieldset className="flex flex-col gap-1.5"> | |
| 36 | + <legend className="mb-1.5 type-ui text-ink">Color</legend> | |
| 37 | + {/* Colour keys: each a small raised disc; the chosen one is ringed in the focus green (intent). */} | |
| 38 | + <div className="flex items-center gap-2 rounded-pill recipe-well-track p-1.5"> | |
| 39 | + {LABEL_COLORS.map((c) => ( | |
| 40 | + <label key={c} className="cursor-pointer" title={c}> | |
| 41 | + <input | |
| 42 | + type="radio" | |
| 43 | + name="color" | |
| 44 | + value={c} | |
| 45 | + checked={color === c} | |
| 46 | + onChange={() => setColor(c)} | |
| 47 | + aria-label={c} | |
| 48 | + className="peer sr-only" | |
| 49 | + /> | |
| 50 | + <span | |
| 51 | + className="block size-5 rounded-full shadow-[var(--mu-raise-sm)] transition-[outline-color] duration-150 outline-2 outline-offset-2 outline-transparent outline-solid peer-checked:outline-green-deep peer-focus-visible:outline-green-deep/60" | |
| 52 | + style={{ background: c }} | |
| 53 | + /> | |
| 54 | + </label> | |
| 55 | + ))} | |
| 56 | + </div> | |
| 57 | + </fieldset> | |
| 58 | + <div className="flex flex-col gap-1.5"> | |
| 59 | + <span className="type-label engraved">Preview</span> | |
| 60 | + <LabelChip name={name.trim() || "label"} color={color} /> | |
| 61 | + </div> | |
| 62 | + </div> | |
| 63 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 64 | + {state?.success && <Alert kind="success">{state.success}</Alert>} | |
| 65 | + </PlateBody> | |
| 66 | + </form> | |
| 67 | + ); | |
| 68 | + } | |
| @@ -0,0 +1,69 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import Link from "next/link"; | |
| 3 | + import { createLabelFormAction, deleteLabelAction } from "@/app/actions/issues"; | |
| 4 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 5 | + import { TagIcon } from "@/components/icons"; | |
| 6 | + import { LabelChip } from "@/components/issues/issue-icons"; | |
| 7 | + import { SettingsSection } from "@/components/metal"; | |
| 8 | + import { PlateSection } from "@/components/settings-plate"; | |
| 9 | + import { Blankslate, PageTitle } from "@/components/ui"; | |
| 10 | + import { labelUsage, listLabels } from "@/lib/issues"; | |
| 11 | + import { getRepo } from "@/lib/repo"; | |
| 12 | + import { NewLabelForm } from "./form"; | |
| 13 | + | |
| 14 | + export const metadata: Metadata = { title: "Labels" }; | |
| 15 | + | |
| 16 | + export default async function LabelsPage({ params }: PageProps<"/[owner]/[repo]/settings/labels">) { | |
| 17 | + const { owner, repo: repoParam } = await params; | |
| 18 | + const repo = await getRepo(owner, repoParam); | |
| 19 | + const labels = listLabels(repo.id); | |
| 20 | + const usage = labelUsage(repo.id); | |
| 21 | + | |
| 22 | + return ( | |
| 23 | + <div> | |
| 24 | + <PageTitle | |
| 25 | + title="Labels" | |
| 26 | + description="Labels categorize issues. Collaborators can also create them right from an issue’s label menu." | |
| 27 | + /> | |
| 28 | + <div className="flex flex-col gap-7"> | |
| 29 | + {labels.length === 0 ? ( | |
| 30 | + <PlateSection title="Labels"> | |
| 31 | + <Blankslate icon={<TagIcon />} title="No labels yet"> | |
| 32 | + Create labels like bug, feature or design to filter and group issues. | |
| 33 | + </Blankslate> | |
| 34 | + </PlateSection> | |
| 35 | + ) : ( | |
| 36 | + <SettingsSection title={`Labels · ${labels.length}`}> | |
| 37 | + {labels.map((label) => { | |
| 38 | + const count = usage.get(label.id) ?? 0; | |
| 39 | + return ( | |
| 40 | + <div key={label.id} className="flex min-h-[52px] items-center gap-3 px-[18px] py-2.5"> | |
| 41 | + <LabelChip name={label.name} color={label.color} /> | |
| 42 | + <Link | |
| 43 | + href={`${repo.base}/issues?label=${label.id}`} | |
| 44 | + className="rounded-key px-1 type-label engraved hover:text-ink focus-visible:focus-ring" | |
| 45 | + > | |
| 46 | + {count} issue{count === 1 ? "" : "s"} | |
| 47 | + </Link> | |
| 48 | + <div className="ml-auto"> | |
| 49 | + <ConfirmButton | |
| 50 | + action={deleteLabelAction.bind(null, repo.owner, repo.name, label.id)} | |
| 51 | + message={`Delete the label "${label.name}"? It will be removed from ${count} issue${count === 1 ? "" : "s"}.`} | |
| 52 | + variant="default" | |
| 53 | + > | |
| 54 | + Delete Label | |
| 55 | + </ConfirmButton> | |
| 56 | + </div> | |
| 57 | + </div> | |
| 58 | + ); | |
| 59 | + })} | |
| 60 | + </SettingsSection> | |
| 61 | + )} | |
| 62 | + | |
| 63 | + <PlateSection title="New label"> | |
| 64 | + <NewLabelForm action={createLabelFormAction.bind(null, repo.owner, repo.name)} /> | |
| 65 | + </PlateSection> | |
| 66 | + </div> | |
| 67 | + </div> | |
| 68 | + ); | |
| 69 | + } | |
| @@ -0,0 +1,23 @@ | ||
| 1 | + import { forbidden } from "next/navigation"; | |
| 2 | + import { SettingsNav } from "@/components/settings-nav"; | |
| 3 | + import { GearIcon, PeopleIcon, TagIcon } from "@/components/icons"; | |
| 4 | + import { getRepo } from "@/lib/repo"; | |
| 5 | + | |
| 6 | + export default async function RepoSettingsLayout({ params, children }: LayoutProps<"/[owner]/[repo]/settings">) { | |
| 7 | + const { owner, repo: repoParam } = await params; | |
| 8 | + const repo = await getRepo(owner, repoParam); | |
| 9 | + if (!repo.isAdmin) forbidden(); | |
| 10 | + return ( | |
| 11 | + <div className="grid gap-8 md:grid-cols-[200px_1fr]"> | |
| 12 | + <SettingsNav | |
| 13 | + title="Repository" | |
| 14 | + items={[ | |
| 15 | + { href: `${repo.base}/settings`, label: "General", icon: <GearIcon /> }, | |
| 16 | + { href: `${repo.base}/settings/access`, label: "Collaborators", icon: <PeopleIcon /> }, | |
| 17 | + { href: `${repo.base}/settings/labels`, label: "Labels", icon: <TagIcon /> }, | |
| 18 | + ]} | |
| 19 | + /> | |
| 20 | + <div className="min-w-0">{children}</div> | |
| 21 | + </div> | |
| 22 | + ); | |
| 23 | + } | |
| @@ -0,0 +1,40 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { deleteRepoAction, removeRepoImageAction, renameRepoAction, updateRepoAction, updateRepoImageAction } from "@/app/actions/repo"; | |
| 3 | + import { PlateSection } from "@/components/settings-plate"; | |
| 4 | + import { PageTitle } from "@/components/ui"; | |
| 5 | + import { getRepo } from "@/lib/repo"; | |
| 6 | + import { DangerZone, GeneralForm, ImageForm, RenameForm } from "./forms"; | |
| 7 | + | |
| 8 | + export const metadata: Metadata = { title: "Settings" }; | |
| 9 | + | |
| 10 | + export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[repo]/settings">) { | |
| 11 | + const { owner, repo: repoParam } = await params; | |
| 12 | + const repo = await getRepo(owner, repoParam); | |
| 13 | + return ( | |
| 14 | + <div> | |
| 15 | + <PageTitle title="General" /> | |
| 16 | + <div className="flex flex-col gap-7"> | |
| 17 | + <PlateSection title="Name"> | |
| 18 | + <RenameForm action={renameRepoAction.bind(null, repo.owner, repo.name)} name={repo.name} /> | |
| 19 | + </PlateSection> | |
| 20 | + <PlateSection title="Repository image"> | |
| 21 | + <ImageForm | |
| 22 | + action={updateRepoImageAction.bind(null, repo.owner, repo.name)} | |
| 23 | + resetAction={removeRepoImageAction.bind(null, repo.owner, repo.name)} | |
| 24 | + repo={{ owner: repo.owner, name: repo.name, imageVersion: repo.imageVersion }} | |
| 25 | + /> | |
| 26 | + </PlateSection> | |
| 27 | + <PlateSection title="Details"> | |
| 28 | + <GeneralForm | |
| 29 | + action={updateRepoAction.bind(null, repo.owner, repo.name)} | |
| 30 | + description={repo.description} | |
| 31 | + visibility={repo.visibility} | |
| 32 | + /> | |
| 33 | + </PlateSection> | |
| 34 | + <PlateSection title="Danger zone"> | |
| 35 | + <DangerZone action={deleteRepoAction.bind(null, repo.owner, repo.name)} fullName={`${repo.owner}/${repo.name}`} /> | |
| 36 | + </PlateSection> | |
| 37 | + </div> | |
| 38 | + </div> | |
| 39 | + ); | |
| 40 | + } | |
| @@ -0,0 +1,15 @@ | ||
| 1 | + import { notFound, redirect } from "next/navigation"; | |
| 2 | + import { TreeView } from "@/components/tree-view"; | |
| 3 | + import { objectType } from "@/lib/git"; | |
| 4 | + import { encodePath } from "@/lib/format"; | |
| 5 | + import { getRefAndPath, getRepo } from "@/lib/repo"; | |
| 6 | + | |
| 7 | + export default async function TreePage({ params }: PageProps<"/[owner]/[repo]/tree/[...segments]">) { | |
| 8 | + const { owner, repo: repoParam, segments } = await params; | |
| 9 | + const repo = await getRepo(owner, repoParam); | |
| 10 | + const { ref, path } = await getRefAndPath(repo, segments); | |
| 11 | + const type = await objectType(repo.dir, ref, path); | |
| 12 | + if (type === "blob") redirect(`${repo.base}/blob/${encodePath(ref)}/${encodePath(path)}`); | |
| 13 | + if (type !== "tree") notFound(); | |
| 14 | + return <TreeView repo={repo} gitRef={ref} path={path} />; | |
| 15 | + } | |
| @@ -0,0 +1,28 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { notFound } from "next/navigation"; | |
| 3 | + import { uploadFilesAction } from "@/app/actions/files"; | |
| 4 | + import { UploadForm } from "@/components/upload-form"; | |
| 5 | + import { objectType, revParse } from "@/lib/git"; | |
| 6 | + import { encodePath } from "@/lib/format"; | |
| 7 | + import { getBranchAndPath, getWritableRepo } from "@/lib/repo"; | |
| 8 | + | |
| 9 | + export const metadata: Metadata = { title: "Upload files" }; | |
| 10 | + | |
| 11 | + export default async function UploadPage({ params }: PageProps<"/[owner]/[repo]/upload/[...segments]">) { | |
| 12 | + const { owner, repo: repoParam, segments } = await params; | |
| 13 | + const repo = await getWritableRepo(owner, repoParam); | |
| 14 | + const { branch, path: dir, exists } = await getBranchAndPath(repo, segments); | |
| 15 | + if (exists && dir && (await objectType(repo.dir, branch, dir)) !== "tree") notFound(); | |
| 16 | + const baseSha = exists ? ((await revParse(repo.dir, `refs/heads/${branch}`)) ?? "") : ""; | |
| 17 | + | |
| 18 | + return ( | |
| 19 | + <UploadForm | |
| 20 | + action={uploadFilesAction.bind(null, repo.owner, repo.name)} | |
| 21 | + repoName={repo.name} | |
| 22 | + branch={branch} | |
| 23 | + baseSha={baseSha} | |
| 24 | + dir={dir} | |
| 25 | + cancelHref={exists ? `${repo.base}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}` : repo.base} | |
| 26 | + /> | |
| 27 | + ); | |
| 28 | + } | |
| @@ -0,0 +1,92 @@ | ||
| 1 | + import { notFound } from "next/navigation"; | |
| 2 | + import { Field } from "@unlocalhosted/metalui"; | |
| 3 | + import { CalendarIcon, PencilIcon, PlusIcon, RepoIcon, SearchIcon } from "@/components/icons"; | |
| 4 | + import { FieldIcon, FieldInput } from "@/components/metal"; | |
| 5 | + import { RepoList } from "@/components/repo-list"; | |
| 6 | + import { Avatar, Blankslate, Box, ButtonLink, Counter } from "@/components/ui"; | |
| 7 | + import { getViewer } from "@/lib/auth"; | |
| 8 | + import { formatDate } from "@/lib/format"; | |
| 9 | + import { withActivity } from "@/lib/listing"; | |
| 10 | + import { listReposByOwner } from "@/lib/repos"; | |
| 11 | + import { getUserByUsername } from "@/lib/users"; | |
| 12 | + | |
| 13 | + export async function generateMetadata({ params }: PageProps<"/[owner]">) { | |
| 14 | + const user = getUserByUsername((await params).owner); | |
| 15 | + return { title: user ? (user.name ? `${user.username} (${user.name})` : user.username) : "Not found" }; | |
| 16 | + } | |
| 17 | + | |
| 18 | + const heading = "text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink"; | |
| 19 | + | |
| 20 | + export default async function ProfilePage({ params, searchParams }: PageProps<"/[owner]">) { | |
| 21 | + const user = getUserByUsername((await params).owner); | |
| 22 | + if (!user) notFound(); | |
| 23 | + const q = String((await searchParams).q ?? "").trim().toLowerCase(); | |
| 24 | + const viewer = await getViewer(); | |
| 25 | + const all = await withActivity(listReposByOwner(user.id, viewer?.id ?? null)); | |
| 26 | + const repos = q ? all.filter((r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)) : all; | |
| 27 | + const isSelf = viewer?.id === user.id; | |
| 28 | + const publicCount = all.filter((r) => r.visibility === "public").length; | |
| 29 | + | |
| 30 | + return ( | |
| 31 | + <div className="mx-auto grid w-full max-w-[1280px] gap-8 px-4 py-10 md:grid-cols-[300px_1fr] md:px-6"> | |
| 32 | + <aside> | |
| 33 | + <Box className="p-6 md:sticky md:top-20"> | |
| 34 | + <Avatar name={user.username} size={88} /> | |
| 35 | + <h1 className="mt-5"> | |
| 36 | + {user.name && <span className={`block ${heading}`}>{user.name}</span>} | |
| 37 | + <span className={`block ${user.name ? "mt-1 type-readout text-ink3" : heading}`}>@{user.username}</span> | |
| 38 | + </h1> | |
| 39 | + {user.bio && <p className="mt-3 type-body text-ink2">{user.bio}</p>} | |
| 40 | + <dl className="mt-5 grid grid-cols-2 gap-2"> | |
| 41 | + <div className="well flex flex-col-reverse gap-1 rounded-row px-3 py-2.5"> | |
| 42 | + <dt className="type-label engraved">Repositories</dt> | |
| 43 | + <dd className="type-stat text-ink">{all.length}</dd> | |
| 44 | + </div> | |
| 45 | + <div className="well flex flex-col-reverse gap-1 rounded-row px-3 py-2.5"> | |
| 46 | + <dt className="type-label engraved">Public</dt> | |
| 47 | + <dd className="type-stat text-ink">{publicCount}</dd> | |
| 48 | + </div> | |
| 49 | + </dl> | |
| 50 | + <p className="mt-4 flex items-center gap-2 type-meta text-ink3"> | |
| 51 | + <CalendarIcon width={14} height={14} /> Joined {formatDate(user.createdAt)} | |
| 52 | + </p> | |
| 53 | + {isSelf && ( | |
| 54 | + <ButtonLink href="/settings/profile" icon={<PencilIcon />} className="mt-5 w-full"> | |
| 55 | + Edit Profile | |
| 56 | + </ButtonLink> | |
| 57 | + )} | |
| 58 | + </Box> | |
| 59 | + </aside> | |
| 60 | + | |
| 61 | + <main className="min-w-0"> | |
| 62 | + <div className="mb-5 flex flex-wrap items-center gap-3"> | |
| 63 | + <h2 className="flex items-center gap-2 type-display text-ink"> | |
| 64 | + <RepoIcon className="text-ink2" /> Repositories <Counter>{all.length}</Counter> | |
| 65 | + </h2> | |
| 66 | + <div className="ml-auto flex w-full items-center gap-2 sm:w-auto"> | |
| 67 | + <form role="search" className="flex-1 sm:w-72"> | |
| 68 | + <Field size="regular"> | |
| 69 | + <FieldIcon> | |
| 70 | + <SearchIcon /> | |
| 71 | + </FieldIcon> | |
| 72 | + <FieldInput name="q" defaultValue={q} placeholder="Find a repository…" aria-label="Find a repository" /> | |
| 73 | + </Field> | |
| 74 | + </form> | |
| 75 | + {isSelf && ( | |
| 76 | + <ButtonLink href="/new" variant="primary" icon={<PlusIcon />}> | |
| 77 | + New | |
| 78 | + </ButtonLink> | |
| 79 | + )} | |
| 80 | + </div> | |
| 81 | + </div> | |
| 82 | + {repos.length > 0 ? ( | |
| 83 | + <RepoList repos={repos} showOwner={false} /> | |
| 84 | + ) : ( | |
| 85 | + <Box> | |
| 86 | + <Blankslate icon={<RepoIcon />} title={q ? "No matching repositories" : `${user.username} doesn’t have any repositories yet.`} /> | |
| 87 | + </Box> | |
| 88 | + )} | |
| 89 | + </main> | |
| 90 | + </div> | |
| 91 | + ); | |
| 92 | + } | |
| @@ -0,0 +1,34 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { redirect } from "next/navigation"; | |
| 4 | + import { endSession, safeNext, startSession } from "@/lib/auth"; | |
| 5 | + import { authenticateUser, createUser, UserError } from "@/lib/users"; | |
| 6 | + import type { FormState } from "./types"; | |
| 7 | + | |
| 8 | + export async function signupAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 9 | + try { | |
| 10 | + const user = await createUser({ | |
| 11 | + username: String(formData.get("username") ?? ""), | |
| 12 | + email: String(formData.get("email") ?? ""), | |
| 13 | + password: String(formData.get("password") ?? ""), | |
| 14 | + name: String(formData.get("name") ?? ""), | |
| 15 | + }); | |
| 16 | + await startSession(user.id); | |
| 17 | + } catch (e) { | |
| 18 | + if (e instanceof UserError) return { error: e.message }; | |
| 19 | + throw e; | |
| 20 | + } | |
| 21 | + redirect(safeNext(formData.get("next"))); | |
| 22 | + } | |
| 23 | + | |
| 24 | + export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 25 | + const user = await authenticateUser(String(formData.get("login") ?? ""), String(formData.get("password") ?? "")); | |
| 26 | + if (!user) return { error: "Incorrect username or password." }; | |
| 27 | + await startSession(user.id); | |
| 28 | + redirect(safeNext(formData.get("next"))); | |
| 29 | + } | |
| 30 | + | |
| 31 | + export async function logoutAction() { | |
| 32 | + await endSession(); | |
| 33 | + redirect("/"); | |
| 34 | + } | |
| @@ -0,0 +1,143 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { redirect } from "next/navigation"; | |
| 4 | + import { git, GitError, objectType, revParse } from "@/lib/git"; | |
| 5 | + import { cleanRepoPath, commitFiles, isValidBranchName, type FileChange } from "@/lib/git-write"; | |
| 6 | + import { encodePath } from "@/lib/format"; | |
| 7 | + import { markPushed, type RepoRecord } from "@/lib/repos"; | |
| 8 | + import type { User } from "@/lib/users"; | |
| 9 | + import { ActionError, repoForAction } from "./guard"; | |
| 10 | + import type { FormState } from "./types"; | |
| 11 | + | |
| 12 | + const str = (formData: FormData, key: string) => String(formData.get(key) ?? "").trim(); | |
| 13 | + | |
| 14 | + /** Blob sha of a path at a commit, or null if it doesn't exist there. */ | |
| 15 | + async function blobAt(dir: string, commit: string, path: string) { | |
| 16 | + return (await git(dir, ["rev-parse", "--verify", "--quiet", `${commit}:${path}`]).catch(() => "")).trim() || null; | |
| 17 | + } | |
| 18 | + | |
| 19 | + /** | |
| 20 | + * Commits `changes` from the web UI, either directly to `branch` or to a new | |
| 21 | + * branch, and returns the branch that received the commit. `touched` lists the | |
| 22 | + * paths whose content the user saw; if any changed on the branch since the page | |
| 23 | + * was loaded (`baseSha`), the commit is refused instead of overwriting them. | |
| 24 | + */ | |
| 25 | + async function commitFromForm( | |
| 26 | + repo: RepoRecord, | |
| 27 | + viewer: User, | |
| 28 | + formData: FormData, | |
| 29 | + changes: FileChange[], | |
| 30 | + touched: string[], | |
| 31 | + defaultMessage: string, | |
| 32 | + ) { | |
| 33 | + const branch = str(formData, "branch"); | |
| 34 | + const baseSha = str(formData, "baseSha"); | |
| 35 | + const head = await revParse(repo.dir, `refs/heads/${branch}`); | |
| 36 | + | |
| 37 | + if (baseSha) { | |
| 38 | + if (!head) throw new ActionError(`Branch ${branch} no longer exists.`); | |
| 39 | + if (head !== baseSha) { | |
| 40 | + for (const path of touched) { | |
| 41 | + if ((await blobAt(repo.dir, baseSha, path)) !== (await blobAt(repo.dir, head, path))) { | |
| 42 | + throw new ActionError(`${path} was changed on ${branch} since you started editing. Reload to get the latest version.`); | |
| 43 | + } | |
| 44 | + } | |
| 45 | + } | |
| 46 | + } else if (head) { | |
| 47 | + throw new ActionError(`Branch ${branch} was created by someone else in the meantime. Reload and try again.`); | |
| 48 | + } | |
| 49 | + | |
| 50 | + const summary = str(formData, "message") || defaultMessage; | |
| 51 | + const description = str(formData, "description"); | |
| 52 | + const message = `${summary}${description ? `\n\n${description}` : ""}\n`; | |
| 53 | + const author = { name: viewer.name || viewer.username, email: viewer.email }; | |
| 54 | + | |
| 55 | + let target = branch; | |
| 56 | + if (formData.get("target") === "branch") { | |
| 57 | + target = str(formData, "newBranch"); | |
| 58 | + if (!(await isValidBranchName(repo.dir, target))) throw new ActionError(`${target || "That"} is not a valid branch name.`); | |
| 59 | + await commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author }); | |
| 60 | + } else { | |
| 61 | + await commitFiles(repo.dir, { branch, parent: head, changes, message, author }); | |
| 62 | + } | |
| 63 | + markPushed(repo.id); | |
| 64 | + return target; | |
| 65 | + } | |
| 66 | + | |
| 67 | + async function handle(fn: () => Promise<string>): Promise<FormState> { | |
| 68 | + let destination: string; | |
| 69 | + try { | |
| 70 | + destination = await fn(); | |
| 71 | + } catch (e) { | |
| 72 | + if (e instanceof ActionError || e instanceof GitError) return { error: e.message }; | |
| 73 | + throw e; | |
| 74 | + } | |
| 75 | + redirect(destination); | |
| 76 | + } | |
| 77 | + | |
| 78 | + export async function saveFileAction(owner: string, name: string, _prev: FormState, formData: FormData) { | |
| 79 | + return handle(async () => { | |
| 80 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 81 | + const path = cleanRepoPath(str(formData, "path")); | |
| 82 | + if (!path) throw new ActionError("That file name is invalid."); | |
| 83 | + const originalPath = str(formData, "originalPath"); | |
| 84 | + const content = Buffer.from(String(formData.get("content") ?? "")); | |
| 85 | + const head = await revParse(repo.dir, `refs/heads/${str(formData, "branch")}`); | |
| 86 | + | |
| 87 | + // Refuse to silently overwrite a different existing file. | |
| 88 | + if (path !== originalPath && head && (await objectType(repo.dir, head, path))) { | |
| 89 | + throw new ActionError(`A file named ${path} already exists.`); | |
| 90 | + } | |
| 91 | + const changes: FileChange[] = [{ path, content }]; | |
| 92 | + if (originalPath && originalPath !== path) changes.unshift({ path: originalPath, delete: true }); | |
| 93 | + | |
| 94 | + const filename = path.split("/").pop()!; | |
| 95 | + const verb = !originalPath ? "Create" : originalPath !== path ? "Rename" : "Update"; | |
| 96 | + const branch = await commitFromForm(repo, viewer, formData, changes, originalPath ? [originalPath] : [], `${verb} ${filename}`); | |
| 97 | + return `/${repo.owner}/${repo.name}/blob/${encodePath(branch)}/${encodePath(path)}`; | |
| 98 | + }); | |
| 99 | + } | |
| 100 | + | |
| 101 | + export async function deleteFileAction(owner: string, name: string, _prev: FormState, formData: FormData) { | |
| 102 | + return handle(async () => { | |
| 103 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 104 | + const path = cleanRepoPath(str(formData, "path")); | |
| 105 | + if (!path) throw new ActionError("That file name is invalid."); | |
| 106 | + const branch = await commitFromForm(repo, viewer, formData, [{ path, delete: true }], [path], `Delete ${path.split("/").pop()}`); | |
| 107 | + | |
| 108 | + // Go to the closest directory that still exists. | |
| 109 | + const parts = path.split("/").slice(0, -1); | |
| 110 | + while (parts.length && (await objectType(repo.dir, `refs/heads/${branch}`, parts.join("/"))) !== "tree") parts.pop(); | |
| 111 | + const suffix = parts.length ? `/${encodePath(parts.join("/"))}` : ""; | |
| 112 | + const hasFiles = await revParse(repo.dir, `refs/heads/${branch}`); | |
| 113 | + return hasFiles ? `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${suffix}` : `/${repo.owner}/${repo.name}`; | |
| 114 | + }); | |
| 115 | + } | |
| 116 | + | |
| 117 | + const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; | |
| 118 | + | |
| 119 | + export async function uploadFilesAction(owner: string, name: string, _prev: FormState, formData: FormData) { | |
| 120 | + return handle(async () => { | |
| 121 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 122 | + const dir = str(formData, "dir"); | |
| 123 | + const files = formData.getAll("files").filter((f): f is File => f instanceof File && f.size > 0); | |
| 124 | + if (files.length === 0) throw new ActionError("Choose at least one file to upload."); | |
| 125 | + | |
| 126 | + const changes: FileChange[] = []; | |
| 127 | + for (const file of files) { | |
| 128 | + if (file.size > MAX_UPLOAD_BYTES) throw new ActionError(`${file.name} is larger than 10 MB.`); | |
| 129 | + const path = cleanRepoPath(dir ? `${dir}/${file.name}` : file.name); | |
| 130 | + if (!path) throw new ActionError(`${file.name} is not a valid file name.`); | |
| 131 | + changes.push({ path, content: Buffer.from(await file.arrayBuffer()) }); | |
| 132 | + } | |
| 133 | + const branch = await commitFromForm( | |
| 134 | + repo, | |
| 135 | + viewer, | |
| 136 | + formData, | |
| 137 | + changes, | |
| 138 | + changes.map((c) => c.path), | |
| 139 | + files.length === 1 ? `Add ${files[0].name}` : `Add ${files.length} files`, | |
| 140 | + ); | |
| 141 | + return `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}`; | |
| 142 | + }); | |
| 143 | + } | |
| @@ -0,0 +1,22 @@ | ||
| 1 | + import "server-only"; | |
| 2 | + import { getViewer } from "@/lib/auth"; | |
| 3 | + import { canWrite, findRepo, repoAccess, type Access } from "@/lib/repos"; | |
| 4 | + | |
| 5 | + export class ActionError extends Error {} | |
| 6 | + | |
| 7 | + export async function viewerOrThrow() { | |
| 8 | + const viewer = await getViewer(); | |
| 9 | + if (!viewer) throw new ActionError("You must be signed in to do that."); | |
| 10 | + return viewer; | |
| 11 | + } | |
| 12 | + | |
| 13 | + /** Loads a repository for a server action and checks the viewer's access level. */ | |
| 14 | + export async function repoForAction(owner: string, name: string, need: "read" | "write" | "admin") { | |
| 15 | + const viewer = await viewerOrThrow(); | |
| 16 | + const repo = findRepo(owner, name); | |
| 17 | + const access: Access = repo ? repoAccess(repo, viewer.id) : null; | |
| 18 | + if (!repo || !access) throw new ActionError("Repository not found."); | |
| 19 | + if (need === "write" && !canWrite(access)) throw new ActionError("You don't have push access to this repository."); | |
| 20 | + if (need === "admin" && access !== "admin") throw new ActionError("Only the owner can change these settings."); | |
| 21 | + return { repo, viewer, access }; | |
| 22 | + } | |
| @@ -0,0 +1,176 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { redirect } from "next/navigation"; | |
| 5 | + import { | |
| 6 | + ESTIMATES, | |
| 7 | + STATUS_IDS, | |
| 8 | + isValidDay, | |
| 9 | + type ActionResult, | |
| 10 | + type IssueLabel, | |
| 11 | + type IssuePatch, | |
| 12 | + type NewIssueInput, | |
| 13 | + } from "@/lib/issue-meta"; | |
| 14 | + import { | |
| 15 | + addComment, | |
| 16 | + createIssue, | |
| 17 | + createLabel, | |
| 18 | + deleteIssue, | |
| 19 | + deleteLabel, | |
| 20 | + getIssue, | |
| 21 | + LabelError, | |
| 22 | + listLabels, | |
| 23 | + listMembers, | |
| 24 | + updateIssue, | |
| 25 | + } from "@/lib/issues"; | |
| 26 | + import { canWrite, type RepoRecord } from "@/lib/repos"; | |
| 27 | + import { ActionError, repoForAction } from "./guard"; | |
| 28 | + import type { FormState } from "./types"; | |
| 29 | + | |
| 30 | + const MAX_BODY = 65_536; | |
| 31 | + | |
| 32 | + function revalidateIssues(repo: RepoRecord) { | |
| 33 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 34 | + } | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * Validates untrusted issue properties from the browser. Only the fields that | |
| 38 | + * are present are checked; anything invalid throws. | |
| 39 | + */ | |
| 40 | + function validate(repo: RepoRecord, patch: IssuePatch): IssuePatch { | |
| 41 | + const out: IssuePatch = {}; | |
| 42 | + if (patch.title !== undefined) { | |
| 43 | + const title = String(patch.title).trim().slice(0, 256); | |
| 44 | + if (!title) throw new ActionError("Title can't be blank."); | |
| 45 | + out.title = title; | |
| 46 | + } | |
| 47 | + if (patch.body !== undefined) out.body = String(patch.body).trim().slice(0, MAX_BODY); | |
| 48 | + if (patch.status !== undefined) { | |
| 49 | + if (!STATUS_IDS.includes(patch.status)) throw new ActionError("Unknown status."); | |
| 50 | + out.status = patch.status; | |
| 51 | + } | |
| 52 | + if (patch.priority !== undefined) { | |
| 53 | + if (!Number.isInteger(patch.priority) || patch.priority < 0 || patch.priority > 4) throw new ActionError("Unknown priority."); | |
| 54 | + out.priority = patch.priority; | |
| 55 | + } | |
| 56 | + if (patch.assigneeId !== undefined) { | |
| 57 | + if (patch.assigneeId !== null && !listMembers(repo.id, repo.ownerId).some((m) => m.id === patch.assigneeId)) { | |
| 58 | + throw new ActionError("Only the owner and collaborators can be assigned."); | |
| 59 | + } | |
| 60 | + out.assigneeId = patch.assigneeId; | |
| 61 | + } | |
| 62 | + if (patch.estimate !== undefined) { | |
| 63 | + if (patch.estimate !== null && !(ESTIMATES as readonly number[]).includes(patch.estimate)) throw new ActionError("Unknown estimate."); | |
| 64 | + out.estimate = patch.estimate; | |
| 65 | + } | |
| 66 | + if (patch.dueDate !== undefined) { | |
| 67 | + if (patch.dueDate !== null && !isValidDay(String(patch.dueDate))) throw new ActionError("Invalid due date."); | |
| 68 | + out.dueDate = patch.dueDate; | |
| 69 | + } | |
| 70 | + if (patch.labelIds !== undefined) { | |
| 71 | + if (!Array.isArray(patch.labelIds)) throw new ActionError("Invalid labels."); | |
| 72 | + const known = new Set(listLabels(repo.id).map((l) => l.id)); | |
| 73 | + const ids = [...new Set(patch.labelIds)]; | |
| 74 | + if (!ids.every((id) => known.has(id))) throw new ActionError("Unknown label."); | |
| 75 | + out.labelIds = ids; | |
| 76 | + } | |
| 77 | + if (patch.sortOrder !== undefined) { | |
| 78 | + if (typeof patch.sortOrder !== "number" || !Number.isFinite(patch.sortOrder)) throw new ActionError("Invalid order."); | |
| 79 | + out.sortOrder = patch.sortOrder; | |
| 80 | + } | |
| 81 | + return out; | |
| 82 | + } | |
| 83 | + | |
| 84 | + // Permissions: anyone who can read a repository may open issues and comment. | |
| 85 | + // Changing an issue in any way (title, description, status, triage, order) needs | |
| 86 | + // push access. Deleting is reserved for the issue's creator and the repository owner. | |
| 87 | + | |
| 88 | + export async function createIssueAction( | |
| 89 | + owner: string, | |
| 90 | + name: string, | |
| 91 | + input: NewIssueInput, | |
| 92 | + ): Promise<ActionResult & { number?: number }> { | |
| 93 | + try { | |
| 94 | + const { repo, viewer, access } = await repoForAction(owner, name, "read"); | |
| 95 | + const { title, body, ...properties } = validate(repo, { ...input, sortOrder: undefined }); | |
| 96 | + if (!title) return { error: "Title can't be blank." }; | |
| 97 | + const number = createIssue(repo.id, viewer.id, { | |
| 98 | + title, | |
| 99 | + body: body ?? "", | |
| 100 | + ...(canWrite(access) ? properties : {}), | |
| 101 | + }); | |
| 102 | + revalidateIssues(repo); | |
| 103 | + return { number }; | |
| 104 | + } catch (e) { | |
| 105 | + if (e instanceof ActionError) return { error: e.message }; | |
| 106 | + throw e; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + export async function updateIssueAction(owner: string, name: string, number: number, patch: IssuePatch): Promise<ActionResult> { | |
| 111 | + try { | |
| 112 | + const { repo, viewer, access } = await repoForAction(owner, name, "read"); | |
| 113 | + const issue = getIssue(repo.id, number); | |
| 114 | + if (!issue) return { error: "Issue not found." }; | |
| 115 | + if (!canWrite(access)) return { error: "Only collaborators can change issues. You can still comment." }; | |
| 116 | + const clean = validate(repo, patch); | |
| 117 | + updateIssue(issue, clean, viewer.id); | |
| 118 | + revalidateIssues(repo); | |
| 119 | + return {}; | |
| 120 | + } catch (e) { | |
| 121 | + if (e instanceof ActionError) return { error: e.message }; | |
| 122 | + throw e; | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + export async function commentAction(owner: string, name: string, number: number, formData: FormData) { | |
| 127 | + const { repo, viewer } = await repoForAction(owner, name, "read"); | |
| 128 | + const issue = getIssue(repo.id, number); | |
| 129 | + if (!issue) throw new ActionError("Issue not found"); | |
| 130 | + const body = String(formData.get("body") ?? "").trim().slice(0, MAX_BODY); | |
| 131 | + if (body) addComment(issue.id, { body, authorId: viewer.id }); | |
| 132 | + revalidateIssues(repo); | |
| 133 | + } | |
| 134 | + | |
| 135 | + export async function deleteIssueAction(owner: string, name: string, number: number) { | |
| 136 | + const { repo, viewer, access } = await repoForAction(owner, name, "read"); | |
| 137 | + const issue = getIssue(repo.id, number); | |
| 138 | + if (!issue) throw new ActionError("Issue not found."); | |
| 139 | + if (issue.authorId !== viewer.id && access !== "admin") { | |
| 140 | + throw new ActionError("Only the issue's creator and the repository owner can delete it."); | |
| 141 | + } | |
| 142 | + deleteIssue(issue.id); | |
| 143 | + revalidateIssues(repo); | |
| 144 | + redirect(`/${repo.owner}/${repo.name}/issues`); | |
| 145 | + } | |
| 146 | + | |
| 147 | + export async function createLabelAction( | |
| 148 | + owner: string, | |
| 149 | + name: string, | |
| 150 | + input: { name: string; color: string }, | |
| 151 | + ): Promise<ActionResult & { label?: IssueLabel }> { | |
| 152 | + try { | |
| 153 | + const { repo } = await repoForAction(owner, name, "write"); | |
| 154 | + const label = createLabel(repo.id, { name: String(input.name ?? ""), color: String(input.color ?? "") }); | |
| 155 | + revalidateIssues(repo); | |
| 156 | + return { label }; | |
| 157 | + } catch (e) { | |
| 158 | + if (e instanceof ActionError || e instanceof LabelError) return { error: e.message }; | |
| 159 | + throw e; | |
| 160 | + } | |
| 161 | + } | |
| 162 | + | |
| 163 | + /** For the labels settings form. */ | |
| 164 | + export async function createLabelFormAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 165 | + const result = await createLabelAction(owner, name, { | |
| 166 | + name: String(formData.get("name") ?? ""), | |
| 167 | + color: String(formData.get("color") ?? ""), | |
| 168 | + }); | |
| 169 | + return result.error ? { error: result.error } : { success: `Created label "${result.label!.name}".` }; | |
| 170 | + } | |
| 171 | + | |
| 172 | + export async function deleteLabelAction(owner: string, name: string, labelId: number) { | |
| 173 | + const { repo } = await repoForAction(owner, name, "write"); | |
| 174 | + deleteLabel(repo.id, labelId); | |
| 175 | + revalidateIssues(repo); | |
| 176 | + } | |
| @@ -0,0 +1,114 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { redirect } from "next/navigation"; | |
| 5 | + import { | |
| 6 | + addCollaborator, | |
| 7 | + createRepo, | |
| 8 | + deleteRepo, | |
| 9 | + removeCollaborator, | |
| 10 | + removeRepoImage, | |
| 11 | + renameRepo, | |
| 12 | + RepoError, | |
| 13 | + setRepoImage, | |
| 14 | + updateRepo, | |
| 15 | + type Visibility, | |
| 16 | + } from "@/lib/repos"; | |
| 17 | + import { ActionError, repoForAction, viewerOrThrow } from "./guard"; | |
| 18 | + import type { FormState } from "./types"; | |
| 19 | + | |
| 20 | + const visibilityOf = (v: FormDataEntryValue | null): Visibility => (v === "private" ? "private" : "public"); | |
| 21 | + | |
| 22 | + async function attempt(fn: () => Promise<FormState | void> | FormState | void): Promise<FormState> { | |
| 23 | + try { | |
| 24 | + return (await fn()) ?? undefined; | |
| 25 | + } catch (e) { | |
| 26 | + if (e instanceof RepoError || e instanceof ActionError) return { error: e.message }; | |
| 27 | + throw e; | |
| 28 | + } | |
| 29 | + } | |
| 30 | + | |
| 31 | + export async function createRepoAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 32 | + let path = ""; | |
| 33 | + const result = await attempt(async () => { | |
| 34 | + const viewer = await viewerOrThrow(); | |
| 35 | + const repo = await createRepo(viewer, { | |
| 36 | + name: String(formData.get("name") ?? ""), | |
| 37 | + description: String(formData.get("description") ?? ""), | |
| 38 | + visibility: visibilityOf(formData.get("visibility")), | |
| 39 | + readme: formData.get("readme") === "on", | |
| 40 | + }); | |
| 41 | + path = `/${repo.owner}/${repo.name}`; | |
| 42 | + }); | |
| 43 | + if (result?.error) return result; | |
| 44 | + redirect(path); | |
| 45 | + } | |
| 46 | + | |
| 47 | + export async function updateRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) { | |
| 48 | + return attempt(async () => { | |
| 49 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 50 | + updateRepo(repo.id, { | |
| 51 | + description: String(formData.get("description") ?? ""), | |
| 52 | + visibility: visibilityOf(formData.get("visibility")), | |
| 53 | + }); | |
| 54 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 55 | + return { success: "Settings saved." }; | |
| 56 | + }); | |
| 57 | + } | |
| 58 | + | |
| 59 | + export async function updateRepoImageAction(owner: string, name: string, _prev: FormState, formData: FormData) { | |
| 60 | + return attempt(async () => { | |
| 61 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 62 | + const file = formData.get("image"); | |
| 63 | + if (!(file instanceof File)) throw new RepoError("Choose an image to upload."); | |
| 64 | + setRepoImage(repo.id, new Uint8Array(await file.arrayBuffer())); | |
| 65 | + // The image shows up in sidebars and lists everywhere, not just on this repository's pages. | |
| 66 | + revalidatePath("/", "layout"); | |
| 67 | + return { success: "Repository image updated." }; | |
| 68 | + }); | |
| 69 | + } | |
| 70 | + | |
| 71 | + export async function removeRepoImageAction(owner: string, name: string) { | |
| 72 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 73 | + removeRepoImage(repo.id); | |
| 74 | + revalidatePath("/", "layout"); | |
| 75 | + } | |
| 76 | + | |
| 77 | + export async function renameRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) { | |
| 78 | + let path = ""; | |
| 79 | + const result = await attempt(async () => { | |
| 80 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 81 | + const renamed = await renameRepo(repo, String(formData.get("name") ?? "")); | |
| 82 | + path = `/${renamed.owner}/${renamed.name}/settings`; | |
| 83 | + }); | |
| 84 | + if (result?.error) return result; | |
| 85 | + redirect(path); | |
| 86 | + } | |
| 87 | + | |
| 88 | + export async function deleteRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) { | |
| 89 | + const result = await attempt(async () => { | |
| 90 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 91 | + if (formData.get("confirm") !== `${repo.owner}/${repo.name}`) { | |
| 92 | + return { error: `Type ${repo.owner}/${repo.name} to confirm.` }; | |
| 93 | + } | |
| 94 | + await deleteRepo(repo); | |
| 95 | + }); | |
| 96 | + if (result?.error) return result; | |
| 97 | + redirect("/"); | |
| 98 | + } | |
| 99 | + | |
| 100 | + export async function addCollaboratorAction(owner: string, name: string, _prev: FormState, formData: FormData) { | |
| 101 | + return attempt(async () => { | |
| 102 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 103 | + const username = String(formData.get("username") ?? "").trim(); | |
| 104 | + addCollaborator(repo, username); | |
| 105 | + revalidatePath(`/${repo.owner}/${repo.name}/settings`); | |
| 106 | + return { success: `${username} can now push to this repository.` }; | |
| 107 | + }); | |
| 108 | + } | |
| 109 | + | |
| 110 | + export async function removeCollaboratorAction(owner: string, name: string, userId: number) { | |
| 111 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 112 | + removeCollaborator(repo.id, userId); | |
| 113 | + revalidatePath(`/${repo.owner}/${repo.name}/settings`); | |
| 114 | + } | |
| @@ -0,0 +1,91 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys"; | |
| 5 | + import { canWrite, findRepoById, repoAccess } from "@/lib/repos"; | |
| 6 | + import { createToken, deleteToken, TokenError, type TokenPermission } from "@/lib/tokens"; | |
| 7 | + import { setPassword, updateProfile, UserError, validatePassword, verifyUserPassword } from "@/lib/users"; | |
| 8 | + import { viewerOrThrow } from "./guard"; | |
| 9 | + import type { FormState } from "./types"; | |
| 10 | + | |
| 11 | + export async function updateProfileAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 12 | + const viewer = await viewerOrThrow(); | |
| 13 | + try { | |
| 14 | + updateProfile(viewer.id, { | |
| 15 | + name: String(formData.get("name") ?? ""), | |
| 16 | + email: String(formData.get("email") ?? ""), | |
| 17 | + bio: String(formData.get("bio") ?? ""), | |
| 18 | + }); | |
| 19 | + } catch (e) { | |
| 20 | + if (e instanceof UserError) return { error: e.message }; | |
| 21 | + throw e; | |
| 22 | + } | |
| 23 | + revalidatePath("/", "layout"); | |
| 24 | + return { success: "Profile updated." }; | |
| 25 | + } | |
| 26 | + | |
| 27 | + export async function changePasswordAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 28 | + const viewer = await viewerOrThrow(); | |
| 29 | + const current = String(formData.get("current") ?? ""); | |
| 30 | + const next = String(formData.get("password") ?? ""); | |
| 31 | + if (!(await verifyUserPassword(viewer.id, current))) return { error: "Your current password is incorrect." }; | |
| 32 | + if (next !== formData.get("confirm")) return { error: "The new passwords don't match." }; | |
| 33 | + const problem = validatePassword(next); | |
| 34 | + if (problem) return { error: problem }; | |
| 35 | + await setPassword(viewer.id, next); | |
| 36 | + return { success: "Password changed." }; | |
| 37 | + } | |
| 38 | + | |
| 39 | + export async function addSshKeyAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 40 | + const viewer = await viewerOrThrow(); | |
| 41 | + try { | |
| 42 | + addSshKey(viewer.id, String(formData.get("title") ?? ""), String(formData.get("key") ?? "")); | |
| 43 | + } catch (e) { | |
| 44 | + if (e instanceof KeyError) return { error: e.message }; | |
| 45 | + throw e; | |
| 46 | + } | |
| 47 | + revalidatePath("/settings/keys"); | |
| 48 | + return { success: "SSH key added." }; | |
| 49 | + } | |
| 50 | + | |
| 51 | + export async function deleteSshKeyAction(keyId: number) { | |
| 52 | + const viewer = await viewerOrThrow(); | |
| 53 | + deleteSshKey(viewer.id, keyId); | |
| 54 | + revalidatePath("/settings/keys"); | |
| 55 | + } | |
| 56 | + | |
| 57 | + export async function createTokenAction(_prev: FormState, formData: FormData): Promise<FormState> { | |
| 58 | + const viewer = await viewerOrThrow(); | |
| 59 | + const permission: TokenPermission = formData.get("permission") === "read" ? "read" : "write"; | |
| 60 | + const expiry = String(formData.get("expiry") ?? "30"); | |
| 61 | + let repoIds: number[] | null = null; | |
| 62 | + if (formData.get("scope") === "selected") { | |
| 63 | + repoIds = formData | |
| 64 | + .getAll("repos") | |
| 65 | + .map(Number) | |
| 66 | + .filter((id) => { | |
| 67 | + const repo = findRepoById(id); | |
| 68 | + const access = repo ? repoAccess(repo, viewer.id) : null; | |
| 69 | + return access && (permission === "read" || canWrite(access)); | |
| 70 | + }); | |
| 71 | + } | |
| 72 | + try { | |
| 73 | + const token = createToken(viewer.id, { | |
| 74 | + name: String(formData.get("name") ?? ""), | |
| 75 | + permission, | |
| 76 | + expiresInDays: expiry === "never" ? null : Number(expiry) || 30, | |
| 77 | + repoIds, | |
| 78 | + }); | |
| 79 | + revalidatePath("/settings/tokens"); | |
| 80 | + return { token }; | |
| 81 | + } catch (e) { | |
| 82 | + if (e instanceof TokenError) return { error: e.message }; | |
| 83 | + throw e; | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + export async function deleteTokenAction(tokenId: number) { | |
| 88 | + const viewer = await viewerOrThrow(); | |
| 89 | + deleteToken(viewer.id, tokenId); | |
| 90 | + revalidatePath("/settings/tokens"); | |
| 91 | + } | |
| @@ -0,0 +1 @@ | ||
| 1 | + export type FormState = { error?: string; success?: string; token?: string } | undefined; | |
Binary file not shown.
| @@ -0,0 +1,54 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { Field } from "@unlocalhosted/metalui"; | |
| 3 | + import { CompassIcon, SearchIcon } from "@/components/icons"; | |
| 4 | + import { FieldIcon, FieldInput } from "@/components/metal"; | |
| 5 | + import { RepoList } from "@/components/repo-list"; | |
| 6 | + import { Blankslate, Box, Counter } from "@/components/ui"; | |
| 7 | + import { getViewer } from "@/lib/auth"; | |
| 8 | + import { withActivity } from "@/lib/listing"; | |
| 9 | + import { searchRepos } from "@/lib/repos"; | |
| 10 | + | |
| 11 | + export const metadata: Metadata = { title: "Explore" }; | |
| 12 | + | |
| 13 | + export default async function ExplorePage({ searchParams }: PageProps<"/explore">) { | |
| 14 | + const q = String((await searchParams).q ?? "").trim(); | |
| 15 | + const viewer = await getViewer(); | |
| 16 | + const repos = await withActivity(searchRepos(q, viewer?.id ?? null)); | |
| 17 | + | |
| 18 | + return ( | |
| 19 | + <main className="w-full"> | |
| 20 | + <section className="rule-b"> | |
| 21 | + <div className="mx-auto max-w-5xl px-4 pt-16 pb-14 text-center"> | |
| 22 | + <span className="icon-tile mx-auto size-14 rounded-[18px]"> | |
| 23 | + <CompassIcon width={24} height={24} /> | |
| 24 | + </span> | |
| 25 | + <p className="mt-6 type-label engraved">Public repositories</p> | |
| 26 | + <h1 className="mt-2 text-[36px] leading-[1.05] font-semibold tracking-[-0.04em] text-ink">Explore Vectrum</h1> | |
| 27 | + <p className="mt-2 type-lead text-ink2">Discover repositories hosted here.</p> | |
| 28 | + <form role="search" className="mx-auto mt-8 max-w-xl text-left"> | |
| 29 | + <Field size="large" className="focus-within:focus-ring-flush"> | |
| 30 | + <FieldIcon> | |
| 31 | + <SearchIcon /> | |
| 32 | + </FieldIcon> | |
| 33 | + <FieldInput name="q" defaultValue={q} placeholder="Search by name, owner or description" aria-label="Search repositories" /> | |
| 34 | + </Field> | |
| 35 | + </form> | |
| 36 | + </div> | |
| 37 | + </section> | |
| 38 | + <div className="mx-auto max-w-5xl px-4 py-10"> | |
| 39 | + <h2 className="mb-4 flex items-center gap-2 type-display text-ink"> | |
| 40 | + {q ? <>Results for “{q}”</> : "Recently updated"} <Counter>{repos.length}</Counter> | |
| 41 | + </h2> | |
| 42 | + {repos.length ? ( | |
| 43 | + <RepoList repos={repos} /> | |
| 44 | + ) : ( | |
| 45 | + <Box> | |
| 46 | + <Blankslate icon={<SearchIcon />} title="No repositories found"> | |
| 47 | + {q ? "Try a different search." : "Nobody has created a public repository yet."} | |
| 48 | + </Blankslate> | |
| 49 | + </Box> | |
| 50 | + )} | |
| 51 | + </div> | |
| 52 | + </main> | |
| 53 | + ); | |
| 54 | + } | |
Binary file not shown.
| @@ -0,0 +1,19 @@ | ||
| 1 | + import { StatusPage } from "@/components/home-status"; | |
| 2 | + import { HomeIcon } from "@/components/icons"; | |
| 3 | + import { ButtonLink } from "@/components/ui"; | |
| 4 | + | |
| 5 | + export default function Forbidden() { | |
| 6 | + return ( | |
| 7 | + <StatusPage | |
| 8 | + code="403" | |
| 9 | + label="Forbidden" | |
| 10 | + title="You don’t have access to this page" | |
| 11 | + description="Ask the repository owner to add you as a collaborator." | |
| 12 | + actions={ | |
| 13 | + <ButtonLink href="/" icon={<HomeIcon />}> | |
| 14 | + Go Home | |
| 15 | + </ButtonLink> | |
| 16 | + } | |
| 17 | + /> | |
| 18 | + ); | |
| 19 | + } | |
| @@ -1,26 +1,330 @@ | ||
| 1 | 1 | @import "tailwindcss"; |
| 2 | + /* MetalUI's Tailwind theme: its type roles (type-ui, type-label…), inks (text-ink, text-ink2…), materials | |
| 3 | + (material-raised, material-well, material-cap…) and radii, for the parts of the app MetalUI has no | |
| 4 | + component for. The components' own CSS comes from @unlocalhosted/metalui/styles.css (see layout). */ | |
| 5 | + @import "@unlocalhosted/metalui/theme.css"; | |
| 2 | 6 | |
| 7 | + /* | |
| 8 | + * Soft Hardware: bone (light) and graphite (dark) colorways from MetalUI. The app's older token names | |
| 9 | + * are kept as aliases of MetalUI's so every surface follows the colorway: | |
| 10 | + * sheet / canvas / subtle / inset → surface lo → hi, and the sunk well | |
| 11 | + * fg / muted / dim → ink, ink2, ink3 | |
| 12 | + * line → the engraved rule | |
| 13 | + * Green is intent (focus, selection, live), never a call to action; red is destructive only. | |
| 14 | + * | |
| 15 | + * The colorway follows the system unless <html data-mu-colorway="bone|graphite"> overrides it. | |
| 16 | + */ | |
| 3 | 17 | :root { |
| 4 | - --background: #ffffff; | |
| 5 | - --foreground: #171717; | |
| 6 | - } | |
| 18 | + --mu-sans: var(--font-geist), "Geist", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; | |
| 19 | + --mu-mono: var(--font-geist-mono), "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 20 | + --mu-pixel: var(--font-doto), "Martian Mono", ui-monospace, monospace; | |
| 7 | 21 | |
| 8 | - @theme inline { | |
| 9 | - --color-background: var(--background); | |
| 10 | - --color-foreground: var(--foreground); | |
| 11 | - --font-sans: var(--font-geist-sans); | |
| 12 | - --font-mono: var(--font-geist-mono); | |
| 22 | + --page: var(--mu-page); | |
| 23 | + --sheet: var(--mu-s); | |
| 24 | + --canvas: var(--mu-s-hi); | |
| 25 | + --canvas-subtle: var(--mu-s-lo); | |
| 26 | + --canvas-inset: var(--mu-well-top); | |
| 27 | + --header: var(--mu-page); | |
| 28 | + --border: var(--mu-rule); | |
| 29 | + --border-muted: color-mix(in srgb, var(--mu-rule) 70%, transparent); | |
| 30 | + --fg: var(--mu-ink); | |
| 31 | + --fg-muted: var(--mu-ink2); | |
| 32 | + --fg-dim: var(--mu-ink3); | |
| 33 | + --accent: var(--mu-ink); | |
| 34 | + --accent-strong: var(--mu-ink); | |
| 35 | + --accent-2: var(--mu-cue-url-ink); | |
| 36 | + --accent-3: var(--mu-ink2); | |
| 37 | + --success-emphasis: #2B7A52; | |
| 38 | + --done: var(--mu-tint-iris); | |
| 39 | + --danger: var(--mu-red); | |
| 40 | + --attention: #9A6A12; | |
| 41 | + --diff-add: rgba(63, 185, 122, .10); | |
| 42 | + --diff-add-num: rgba(63, 185, 122, .18); | |
| 43 | + --diff-del: rgba(216, 69, 59, .08); | |
| 44 | + --diff-del-num: rgba(216, 69, 59, .15); | |
| 45 | + --diff-hunk: rgba(40, 38, 32, .04); | |
| 13 | 46 | } |
| 14 | 47 | |
| 48 | + [data-mu-colorway="graphite"] { | |
| 49 | + --page: #19191B; | |
| 50 | + --success-emphasis: var(--mu-green); | |
| 51 | + --attention: var(--mu-warning); | |
| 52 | + --diff-hunk: rgba(255, 255, 255, .04); | |
| 53 | + } | |
| 15 | 54 | @media (prefers-color-scheme: dark) { |
| 16 | - :root { | |
| 17 | - --background: #0a0a0a; | |
| 18 | - --foreground: #ededed; | |
| 55 | + :root:not([data-mu-colorway="bone"]) { | |
| 56 | + --page: #19191B; | |
| 57 | + --success-emphasis: var(--mu-green); | |
| 58 | + --attention: var(--mu-warning); | |
| 59 | + --diff-hunk: rgba(255, 255, 255, .04); | |
| 60 | + } | |
| 61 | + :root:not([data-mu-colorway="bone"]) .shiki, | |
| 62 | + :root:not([data-mu-colorway="bone"]) .shiki span { | |
| 63 | + color: var(--shiki-dark) !important; | |
| 64 | + background-color: transparent !important; | |
| 19 | 65 | } |
| 20 | 66 | } |
| 67 | + [data-mu-colorway="graphite"] .shiki, | |
| 68 | + [data-mu-colorway="graphite"] .shiki span { | |
| 69 | + color: var(--shiki-dark) !important; | |
| 70 | + background-color: transparent !important; | |
| 71 | + } | |
| 72 | + | |
| 73 | + /* Tailwind's `dark:` variant follows the colorway. */ | |
| 74 | + @custom-variant dark { | |
| 75 | + &:where([data-mu-colorway="graphite"], [data-mu-colorway="graphite"] *) { | |
| 76 | + @slot; | |
| 77 | + } | |
| 78 | + @media (prefers-color-scheme: dark) { | |
| 79 | + &:where(:root:not([data-mu-colorway="bone"]), :root:not([data-mu-colorway="bone"]) *) { | |
| 80 | + @slot; | |
| 81 | + } | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + /* App aliases. `page`, `green`, `success` and `red` come from MetalUI's theme as they are. */ | |
| 86 | + @theme inline { | |
| 87 | + --color-sheet: var(--sheet); | |
| 88 | + --color-canvas: var(--canvas); | |
| 89 | + --color-subtle: var(--canvas-subtle); | |
| 90 | + --color-inset: var(--canvas-inset); | |
| 91 | + --color-header: var(--header); | |
| 92 | + --color-line: var(--border); | |
| 93 | + --color-line-muted: var(--border-muted); | |
| 94 | + --color-fg: var(--fg); | |
| 95 | + --color-muted: var(--fg-muted); | |
| 96 | + --color-dim: var(--fg-dim); | |
| 97 | + --color-accent: var(--accent); | |
| 98 | + --color-accent-strong: var(--accent-strong); | |
| 99 | + --color-accent-2: var(--accent-2); | |
| 100 | + --color-accent-3: var(--accent-3); | |
| 101 | + --color-success-emphasis: var(--success-emphasis); | |
| 102 | + --color-done: var(--done); | |
| 103 | + --color-danger: var(--danger); | |
| 104 | + --color-attention: var(--attention); | |
| 105 | + --font-sans: var(--mu-sans); | |
| 106 | + --font-mono: var(--mu-mono); | |
| 107 | + --shadow-card: var(--mu-raise-sm); | |
| 108 | + --shadow-lift: var(--mu-raise-lite); | |
| 109 | + --shadow-float: var(--mu-raise); | |
| 110 | + --animate-pop: pop 180ms var(--mu-ease-slide); | |
| 111 | + --animate-fade-up: fade-up 500ms var(--mu-ease-slide) both; | |
| 112 | + } | |
| 113 | + | |
| 114 | + @keyframes pop { | |
| 115 | + from { opacity: 0; transform: translateY(-6px) scale(0.985); } | |
| 116 | + to { opacity: 1; transform: none; } | |
| 117 | + } | |
| 118 | + @keyframes fade-up { | |
| 119 | + from { opacity: 0; transform: translateY(6px); } | |
| 120 | + to { opacity: 1; transform: none; } | |
| 121 | + } | |
| 21 | 122 | |
| 22 | 123 | body { |
| 23 | - background: var(--background); | |
| 24 | - color: var(--foreground); | |
| 25 | - font-family: Arial, Helvetica, sans-serif; | |
| 124 | + background: var(--page); | |
| 125 | + color: var(--mu-ink); | |
| 126 | + font-family: var(--mu-sans); | |
| 127 | + } | |
| 128 | + | |
| 129 | + @layer components { | |
| 130 | + /* The engraved label role: uppercase mono with a lip, e.g. "PULL REQUEST · #8" */ | |
| 131 | + .mono-label { | |
| 132 | + font: 400 9.5px/12px var(--mu-mono); | |
| 133 | + text-transform: uppercase; | |
| 134 | + letter-spacing: 0.11em; | |
| 135 | + color: var(--mu-engrave); | |
| 136 | + text-shadow: var(--mu-lip-shadow); | |
| 137 | + } | |
| 138 | + /* A section heading, engraved above its plate: "ABOUT" */ | |
| 139 | + .section-label { | |
| 140 | + font: 400 9.5px/12px var(--mu-mono); | |
| 141 | + text-transform: uppercase; | |
| 142 | + letter-spacing: 0.11em; | |
| 143 | + color: var(--mu-engrave); | |
| 144 | + text-shadow: var(--mu-lip-shadow); | |
| 145 | + } | |
| 146 | + /* A raised plate in the colorway (Surface material="raise-lite"), for server-rendered blocks. */ | |
| 147 | + .plate { | |
| 148 | + background: linear-gradient(var(--mu-s-hi), var(--mu-s)); | |
| 149 | + box-shadow: var(--mu-raise-lite); | |
| 150 | + } | |
| 151 | + /* A sunk well (the field look): inputs, code, quiet insets. */ | |
| 152 | + .well { | |
| 153 | + background: linear-gradient(var(--mu-well-top), var(--mu-well-bot)); | |
| 154 | + box-shadow: var(--mu-well); | |
| 155 | + } | |
| 156 | + /* An engraved hairline with its lip. */ | |
| 157 | + .rule-b { border-bottom: 1px solid var(--mu-rule); box-shadow: 0 1px 0 var(--mu-rule-lip); } | |
| 158 | + .rule-t { border-top: 1px solid var(--mu-rule); box-shadow: inset 0 1px 0 var(--mu-rule-lip); } | |
| 159 | + } | |
| 160 | + | |
| 161 | + ::selection { | |
| 162 | + background: color-mix(in srgb, var(--mu-green) 35%, transparent); | |
| 163 | + } | |
| 164 | + | |
| 165 | + :focus-visible { | |
| 166 | + outline: 2px solid var(--mu-focus); | |
| 167 | + outline-offset: 2px; | |
| 168 | + } | |
| 169 | + | |
| 170 | + /* Menus and dialogs rise in on the surface spring */ | |
| 171 | + details[open] > .menu-panel { animation: var(--animate-pop); } | |
| 172 | + dialog[open] { animation: var(--animate-pop); } | |
| 173 | + dialog::backdrop { background: var(--mu-scrim); backdrop-filter: blur(2px); } | |
| 174 | + | |
| 175 | + /* Code viewer: shiki output with line numbers */ | |
| 176 | + .code-view .shiki { | |
| 177 | + background: transparent !important; | |
| 178 | + padding: 12px 0; | |
| 179 | + overflow-x: auto; | |
| 180 | + font-size: 12.5px; | |
| 181 | + line-height: 21px; | |
| 182 | + counter-reset: line; | |
| 183 | + } | |
| 184 | + .code-view .shiki code { | |
| 185 | + display: block; | |
| 186 | + min-width: max-content; | |
| 187 | + font-family: var(--mu-mono); | |
| 188 | + } | |
| 189 | + .code-view .line { | |
| 190 | + display: inline-block; | |
| 191 | + width: 100%; | |
| 192 | + padding-right: 24px; | |
| 193 | + } | |
| 194 | + .code-view .line:hover { | |
| 195 | + background: var(--mu-menu-row-hover); | |
| 196 | + } | |
| 197 | + .code-view .line::before { | |
| 198 | + counter-increment: line; | |
| 199 | + content: counter(line); | |
| 200 | + display: inline-block; | |
| 201 | + width: 60px; | |
| 202 | + padding-right: 20px; | |
| 203 | + margin-right: 4px; | |
| 204 | + text-align: right; | |
| 205 | + color: var(--mu-syn-line); | |
| 206 | + user-select: none; | |
| 207 | + } | |
| 208 | + .code-view .line:hover::before { color: var(--mu-ink2); } | |
| 209 | + | |
| 210 | + /* Rendered markdown (README, issues) */ | |
| 211 | + .markdown { | |
| 212 | + font-size: 14px; | |
| 213 | + line-height: 1.7; | |
| 214 | + letter-spacing: -0.005em; | |
| 215 | + overflow-wrap: break-word; | |
| 216 | + } | |
| 217 | + .markdown > *:first-child { margin-top: 0; } | |
| 218 | + .markdown > *:last-child { margin-bottom: 0; } | |
| 219 | + .markdown p, .markdown ul, .markdown ol, .markdown blockquote, .markdown pre, .markdown table { | |
| 220 | + margin: 0 0 16px; | |
| 221 | + } | |
| 222 | + .markdown h1, .markdown h2, .markdown h3, .markdown h4, .markdown h5, .markdown h6 { | |
| 223 | + margin: 26px 0 10px; | |
| 224 | + font-weight: 600; | |
| 225 | + line-height: 1.25; | |
| 226 | + letter-spacing: -0.02em; | |
| 227 | + } | |
| 228 | + .markdown h1 { font-size: 1.6em; } | |
| 229 | + .markdown h2 { font-size: 1.3em; } | |
| 230 | + .markdown h3 { font-size: 1.1em; } | |
| 231 | + .markdown h4 { font-size: 1em; } | |
| 232 | + .markdown a { | |
| 233 | + color: inherit; | |
| 234 | + text-decoration: underline; | |
| 235 | + text-decoration-thickness: 1px; | |
| 236 | + text-decoration-color: color-mix(in srgb, currentColor 30%, transparent); | |
| 237 | + text-underline-offset: 3px; | |
| 238 | + transition: text-decoration-color .16s; | |
| 239 | + } | |
| 240 | + .markdown a:hover { text-decoration-color: currentColor; } | |
| 241 | + .markdown ul { list-style: disc; padding-left: 1.6em; } | |
| 242 | + .markdown ol { list-style: decimal; padding-left: 1.6em; } | |
| 243 | + .markdown li::marker { color: var(--mu-ink3); } | |
| 244 | + .markdown li + li { margin-top: .3em; } | |
| 245 | + .markdown li > input[type="checkbox"] { margin-right: .5em; accent-color: var(--mu-green-deep); } | |
| 246 | + .markdown blockquote { | |
| 247 | + padding: .6em 1em; | |
| 248 | + color: var(--mu-ink2); | |
| 249 | + background: linear-gradient(var(--mu-well-top), var(--mu-well-bot)); | |
| 250 | + box-shadow: var(--mu-well); | |
| 251 | + border-radius: 12px; | |
| 252 | + } | |
| 253 | + .markdown code { | |
| 254 | + padding: .15em .45em; | |
| 255 | + font-size: 85%; | |
| 256 | + background: linear-gradient(var(--mu-well-top), var(--mu-well-bot)); | |
| 257 | + box-shadow: var(--mu-well); | |
| 258 | + border-radius: 6px; | |
| 259 | + font-family: var(--mu-mono); | |
| 260 | + } | |
| 261 | + .markdown pre { | |
| 262 | + padding: 14px 16px; | |
| 263 | + overflow: auto; | |
| 264 | + font-size: 85%; | |
| 265 | + line-height: 1.7; | |
| 266 | + background: linear-gradient(var(--mu-well-top), var(--mu-well-bot)); | |
| 267 | + box-shadow: var(--mu-well); | |
| 268 | + border-radius: 14px; | |
| 269 | + } | |
| 270 | + .markdown pre code { padding: 0; background: transparent; box-shadow: none; font-size: 100%; } | |
| 271 | + .markdown table { | |
| 272 | + border-collapse: separate; | |
| 273 | + border-spacing: 0; | |
| 274 | + display: block; | |
| 275 | + overflow: auto; | |
| 276 | + } | |
| 277 | + .markdown th, .markdown td { padding: 9px 14px; border-bottom: 1px solid var(--mu-rule); } | |
| 278 | + .markdown tr:last-child td { border-bottom: 0; } | |
| 279 | + .markdown th { | |
| 280 | + font: 400 9.5px/12px var(--mu-mono); | |
| 281 | + text-transform: uppercase; | |
| 282 | + letter-spacing: 0.11em; | |
| 283 | + color: var(--mu-engrave); | |
| 284 | + text-align: left; | |
| 285 | + } | |
| 286 | + .markdown img { max-width: 100%; display: inline; border-radius: 10px; } | |
| 287 | + .markdown hr { height: 1px; margin: 32px 0; background: var(--mu-rule); border: 0; box-shadow: 0 1px 0 var(--mu-rule-lip); } | |
| 288 | + | |
| 289 | + /* A quiet light from above, behind marketing-style headers */ | |
| 290 | + .aurora { | |
| 291 | + background: radial-gradient(40rem 18rem at 50% -4rem, color-mix(in srgb, var(--mu-s-hi) 90%, transparent), transparent 70%); | |
| 292 | + } | |
| 293 | + /* Hairline grid that fades out towards the edges, like a cutting mat */ | |
| 294 | + .dot-grid { | |
| 295 | + background-image: | |
| 296 | + linear-gradient(to right, color-mix(in srgb, var(--mu-rule) 55%, transparent) 1px, transparent 1px), | |
| 297 | + linear-gradient(to bottom, color-mix(in srgb, var(--mu-rule) 55%, transparent) 1px, transparent 1px); | |
| 298 | + background-size: 48px 48px; | |
| 299 | + mask-image: radial-gradient(ellipse 60% 55% at 50% 0%, black 30%, transparent 75%); | |
| 300 | + } | |
| 301 | + .text-gradient { | |
| 302 | + background: linear-gradient(180deg, var(--mu-ink) 20%, color-mix(in srgb, var(--mu-ink) 50%, transparent) 110%); | |
| 303 | + -webkit-background-clip: text; | |
| 304 | + background-clip: text; | |
| 305 | + color: transparent; | |
| 306 | + } | |
| 307 | + /* A raised plate for highlighted cards */ | |
| 308 | + .ring-gradient { | |
| 309 | + background: linear-gradient(var(--mu-s-hi), var(--mu-s)); | |
| 310 | + box-shadow: var(--mu-raise); | |
| 311 | + } | |
| 312 | + /* A glyph sunk in a well, like the empty state's */ | |
| 313 | + .icon-tile { | |
| 314 | + display: flex; | |
| 315 | + align-items: center; | |
| 316 | + justify-content: center; | |
| 317 | + background: linear-gradient(var(--mu-well-top), var(--mu-well-bot)); | |
| 318 | + box-shadow: var(--mu-well); | |
| 319 | + color: var(--mu-ink3); | |
| 320 | + } | |
| 321 | + | |
| 322 | + /* CodeMirror editor sizing */ | |
| 323 | + .cm-theme, .cm-editor { height: 100%; } | |
| 324 | + .cm-editor { font-size: 12.5px; } | |
| 325 | + .cm-editor .cm-scroller { font-family: var(--mu-mono); line-height: 21px; } | |
| 326 | + .cm-editor.cm-focused { outline: none !important; } | |
| 327 | + | |
| 328 | + @media (prefers-reduced-motion: reduce) { | |
| 329 | + *, *::before, *::after { animation: none !important; transition: none !important; } | |
| 26 | 330 | } |
| @@ -0,0 +1,4 @@ | ||
| 1 | + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> | |
| 2 | + <style>path { fill: #171717 } @media (prefers-color-scheme: dark) { path { fill: #dcdcdc } }</style> | |
| 3 | + <path fill-rule="evenodd" d="M2 13a8.5 8.5 0 1 0 17 0a8.5 8.5 0 1 0-17 0ZM11.54 7.69Q10 4 14 4H26Q30 4 28.46 7.69L21.54 24.31Q20 28 18.46 24.31Z"/> | |
| 4 | + </svg> | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.