Vectrum
Sign In

Merge branch 'master' into t3/smtp-email-sending

# Conflicts:
#	apps/web/app/page.tsx
#	apps/web/lib/users.ts
JjohannSocommitted 2 parents 4558896 2704b3bcommite6cf1fc

Showing 48 changed files with 1619 additions and 233 deletions.

Merge commit — showing changes against the first parent.

+2−8apps/web/app/[owner]/[repo]/image/route.tsView File
@@ -1,6 +1,7 @@
11 import { getViewer } from "@/lib/auth";
22 import { normalizeRepoName } from "@/lib/git";
33 import { findRepo, getRepoImage, repoAccess } from "@/lib/repos";
4+ import { imageResponse } from "@/lib/uploaded-images";
45
56 /** The repository's uploaded image. URLs carry `?v=<upload time>`, so responses can be cached for good. */
67 export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/image">) {
@@ -11,12 +12,5 @@ export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/
1112
1213 const image = await getRepoImage(repo.id);
1314 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- });
15+ return imageResponse(image, repo.visibility);
2216 }
+23−2apps/web/app/[owner]/[repo]/layout.tsxView File
@@ -1,9 +1,11 @@
11 import Link from "next/link";
22 import { ForkedFrom, ForkLabel } from "@/components/forks";
33 import { ForkIcon } from "@/components/icons";
4+ import { LikeButton, SaveButton } from "@/components/repo-save";
45 import { RepoTabs } from "@/components/repo-tabs";
56 import { ButtonLink, Label, RepoAvatar, VisibilityLabel } from "@/components/ui";
67 import { openIssueCount } from "@/lib/issues";
8+ import { countLikes, likes, saveTargets } from "@/lib/lists";
79 import { countOpenPulls } from "@/lib/pulls";
810 import { countImages } from "@/lib/registry";
911 import { getRepo } from "@/lib/repo";
@@ -24,7 +26,14 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow
2426 const repo = await getRepo(owner, repoParam);
2527 // Like GitHub, readers only see the tab once the wiki has a page.
2628 const showWiki = repo.wikiEnabled && (repo.canWrite || (await wikiHead(repo)).head !== null);
27- const [parent, forks] = await Promise.all([visibleParent(repo, repo.viewer?.id), countForks(repo.id, repo.viewer?.id ?? null)]);
29+ const viewer = repo.viewer;
30+ const [parent, forks, likeCount, liked, targets] = await Promise.all([
31+ visibleParent(repo, viewer?.id),
32+ countForks(repo.id, viewer?.id ?? null),
33+ countLikes(repo.id),
34+ likes(repo.id, viewer?.id),
35+ viewer ? saveTargets(viewer, repo.id) : null,
36+ ]);
2837
2938 return (
3039 <>
@@ -49,7 +58,19 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow
4958 {repo.forkOf && <ForkedFrom parent={parent} className="ml-1 hidden lg:inline-flex" />}
5059 </div>
5160 <RepoTabs base={repo.base} openIssues={await openIssueCount(repo.id)} openPulls={await countOpenPulls(repo.id)} images={countImages(repo.id)} showWiki={showWiki} showSettings={repo.isAdmin} />
52- <div className="ml-auto flex items-center gap-2 2xl:ml-0 2xl:justify-self-end">
61+ <div className="ml-auto flex flex-wrap items-center gap-2 2xl:ml-0 2xl:justify-self-end">
62+ <LikeButton owner={repo.owner} name={repo.name} liked={liked} signedIn={!!viewer} />
63+ <Link href={`${repo.base}/likes`} className="rounded-key px-1 type-readout text-ink2 transition-colors hover:text-ink focus-visible:focus-ring">
64+ {likeCount} {likeCount === 1 ? "like" : "likes"}
65+ </Link>
66+ {viewer && targets && (
67+ <SaveButton
68+ repoId={repo.id}
69+ viewer={viewer.username}
70+ owners={targets.owners.map((o) => o.username)}
71+ lists={targets.lists.map((l) => ({ id: l.id, owner: l.owner.username, name: l.name, visibility: l.visibility, saved: l.saved }))}
72+ />
73+ )}
5374 <ButtonLink href={`${repo.base}/fork`} icon={<ForkIcon />}>
5475 Fork
5576 </ButtonLink>
+48−0apps/web/app/[owner]/[repo]/likes/page.tsxaddedView File
@@ -0,0 +1,48 @@
1+ import type { Metadata } from "next";
2+ import Link from "next/link";
3+ import { HeartIcon } from "@/components/icons";
4+ import { Avatar, Blankslate, Box, PageTitle } from "@/components/ui";
5+ import { timeAgo } from "@/lib/format";
6+ import { listLikers } from "@/lib/lists";
7+ import { getRepo } from "@/lib/repo";
8+
9+ export const metadata: Metadata = { title: "Likes" };
10+
11+ export default async function LikesPage({ params }: PageProps<"/[owner]/[repo]/likes">) {
12+ const { owner, repo: repoParam } = await params;
13+ const repo = await getRepo(owner, repoParam);
14+ const likers = await listLikers(repo.id);
15+
16+ return (
17+ <div className="mx-auto max-w-6xl">
18+ <PageTitle title="Likes" description={`People who like ${repo.owner}/${repo.name}.`} />
19+ {likers.length === 0 ? (
20+ <Box>
21+ <Blankslate icon={<HeartIcon />} title="Nobody likes this repository yet">
22+ Like it to show your appreciation and find it again on your profile.
23+ </Blankslate>
24+ </Box>
25+ ) : (
26+ <Box className="p-1.5">
27+ <ul className="grid gap-0.5 sm:grid-cols-2 lg:grid-cols-3">
28+ {likers.map(({ user, likedAt }) => (
29+ <li key={user.id}>
30+ <Link
31+ href={`/${user.username}`}
32+ className="flex items-center gap-3 rounded-plate px-3 py-2.5 transition-row outline-none hover:recipe-row-list-hover focus-visible:focus-ring-flush"
33+ >
34+ <Avatar name={user.name || user.username} size={32} />
35+ <span className="min-w-0 flex-1">
36+ <span className="block truncate type-title text-ink">{user.name || user.username}</span>
37+ <span className="block truncate type-meta text-ink3">@{user.username}</span>
38+ </span>
39+ <span className="shrink-0 type-readout text-ink3">{timeAgo(likedAt)}</span>
40+ </Link>
41+ </li>
42+ ))}
43+ </ul>
44+ </Box>
45+ )}
46+ </div>
47+ );
48+ }
+2−2apps/web/app/[owner]/[repo]/page.tsxView File
@@ -17,7 +17,7 @@ import {
1717 } from "@/components/icons";
1818 import { RecentPushes } from "@/components/pulls/recent-pushes";
1919 import { TreeView } from "@/components/tree-view";
20- import { Avatar, Blankslate, Box, ButtonLink, inputClass, Label } from "@/components/ui";
20+ import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, inputClass, Label } from "@/components/ui";
2121 import { countOpenPulls } from "@/lib/pulls";
2222 import { encodePath, formatDate, timeAgo } from "@/lib/format";
2323 import { commitCount, listBranches, listContributors, listFilesWithSizes, listTags } from "@/lib/git";
@@ -244,7 +244,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">)
244244 />
245245 ) : (
246246 <Link href={`/${repo.owner}`} title={repo.owner} className="inline-flex rounded-full focus-visible:focus-ring">
247- <Avatar name={repo.owner} size={32} />
247+ <Avatar name={repo.owner} src={avatarUrl({ username: repo.owner, avatarVersion: repo.ownerAvatarVersion })} size={32} />
248248 </Link>
249249 )}
250250 </section>
+2−2apps/web/app/[owner]/[repo]/settings/access/page.tsxView File
@@ -6,7 +6,7 @@ import { ConfirmButton } from "@/components/confirm-button";
66 import { PeopleIcon } from "@/components/icons";
77 import { SettingsSection } from "@/components/metal";
88 import { ListRow, PlateSection } from "@/components/settings-plate";
9- import { Avatar, Blankslate, Label, PageTitle } from "@/components/ui";
9+ import { Avatar, avatarUrl, Blankslate, Label, PageTitle } from "@/components/ui";
1010 import { timeAgo } from "@/lib/format";
1111 import { orgRole } from "@/lib/orgs";
1212 import { getRepo } from "@/lib/repo";
@@ -30,7 +30,7 @@ export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/
3030 <div className="flex flex-col gap-7">
3131 <SettingsSection title={`People with access · ${collaborators.length + 1}`}>
3232 <ListRow
33- lead={<Avatar name={repo.owner} size={32} />}
33+ lead={<Avatar name={repo.owner} src={avatarUrl({ username: repo.owner, avatarVersion: repo.ownerAvatarVersion })} size={32} />}
3434 title={
3535 <>
3636 <Link href={`/${repo.owner}`} className="truncate hover:underline">
+56−57apps/web/app/[owner]/[repo]/settings/forms.tsxView File
@@ -1,13 +1,15 @@
11 "use client";
22
3- import { useActionState, useEffect, useState } from "react";
4- import { Checkbox } from "@unlocalhosted/metalui";
3+ import { useActionState } from "react";
4+ import Link from "next/link";
5+ import { Checkbox, Select } from "@unlocalhosted/metalui";
56 import type { FormState } from "@/app/actions/types";
67 import { DeleteZone } from "@/components/delete-zone";
7- import { BookIcon, GlobeIcon, LockIcon, PencilIcon, UndoIcon, UploadIcon } from "@/components/icons";
8+ import { ImageUploadForm } from "@/components/image-upload-form";
9+ import { ArrowRightIcon, BookIcon, GlobeIcon, LockIcon, PencilIcon } from "@/components/icons";
810 import { ChoiceCards, TextField } from "@/components/settings-fields";
911 import { PlateBody, PlateFooter } from "@/components/settings-plate";
10- import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui";
12+ import { Alert, Button, RepoAvatar, type RepoImageRef } from "@/components/ui";
1113
1214 type Action = (prev: FormState, formData: FormData) => Promise<FormState>;
1315
@@ -20,60 +22,16 @@ export function ImageForm({
2022 resetAction: () => Promise<void>;
2123 repo: RepoImageRef;
2224 }) {
23- const [preview, setPreview] = useState<string | null>(null);
24- const [state, formAction, pending] = useActionState(async (prev: FormState, formData: FormData) => {
25- const result = await action(prev, formData);
26- if (result?.success) setPreview(null);
27- return result;
28- }, undefined);
29- useEffect(() => () => {
30- if (preview) URL.revokeObjectURL(preview);
31- }, [preview]);
32-
3325 return (
34- <PlateBody>
35- <div className="flex flex-wrap items-start gap-5">
36- <RepoAvatar repo={repo} src={preview ?? undefined} size={72} />
37- <div className="flex min-w-60 flex-1 flex-col gap-3">
38- <p className="type-body text-ink2">
39- Shown next to the repository everywhere. Until you upload one, it’s the owner’s avatar. PNG, JPEG, GIF or WebP, up to 1&nbsp;MB.
40- </p>
41- <div className="flex flex-wrap items-center gap-2">
42- <form action={formAction} className="flex items-center gap-2">
43- {/* The file input stays a real input; its label is drawn as MetalUI's standard cap. */}
44- <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")}>
45- <UploadIcon /> {repo.imageVersion ? "Replace Image" : "Upload Image"}
46- <input
47- type="file"
48- name="image"
49- accept="image/png,image/jpeg,image/gif,image/webp"
50- required
51- className="sr-only"
52- onChange={(e) => {
53- const file = e.target.files?.[0];
54- setPreview(file ? URL.createObjectURL(file) : null);
55- }}
56- />
57- </label>
58- {preview && (
59- <Button variant="primary" disabled={pending}>
60- {pending ? "Saving…" : "Save Image"}
61- </Button>
62- )}
63- </form>
64- {repo.imageVersion && !preview && (
65- <form action={resetAction}>
66- <Button variant="invisible" icon={<UndoIcon />}>
67- Reset to Owner’s Avatar
68- </Button>
69- </form>
70- )}
71- </div>
72- {state?.error && <Alert>{state.error}</Alert>}
73- {state?.success && !preview && <Alert kind="success">{state.success}</Alert>}
74- </div>
75- </div>
76- </PlateBody>
26+ <ImageUploadForm
27+ action={action}
28+ resetAction={resetAction}
29+ hasImage={!!repo.imageVersion}
30+ noun="Image"
31+ resetLabel="Reset to Owner’s Avatar"
32+ description="Shown next to the repository everywhere. Until you upload one, it’s the owner’s avatar."
33+ renderImage={(src) => <RepoAvatar repo={repo} src={src} size={72} />}
34+ />
7735 );
7836 }
7937
@@ -94,6 +52,47 @@ export function RenameForm({ action, name }: { action: Action; name: string }) {
9452 );
9553 }
9654
55+ /** Moving the repository to another account: one of `owners`, the accounts the viewer may create repositories in. */
56+ export function TransferForm({ action, owners, name }: { action: Action; owners: string[]; name: string }) {
57+ const [state, formAction, pending] = useActionState(action, undefined);
58+ if (owners.length === 0) {
59+ return (
60+ <PlateBody>
61+ <p className="type-body text-ink2">
62+ Move this repository to an organization you’re an admin or owner of. You don’t have one yet:{" "}
63+ <Link href="/organizations/new" className="text-ink underline">
64+ create an organization
65+ </Link>{" "}
66+ first.
67+ </p>
68+ </PlateBody>
69+ );
70+ }
71+ return (
72+ <form action={formAction}>
73+ <PlateBody>
74+ <p className="type-body text-ink2">
75+ Its issues, pull requests, wiki, releases, packages and collaborators move with it, and the new owner’s access rules apply. Clone
76+ URLs and links change to the new owner’s name.
77+ </p>
78+ <div className="flex max-w-xl flex-wrap items-end gap-2">
79+ <div className="flex flex-col gap-1.5">
80+ <span className="type-ui text-ink">New owner</span>
81+ <Select name="owner" aria-label="New owner" defaultValue={owners[0]} options={owners.map((o) => ({ value: o, label: o }))} className="min-w-36" />
82+ </div>
83+ <span aria-hidden className="flex h-8 items-center type-content text-ink3">
84+ / {name}
85+ </span>
86+ <Button icon={<ArrowRightIcon />} disabled={pending}>
87+ {pending ? "Transferring…" : "Transfer"}
88+ </Button>
89+ </div>
90+ {state?.error && <Alert>{state.error}</Alert>}
91+ </PlateBody>
92+ </form>
93+ );
94+ }
95+
9796 export function GeneralForm({
9897 action,
9998 description,
+8−1apps/web/app/[owner]/[repo]/settings/page.tsxView File
@@ -3,20 +3,24 @@ import {
33 deleteRepoAction,
44 removeRepoImageAction,
55 renameRepoAction,
6+ transferRepoAction,
67 updateFeaturesAction,
78 updateRepoAction,
89 updateRepoImageAction,
910 } from "@/app/actions/repo";
1011 import { PlateSection } from "@/components/settings-plate";
1112 import { PageTitle } from "@/components/ui";
13+ import { repoOwnersFor } from "@/lib/orgs";
1214 import { getRepo } from "@/lib/repo";
13- import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm } from "./forms";
15+ import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm, TransferForm } from "./forms";
1416
1517 export const metadata: Metadata = { title: "Settings" };
1618
1719 export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[repo]/settings">) {
1820 const { owner, repo: repoParam } = await params;
1921 const repo = await getRepo(owner, repoParam);
22+ // Where it can move: accounts the viewer may create repositories in, other than its own.
23+ const owners = repo.viewer ? (await repoOwnersFor(repo.viewer)).map((o) => o.username).filter((o) => o !== repo.owner) : [];
2024 return (
2125 <div>
2226 <PageTitle title="General" />
@@ -41,6 +45,9 @@ export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[
4145 <PlateSection title="Features">
4246 <FeaturesForm action={updateFeaturesAction.bind(null, repo.owner, repo.name)} wiki={repo.wikiEnabled} />
4347 </PlateSection>
48+ <PlateSection title="Transfer">
49+ <TransferForm action={transferRepoAction.bind(null, repo.owner, repo.name)} owners={owners} name={repo.name} />
50+ </PlateSection>
4451 <PlateSection title="Danger zone">
4552 <DangerZone action={deleteRepoAction.bind(null, repo.owner, repo.name)} fullName={`${repo.owner}/${repo.name}`} />
4653 </PlateSection>
+30−41apps/web/app/[owner]/org-profile.tsxView File
@@ -1,36 +1,38 @@
11 import Link from "next/link";
2- import { Field } from "@unlocalhosted/metalui";
3- import { CalendarIcon, GearIcon, OrganizationIcon, PeopleIcon, PlusIcon, RepoIcon, SearchIcon } from "@/components/icons";
4- import { FieldIcon, FieldInput } from "@/components/metal";
2+ import { CalendarIcon, GearIcon, OrganizationIcon, PeopleIcon, PlusIcon } from "@/components/icons";
53 import { OrgInvitations } from "@/components/org-invitations";
6- import { RepoList } from "@/components/repo-list";
7- import { Avatar, Blankslate, Box, ButtonLink, Counter, Label } from "@/components/ui";
4+ import { matches, ProfileTabs, type ProfileTab } from "@/components/profile-tabs";
5+ import { Avatar, avatarUrl, Box, ButtonLink, Counter, Label } from "@/components/ui";
86 import { formatDate } from "@/lib/format";
9- import { withActivity } from "@/lib/listing";
7+ import { listListsOf } from "@/lib/lists";
108 import { listInvitationsFor, listOrgMembers, orgRole, ROLE_INFO, roleAtLeast } from "@/lib/orgs";
119 import { listReposByOwner } from "@/lib/repos";
1210 import type { User } from "@/lib/users";
11+ import { ListsTab, RepositoriesTab } from "./tabs";
1312
1413 const heading = "text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink";
1514
16- /** An organization's page: its repositories the viewer can see and, for its members, its people. */
17- export async function OrgProfile({ org, viewer, q }: { org: User; viewer: User | null; q: string }) {
18- const [role, invitations, all] = await Promise.all([
19- orgRole(org.id, viewer?.id),
15+ /** An organization's page: its repositories and lists the viewer can see and, for its members, its people. */
16+ export async function OrgProfile({ org, viewer, q, tab: wanted }: { org: User; viewer: User | null; q: string; tab: ProfileTab }) {
17+ const viewerId = viewer?.id ?? null;
18+ const [role, invitations, all, lists] = await Promise.all([
19+ orgRole(org.id, viewerId),
2020 viewer ? listInvitationsFor(viewer.id) : [],
21- listReposByOwner(org.id, viewer?.id ?? null).then((repos) => withActivity(repos, viewer?.id)),
21+ listReposByOwner(org.id, viewerId),
22+ listListsOf(org, viewerId),
2223 ]);
24+ // Organizations don't like repositories; only people do.
25+ const tab = wanted === "likes" ? "repositories" : wanted;
2326 // Who belongs to an organization is for its members to see.
2427 const members = role ? await listOrgMembers(org.id) : [];
2528 const invitation = invitations.filter((i) => i.org.id === org.id);
26- const repos = q ? all.filter((r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)) : all;
2729 const publicCount = all.filter((r) => r.visibility === "public").length;
2830
2931 return (
3032 <div className="mx-auto grid w-full max-w-[1280px] gap-8 px-4 py-10 md:grid-cols-[300px_1fr] md:px-6">
3133 <aside className="flex flex-col gap-4">
3234 <Box className="p-6 md:sticky md:top-20">
33- <Avatar name={org.name || org.username} size={88} />
35+ <Avatar name={org.name || org.username} src={avatarUrl(org)} size={88} />
3436 <h1 className="mt-5">
3537 {org.name && <span className={`block ${heading}`}>{org.name}</span>}
3638 <span className={`block ${org.name ? "mt-1 type-readout text-ink3" : heading}`}>@{org.username}</span>
@@ -66,35 +68,22 @@ export async function OrgProfile({ org, viewer, q }: { org: User; viewer: User |
6668 <main className="flex min-w-0 flex-col gap-8">
6769 {invitation.length > 0 && <OrgInvitations invitations={invitation} />}
6870
69- <section>
70- <div className="mb-5 flex flex-wrap items-center gap-3">
71- <h2 className="flex items-center gap-2 type-display text-ink">
72- <RepoIcon className="text-ink2" /> Repositories <Counter>{all.length}</Counter>
73- </h2>
74- <div className="ml-auto flex w-full items-center gap-2 sm:w-auto">
75- <form role="search" className="flex-1 sm:w-72">
76- <Field size="regular">
77- <FieldIcon>
78- <SearchIcon />
79- </FieldIcon>
80- <FieldInput name="q" defaultValue={q} placeholder="Find a repository…" aria-label="Find a repository" />
81- </Field>
82- </form>
83- {roleAtLeast(role, "admin") && (
84- <ButtonLink href={`/new?owner=${org.username}`} variant="primary" icon={<PlusIcon />}>
85- New
86- </ButtonLink>
87- )}
88- </div>
89- </div>
90- {repos.length > 0 ? (
91- <RepoList repos={repos} showOwner={false} />
92- ) : (
93- <Box>
94- <Blankslate icon={<RepoIcon />} title={q ? "No matching repositories" : `${org.username} doesn’t have any repositories yet.`} />
95- </Box>
71+ <ProfileTabs
72+ username={org.username}
73+ tab={tab}
74+ counts={{ repositories: all.length, lists: lists.length }}
75+ q={q}
76+ newHref={
77+ tab === "lists"
78+ ? roleAtLeast(role, "write") && `/lists/new?owner=${org.username}`
79+ : roleAtLeast(role, "admin") && `/new?owner=${org.username}`
80+ }
81+ >
82+ {tab === "repositories" && (
83+ <RepositoriesTab repos={all.filter((r) => matches(q, r))} viewerId={viewerId} empty={q ? "No matching repositories" : `${org.username} doesn’t have any repositories yet.`} />
9684 )}
97- </section>
85+ {tab === "lists" && <ListsTab lists={lists.filter((l) => matches(q, l))} viewerId={viewerId} empty={q ? "No matching lists" : `${org.username} doesn’t have any lists yet.`} />}
86+ </ProfileTabs>
9887
9988 {role && (
10089 <section>
+22−37apps/web/app/[owner]/page.tsxView File
@@ -1,15 +1,14 @@
11 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";
2+ import { CalendarIcon, PencilIcon } from "@/components/icons";
3+ import { matches, profileTab, ProfileTabs } from "@/components/profile-tabs";
4+ import { Avatar, Box, ButtonLink } from "@/components/ui";
75 import { getViewer } from "@/lib/auth";
86 import { formatDate } from "@/lib/format";
9- import { withActivity } from "@/lib/listing";
7+ import { countLikedRepos, listListsOf } from "@/lib/lists";
108 import { listReposByOwner } from "@/lib/repos";
119 import { getUserByUsername } from "@/lib/users";
1210 import { OrgProfile } from "./org-profile";
11+ import { LikedTab, ListsTab, RepositoriesTab } from "./tabs";
1312
1413 export async function generateMetadata({ params }: PageProps<"/[owner]">) {
1514 const user = await getUserByUsername((await params).owner);
@@ -21,11 +20,13 @@ const heading = "text-[22px] leading-7 font-semibold tracking-[-0.025em] text-in
2120 export default async function ProfilePage({ params, searchParams }: PageProps<"/[owner]">) {
2221 const user = await getUserByUsername((await params).owner);
2322 if (!user) notFound();
24- const q = String((await searchParams).q ?? "").trim().toLowerCase();
23+ const sp = await searchParams;
24+ const q = String(sp.q ?? "").trim().toLowerCase();
25+ const tab = profileTab(sp.tab);
2526 const viewer = await getViewer();
26- if (user.isOrg) return <OrgProfile org={user} viewer={viewer} q={q} />;
27- const all = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null), viewer?.id);
28- const repos = q ? all.filter((r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)) : all;
27+ if (user.isOrg) return <OrgProfile org={user} viewer={viewer} q={q} tab={tab} />;
28+ const viewerId = viewer?.id ?? null;
29+ const [all, lists, likedCount] = await Promise.all([listReposByOwner(user.id, viewerId), listListsOf(user, viewerId), countLikedRepos(user.id, viewerId)]);
2930 const isSelf = viewer?.id === user.id;
3031 const publicCount = all.filter((r) => r.visibility === "public").length;
3132
@@ -61,33 +62,17 @@ export default async function ProfilePage({ params, searchParams }: PageProps<"/
6162 </aside>
6263
6364 <main className="min-w-0">
64- <div className="mb-5 flex flex-wrap items-center gap-3">
65- <h2 className="flex items-center gap-2 type-display text-ink">
66- <RepoIcon className="text-ink2" /> Repositories <Counter>{all.length}</Counter>
67- </h2>
68- <div className="ml-auto flex w-full items-center gap-2 sm:w-auto">
69- <form role="search" className="flex-1 sm:w-72">
70- <Field size="regular">
71- <FieldIcon>
72- <SearchIcon />
73- </FieldIcon>
74- <FieldInput name="q" defaultValue={q} placeholder="Find a repository…" aria-label="Find a repository" />
75- </Field>
76- </form>
77- {isSelf && (
78- <ButtonLink href="/new" variant="primary" icon={<PlusIcon />}>
79- New
80- </ButtonLink>
81- )}
82- </div>
83- </div>
84- {repos.length > 0 ? (
85- <RepoList repos={repos} showOwner={false} />
86- ) : (
87- <Box>
88- <Blankslate icon={<RepoIcon />} title={q ? "No matching repositories" : `${user.username} doesn’t have any repositories yet.`} />
89- </Box>
90- )}
65+ <ProfileTabs
66+ username={user.username}
67+ tab={tab}
68+ counts={{ repositories: all.length, lists: lists.length, likes: likedCount }}
69+ q={q}
70+ newHref={isSelf && tab !== "likes" ? (tab === "lists" ? "/lists/new" : "/new") : null}
71+ >
72+ {tab === "repositories" && <RepositoriesTab repos={all.filter((r) => matches(q, r))} viewerId={viewerId} empty={q ? "No matching repositories" : `${user.username} doesn’t have any repositories yet.`} />}
73+ {tab === "lists" && <ListsTab lists={lists.filter((l) => matches(q, l))} viewerId={viewerId} empty={q ? "No matching lists" : isSelf ? "You haven’t made any lists yet." : `${user.username} doesn’t have any lists yet.`} />}
74+ {tab === "likes" && <LikedTab userId={user.id} viewerId={viewerId} q={q} empty={q ? "No matching repositories" : isSelf ? "You haven’t liked any repositories yet." : `${user.username} hasn’t liked any repositories yet.`} />}
75+ </ProfileTabs>
9176 </main>
9277 </div>
9378 );
+38−0apps/web/app/[owner]/tabs.tsxaddedView File
@@ -0,0 +1,38 @@
1+ import type { ReactNode } from "react";
2+ import { BookmarkIcon, HeartIcon, RepoIcon } from "@/components/icons";
3+ import { ListCards } from "@/components/list-cards";
4+ import { matches } from "@/components/profile-tabs";
5+ import { RepoList } from "@/components/repo-list";
6+ import { Blankslate, Box } from "@/components/ui";
7+ import { withActivity } from "@/lib/listing";
8+ import { listLikedRepos, withRepoCounts, type RepoList as List } from "@/lib/lists";
9+ import type { RepoRecord } from "@/lib/repos";
10+
11+ // What a profile's tabs show (see components/profile-tabs.tsx), for people and organizations.
12+
13+ type Tab = { viewerId: string | null; empty: string };
14+
15+ export async function RepositoriesTab({ repos, viewerId, empty }: Tab & { repos: RepoRecord[] }) {
16+ if (!repos.length) return <Empty icon={<RepoIcon />} title={empty} />;
17+ return <RepoList repos={await withActivity(repos, viewerId)} showOwner={false} />;
18+ }
19+
20+ export async function ListsTab({ lists, viewerId, empty }: Tab & { lists: List[] }) {
21+ if (!lists.length) return <Empty icon={<BookmarkIcon />} title={empty} />;
22+ return <ListCards lists={await withRepoCounts(lists, viewerId)} />;
23+ }
24+
25+ /** The repositories a person likes, the latest liked first. */
26+ export async function LikedTab({ userId, viewerId, q, empty }: Tab & { userId: string; q: string }) {
27+ const repos = (await listLikedRepos(userId, viewerId)).filter((r) => matches(q, r));
28+ if (!repos.length) return <Empty icon={<HeartIcon />} title={empty} />;
29+ return <RepoList repos={await withActivity(repos, viewerId)} />;
30+ }
31+
32+ function Empty({ icon, title }: { icon: ReactNode; title: string }) {
33+ return (
34+ <Box>
35+ <Blankslate icon={icon} title={title} />
36+ </Box>
37+ );
38+ }
+120−0apps/web/app/actions/lists.tsaddedView File
@@ -0,0 +1,120 @@
1+ "use server";
2+
3+ import { revalidatePath } from "next/cache";
4+ import { redirect } from "next/navigation";
5+ import { createList, deleteList, findList, findListById, ListError, listPath, mayManageLists, setInList, setLiked, updateList, type RepoList } from "@/lib/lists";
6+ import { findRepoById, repoAccess } from "@/lib/repos";
7+ import { getUserByUsername } from "@/lib/users";
8+ import { ActionError, repoForAction, viewerOrThrow } from "./guard";
9+ import type { FormState } from "./types";
10+
11+ // Anyone signed in likes the repositories they can read, and saves them to their own lists and to
12+ // those of organizations they can push in.
13+
14+ async function attempt(fn: () => Promise<FormState | void>): Promise<FormState> {
15+ try {
16+ return (await fn()) ?? undefined;
17+ } catch (e) {
18+ if (e instanceof ListError || e instanceof ActionError) return { error: e.message };
19+ throw e;
20+ }
21+ }
22+
23+ function listInput(formData: FormData) {
24+ return {
25+ name: String(formData.get("name") ?? ""),
26+ description: String(formData.get("description") ?? ""),
27+ visibility: formData.get("visibility") === "private" ? ("private" as const) : ("public" as const),
28+ };
29+ }
30+
31+ /** A list the viewer may change. */
32+ async function listForAction(list: RepoList | null) {
33+ const viewer = await viewerOrThrow();
34+ if (!list || !(await mayManageLists(list.owner, viewer.id))) throw new ActionError("List not found.");
35+ return { list, viewer };
36+ }
37+
38+ /** A repository the viewer can read, by id. */
39+ async function readableRepo(repoId: string, viewerId: string) {
40+ const repo = await findRepoById(String(repoId));
41+ if (!repo || !(await repoAccess(repo, viewerId))) throw new ActionError("Repository not found.");
42+ return repo;
43+ }
44+
45+ const refreshList = (list: RepoList) => {
46+ revalidatePath(listPath(list));
47+ revalidatePath(`/${list.owner.username}`);
48+ };
49+
50+ export async function setLikedAction(owner: string, name: string, liked: boolean): Promise<FormState> {
51+ return attempt(async () => {
52+ const { repo, viewer } = await repoForAction(owner, name, "read");
53+ await setLiked(repo, viewer, liked === true);
54+ revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
55+ revalidatePath(`/${viewer.username}`);
56+ });
57+ }
58+
59+ /** Puts a repository in one of the viewer's lists (or an organization's), or takes it out. */
60+ export async function setSavedAction(listId: string, repoId: string, saved: boolean): Promise<FormState> {
61+ return attempt(async () => {
62+ const { list, viewer } = await listForAction(await findListById(String(listId)));
63+ const repo = await readableRepo(repoId, viewer.id);
64+ await setInList(list, repo.id, saved === true);
65+ refreshList(list);
66+ revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
67+ return { success: saved ? `Saved to ${list.name}.` : `Removed from ${list.name}.` };
68+ });
69+ }
70+
71+ /**
72+ * Creates a list. From a repository's Save menu (with `repo`, a repository id) the repository goes
73+ * in it at once and the menu stays open; otherwise the new list's page opens.
74+ */
75+ export async function createListAction(_prev: FormState, formData: FormData): Promise<FormState> {
76+ let path = "";
77+ const repoId = String(formData.get("repo") ?? "");
78+ const result = await attempt(async () => {
79+ const viewer = await viewerOrThrow();
80+ const owner = await getUserByUsername(String(formData.get("owner") ?? viewer.username));
81+ if (!owner || !(await mayManageLists(owner, viewer.id))) throw new ActionError("You can’t make lists for that account.");
82+ const repo = repoId ? await readableRepo(repoId, viewer.id) : null;
83+ const list = await createList(owner, listInput(formData));
84+ if (repo) {
85+ await setInList(list, repo.id, true);
86+ revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
87+ }
88+ refreshList(list);
89+ path = listPath(list);
90+ return repo ? { success: `Saved to ${list.name}.` } : undefined;
91+ });
92+ if (result?.error || repoId) return result;
93+ redirect(path);
94+ }
95+
96+ export async function updateListAction(owner: string, slug: string, _prev: FormState, formData: FormData): Promise<FormState> {
97+ let path = "";
98+ const result = await attempt(async () => {
99+ const { list } = await listForAction(await findList(owner, slug));
100+ const updated = await updateList(list, listInput(formData));
101+ refreshList(list);
102+ refreshList(updated);
103+ path = listPath(updated);
104+ });
105+ if (result?.error) return result;
106+ redirect(path);
107+ }
108+
109+ export async function deleteListAction(owner: string, slug: string, _prev: FormState, formData: FormData): Promise<FormState> {
110+ let path = "";
111+ const result = await attempt(async () => {
112+ const { list } = await listForAction(await findList(owner, slug));
113+ if (formData.get("confirm") !== list.name) return { error: `Type ${list.name} to confirm.` };
114+ await deleteList(list);
115+ refreshList(list);
116+ path = `/${list.owner.username}?tab=lists`;
117+ });
118+ if (result?.error) return result;
119+ redirect(path);
120+ }
+20−0apps/web/app/actions/orgs.tsView File
@@ -12,8 +12,10 @@ import {
1212 isOrgRole,
1313 OrgError,
1414 removeMember,
15+ removeOrgAvatar,
1516 ROLE_INFO,
1617 setMemberRole,
18+ setOrgAvatar,
1719 updateOrgProfile,
1820 } from "@/lib/orgs";
1921 import { ActionError, orgForAction, viewerOrThrow } from "./guard";
@@ -65,6 +67,24 @@ export async function updateOrgAction(name: string, _prev: FormState, formData:
6567 });
6668 }
6769
70+ export async function updateOrgAvatarAction(name: string, _prev: FormState, formData: FormData) {
71+ return attempt(async () => {
72+ const { org } = await orgForAction(name, "owner");
73+ const file = formData.get("image");
74+ if (!(file instanceof File)) throw new OrgError("Choose an image to upload.");
75+ await setOrgAvatar(org.id, new Uint8Array(await file.arrayBuffer()));
76+ // The avatar shows up in sidebars and lists everywhere, beside the organization's repositories too.
77+ refresh();
78+ return { success: "Avatar updated." };
79+ });
80+ }
81+
82+ export async function removeOrgAvatarAction(name: string) {
83+ const { org } = await orgForAction(name, "owner");
84+ await removeOrgAvatar(org.id);
85+ refresh();
86+ }
87+
6888 export async function deleteOrgAction(name: string, _prev: FormState, formData: FormData) {
6989 const result = await attempt(async () => {
7090 const { org } = await orgForAction(name, "owner");
+17−0apps/web/app/actions/repo.tsView File
@@ -12,6 +12,7 @@ import {
1212 RepoError,
1313 setRepoImage,
1414 setWikiEnabled,
15+ transferRepo,
1516 updateRepo,
1617 type Visibility,
1718 } from "@/lib/repos";
@@ -104,6 +105,22 @@ export async function renameRepoAction(owner: string, name: string, _prev: FormS
104105 redirect(path);
105106 }
106107
108+ /** Moves the repository to an account the viewer may create repositories in: theirs, or an organization they're an admin of. */
109+ export async function transferRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) {
110+ let path = "";
111+ const result = await attempt(async () => {
112+ const { repo, viewer } = await repoForAction(owner, name, "admin");
113+ const ownerName = String(formData.get("owner") ?? "");
114+ const newOwner = ownerName.toLowerCase() === viewer.username.toLowerCase() ? viewer : await getOrg(ownerName);
115+ if (!newOwner || !(await mayCreateRepos(newOwner, viewer))) throw new ActionError(`You can't move repositories to ${ownerName}.`);
116+ const moved = await transferRepo(repo, newOwner);
117+ revalidatePath("/", "layout");
118+ path = `/${moved.owner}/${moved.name}/settings`;
119+ });
120+ if (result?.error) return result;
121+ redirect(path);
122+ }
123+
107124 export async function deleteRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) {
108125 const result = await attempt(async () => {
109126 const { repo } = await repoForAction(owner, name, "admin");
+40−0apps/web/app/lists/[owner]/[slug]/edit/page.tsxaddedView File
@@ -0,0 +1,40 @@
1+ import { forbidden } from "next/navigation";
2+ import { deleteListAction, updateListAction } from "@/app/actions/lists";
3+ import { PencilIcon } from "@/components/icons";
4+ import { DeleteListZone, ListForm } from "@/components/list-form";
5+ import { PlateSection } from "@/components/settings-plate";
6+ import { getList } from "../load";
7+
8+ export async function generateMetadata({ params }: PageProps<"/lists/[owner]/[slug]/edit">) {
9+ const { owner, slug } = await params;
10+ const { list } = await getList(owner, slug);
11+ return { title: `Edit ${list.name}` };
12+ }
13+
14+ export default async function EditListPage({ params }: PageProps<"/lists/[owner]/[slug]/edit">) {
15+ const { owner, slug } = await params;
16+ const { list, canManage } = await getList(owner, slug);
17+ if (!canManage) forbidden();
18+ return (
19+ <main className="mx-auto flex w-full max-w-2xl flex-col gap-8 px-4 py-12">
20+ <div className="flex items-center gap-4">
21+ <span className="icon-tile size-12 shrink-0 rounded-plate [&_svg]:size-[22px]">
22+ <PencilIcon />
23+ </span>
24+ <div>
25+ <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Edit {list.name}</h1>
26+ <p className="mt-1 type-lead text-ink2">A list of {list.owner.isOrg ? `the organization ${list.owner.username}` : list.owner.username}.</p>
27+ </div>
28+ </div>
29+ <ListForm
30+ action={updateListAction.bind(null, list.owner.username, list.slug)}
31+ owners={[list.owner.username]}
32+ owner={list.owner.username}
33+ list={{ name: list.name, description: list.description, visibility: list.visibility }}
34+ />
35+ <PlateSection title="Danger zone">
36+ <DeleteListZone action={deleteListAction.bind(null, list.owner.username, list.slug)} name={list.name} />
37+ </PlateSection>
38+ </main>
39+ );
40+ }
+13−0apps/web/app/lists/[owner]/[slug]/load.tsaddedView File
@@ -0,0 +1,13 @@
1+ import "server-only";
2+ import { cache } from "react";
3+ import { notFound } from "next/navigation";
4+ import { getViewer } from "@/lib/auth";
5+ import { findList, mayManageLists, maySeeList } from "@/lib/lists";
6+
7+ /** Loads a list for a page. Lists the viewer can't see render the 404 page, like private repositories. */
8+ export const getList = cache(async (owner: string, slug: string) => {
9+ const list = await findList(owner, slug);
10+ const viewer = await getViewer();
11+ if (!list || !(await maySeeList(list, viewer?.id))) notFound();
12+ return { list, viewer, canManage: await mayManageLists(list.owner, viewer?.id) };
13+ });
+73−0apps/web/app/lists/[owner]/[slug]/page.tsxaddedView File
@@ -0,0 +1,73 @@
1+ import Link from "next/link";
2+ import { setSavedAction } from "@/app/actions/lists";
3+ import { ActionButton } from "@/components/action-button";
4+ import { BookmarkIcon, CalendarIcon, PencilIcon, XIcon } from "@/components/icons";
5+ import { RepoList } from "@/components/repo-list";
6+ import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, Counter, VisibilityLabel } from "@/components/ui";
7+ import { formatDate } from "@/lib/format";
8+ import { withActivity } from "@/lib/listing";
9+ import { listReposInList } from "@/lib/lists";
10+ import { getList } from "./load";
11+
12+ export async function generateMetadata({ params }: PageProps<"/lists/[owner]/[slug]">) {
13+ const { owner, slug } = await params;
14+ const { list } = await getList(owner, slug);
15+ return { title: `${list.name} · ${list.owner.username}`, description: list.description || undefined };
16+ }
17+
18+ export default async function ListPage({ params }: PageProps<"/lists/[owner]/[slug]">) {
19+ const { owner, slug } = await params;
20+ const { list, viewer, canManage } = await getList(owner, slug);
21+ const repos = await withActivity(await listReposInList(list.id, viewer?.id ?? null), viewer?.id);
22+
23+ return (
24+ <main className="mx-auto w-full max-w-5xl px-4 py-10 md:px-6">
25+ <div className="mb-8 flex flex-wrap items-start gap-4">
26+ <span className="icon-tile size-12 shrink-0 rounded-plate [&_svg]:size-[22px]">
27+ <BookmarkIcon />
28+ </span>
29+ <div className="min-w-0 flex-1">
30+ <Link href={`/${list.owner.username}?tab=lists`} className="inline-flex items-center gap-2 type-ui text-ink2 transition-colors hover:text-ink">
31+ <Avatar name={list.owner.name || list.owner.username} src={avatarUrl(list.owner)} size={18} /> {list.owner.username}
32+ </Link>
33+ <h1 className="mt-1 flex flex-wrap items-center gap-2.5 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">
34+ {list.name} <VisibilityLabel visibility={list.visibility} />
35+ </h1>
36+ {list.description && <p className="mt-1.5 max-w-2xl type-lead text-ink2">{list.description}</p>}
37+ <p className="mt-2 flex items-center gap-2 type-meta text-ink3">
38+ <CalendarIcon width={14} height={14} /> Created {formatDate(list.createdAt)}
39+ </p>
40+ </div>
41+ {canManage && (
42+ <ButtonLink href={`/lists/${list.owner.username}/${list.slug}/edit`} icon={<PencilIcon />}>
43+ Edit List
44+ </ButtonLink>
45+ )}
46+ </div>
47+
48+ <h2 className="mb-4 flex items-center gap-2 type-display text-ink">
49+ Repositories <Counter>{repos.length}</Counter>
50+ </h2>
51+ {repos.length > 0 ? (
52+ <RepoList
53+ repos={repos}
54+ actions={
55+ canManage
56+ ? (r) => (
57+ <ActionButton action={setSavedAction.bind(null, list.id, r.id, false)} variant="invisible" icon={<XIcon />}>
58+ Remove
59+ </ActionButton>
60+ )
61+ : undefined
62+ }
63+ />
64+ ) : (
65+ <Box>
66+ <Blankslate icon={<BookmarkIcon />} title="This list is empty">
67+ {canManage ? "Open a repository and use its Save key to put it in this list." : "Nothing has been saved to it yet."}
68+ </Blankslate>
69+ </Box>
70+ )}
71+ </main>
72+ );
73+ }
+28−0apps/web/app/lists/new/page.tsxaddedView File
@@ -0,0 +1,28 @@
1+ import type { Metadata } from "next";
2+ import { createListAction } from "@/app/actions/lists";
3+ import { BookmarkIcon } from "@/components/icons";
4+ import { ListForm } from "@/components/list-form";
5+ import { requireViewer } from "@/lib/auth";
6+ import { listOwnersFor } from "@/lib/lists";
7+
8+ export const metadata: Metadata = { title: "New list" };
9+
10+ export default async function NewListPage({ searchParams }: PageProps<"/lists/new">) {
11+ const viewer = await requireViewer("/lists/new");
12+ const owners = (await listOwnersFor(viewer)).map((o) => o.username);
13+ const wanted = String((await searchParams).owner ?? "").toLowerCase();
14+ return (
15+ <main className="mx-auto w-full max-w-2xl px-4 py-12">
16+ <div className="mb-8 flex items-center gap-4">
17+ <span className="icon-tile size-12 shrink-0 rounded-plate [&_svg]:size-[22px]">
18+ <BookmarkIcon />
19+ </span>
20+ <div>
21+ <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Create a list</h1>
22+ <p className="mt-1 type-lead text-ink2">Gather repositories to find them again or share them. Add one with the Save key on its page.</p>
23+ </div>
24+ </div>
25+ <ListForm action={createListAction} owners={owners} owner={owners.find((o) => o.toLowerCase() === wanted) ?? viewer.username} />
26+ </main>
27+ );
28+ }
+11−0apps/web/app/organizations/[org]/avatar/route.tsaddedView File
@@ -0,0 +1,11 @@
1+ import { getOrg, getOrgAvatar } from "@/lib/orgs";
2+ import { imageResponse } from "@/lib/uploaded-images";
3+
4+ /** The organization's uploaded avatar. URLs carry `?v=<upload time>`, so responses can be cached for good. */
5+ export async function GET(_request: Request, ctx: RouteContext<"/organizations/[org]/avatar">) {
6+ const org = await getOrg((await ctx.params).org);
7+ const image = org ? await getOrgAvatar(org.id) : null;
8+ if (!image) return new Response("Not found", { status: 404 });
9+ // Organizations and their profiles are public.
10+ return imageResponse(image, "public");
11+ }
+25−2apps/web/app/organizations/[org]/settings/forms.tsxView File
@@ -5,9 +5,10 @@ import { FormField, Select, useToast } from "@unlocalhosted/metalui";
55 import type { FormState } from "@/app/actions/types";
66 import { DeleteZone } from "@/components/delete-zone";
77 import { PersonIcon, PlusIcon } from "@/components/icons";
8+ import { ImageUploadForm } from "@/components/image-upload-form";
89 import { ChoiceCards, TextareaField, TextField } from "@/components/settings-fields";
910 import { PlateBody, PlateFooter } from "@/components/settings-plate";
10- import { Alert, Button } from "@/components/ui";
11+ import { Alert, Avatar, avatarUrl, Button } from "@/components/ui";
1112 import { ORG_ROLES, ROLE_INFO, type OrgRole } from "@/lib/org-roles";
1213
1314 type Action = (prev: FormState, formData: FormData) => Promise<FormState>;
@@ -34,6 +35,28 @@ export function OrgProfileForm({ action, org }: { action: Action; org: { usernam
3435 );
3536 }
3637
38+ export function OrgAvatarForm({
39+ action,
40+ resetAction,
41+ org,
42+ }: {
43+ action: Action;
44+ resetAction: () => Promise<void>;
45+ org: { username: string; name: string; avatarVersion: number | null };
46+ }) {
47+ return (
48+ <ImageUploadForm
49+ action={action}
50+ resetAction={resetAction}
51+ hasImage={!!org.avatarVersion}
52+ noun="Avatar"
53+ resetLabel="Remove Avatar"
54+ description="Shown on the organization’s profile and next to it everywhere, and on its repositories that have no image of their own. Until you upload one, it’s the organization’s initials."
55+ renderImage={(src) => <Avatar name={org.name || org.username} src={src ?? avatarUrl(org)} size={72} />}
56+ />
57+ );
58+ }
59+
3760 export function DeleteOrgZone({ action, name }: { action: Action; name: string }) {
3861 return (
3962 <DeleteZone
@@ -43,7 +66,7 @@ export function DeleteOrgZone({ action, name }: { action: Action; name: string }
4366 description="Only an organization without repositories can be deleted. Delete its repositories first."
4467 consequence={
4568 <>
46- This will permanently delete <strong>{name}</strong>, its members and pending invitations. The name becomes free for anyone to take.
69+ This will permanently delete <strong>{name}</strong>, its members, lists and pending invitations. The name becomes free for anyone to take.
4770 </>
4871 }
4972 openLabel="Delete Organization"
+2−2apps/web/app/organizations/[org]/settings/layout.tsxView File
@@ -1,7 +1,7 @@
11 import Link from "next/link";
22 import { GearIcon, PeopleIcon } from "@/components/icons";
33 import { SettingsNav } from "@/components/settings-nav";
4- import { Avatar } from "@/components/ui";
4+ import { Avatar, avatarUrl } from "@/components/ui";
55 import { getOwnedOrg } from "./load";
66
77 export default async function OrgSettingsLayout({ params, children }: LayoutProps<"/organizations/[org]/settings">) {
@@ -10,7 +10,7 @@ export default async function OrgSettingsLayout({ params, children }: LayoutProp
1010 return (
1111 <div className="mx-auto w-full max-w-[1100px] px-4 py-10 md:px-6">
1212 <div className="mb-8 flex items-center gap-4">
13- <Avatar name={org.name || org.username} size={52} />
13+ <Avatar name={org.name || org.username} src={avatarUrl(org)} size={52} />
1414 <div>
1515 <h1 className="text-2xl font-semibold tracking-[-0.03em]">
1616 <Link href={`/${org.username}`} className="hover:underline">
+9−2apps/web/app/organizations/[org]/settings/page.tsxView File
@@ -1,8 +1,8 @@
11 import type { Metadata } from "next";
2- import { deleteOrgAction, updateOrgAction } from "@/app/actions/orgs";
2+ import { deleteOrgAction, removeOrgAvatarAction, updateOrgAction, updateOrgAvatarAction } from "@/app/actions/orgs";
33 import { PlateSection } from "@/components/settings-plate";
44 import { PageTitle } from "@/components/ui";
5- import { DeleteOrgZone, OrgProfileForm } from "./forms";
5+ import { DeleteOrgZone, OrgAvatarForm, OrgProfileForm } from "./forms";
66 import { getOwnedOrg } from "./load";
77
88 export const metadata: Metadata = { title: "Organization settings" };
@@ -16,6 +16,13 @@ export default async function OrgSettingsPage({ params }: PageProps<"/organizati
1616 <PlateSection title="Profile">
1717 <OrgProfileForm action={updateOrgAction.bind(null, org.username)} org={{ username: org.username, name: org.name, bio: org.bio }} />
1818 </PlateSection>
19+ <PlateSection title="Avatar">
20+ <OrgAvatarForm
21+ action={updateOrgAvatarAction.bind(null, org.username)}
22+ resetAction={removeOrgAvatarAction.bind(null, org.username)}
23+ org={{ username: org.username, name: org.name, avatarVersion: org.avatarVersion }}
24+ />
25+ </PlateSection>
1926 <PlateSection title="Danger zone">
2027 <DeleteOrgZone action={deleteOrgAction.bind(null, org.username)} name={org.username} />
2128 </PlateSection>
+35−4apps/web/app/page.tsxView File
@@ -5,12 +5,14 @@ import { HomeTerminal } from "@/components/home-terminal";
55 import { OrgInvitations } from "@/components/org-invitations";
66 import {
77 ArrowRightIcon,
8+ BookmarkIcon,
89 BranchIcon,
910 CodeIcon,
1011 CommitIcon,
1112 CompassIcon,
1213 IssueOpenedIcon,
1314 KeyIcon,
15+ LockIcon,
1416 OrganizationIcon,
1517 PencilIcon,
1618 PeopleIcon,
@@ -20,11 +22,12 @@ import {
2022 } from "@/components/icons";
2123 import { CardDescription, CardTitle } from "@/components/metal";
2224 import { RepoFilterList } from "@/components/repo-filter-list";
23- import { Avatar, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvatar } from "@/components/ui";
25+ import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvatar } from "@/components/ui";
2426 import { VerifyEmailNotice } from "@/components/verify-email-notice";
2527 import { getViewer } from "@/lib/auth";
2628 import { shortSha, timeAgo } from "@/lib/format";
2729 import { recentActivity, withActivity } from "@/lib/listing";
30+ import { listListsOf, listPath } from "@/lib/lists";
2831 import { MAIL_ENABLED } from "@/lib/mail";
2932 import { listInvitationsFor, listOrgsOf } from "@/lib/orgs";
3033 import { listReposForUser, searchRepos } from "@/lib/repos";
@@ -129,11 +132,12 @@ function greeting() {
129132 const liftRow = "rounded-row transition-row outline-none hover:recipe-row-list-hover focus-visible:recipe-row-list-hover focus-visible:focus-ring-flush";
130133
131134 async function Dashboard({ viewer }: { viewer: User }) {
132- const [mine, recent, orgs, invitations] = await Promise.all([
135+ const [mine, recent, orgs, invitations, lists] = await Promise.all([
133136 listReposForUser(viewer.id),
134137 searchRepos("", viewer.id, 30),
135138 listOrgsOf(viewer.id),
136139 listInvitationsFor(viewer.id),
140+ listListsOf(viewer, viewer.id),
137141 ]);
138142 const [activity, explore] = await Promise.all([
139143 recentActivity(mine),
@@ -152,7 +156,7 @@ async function Dashboard({ viewer }: { viewer: User }) {
152156 New
153157 </ButtonLink>
154158 </div>
155- <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion }))} />
159+ <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion, ownerAvatarVersion: r.ownerAvatarVersion }))} />
156160 </Box>
157161 <Box className="p-4">
158162 <div className="mb-2 flex items-center justify-between gap-2">
@@ -168,13 +172,40 @@ async function Dashboard({ viewer }: { viewer: User }) {
168172 {orgs.map(({ org }) => (
169173 <li key={org.id}>
170174 <Link href={`/${org.username}`} className={`flex items-center gap-2.5 px-2 py-1.5 ${liftRow}`}>
171- <OrganizationIcon width={14} height={14} className="shrink-0 text-ink3" />
175+ {org.avatarVersion ? <Avatar name={org.username} src={avatarUrl(org)} size={16} /> : <OrganizationIcon width={14} height={14} className="shrink-0 text-ink3" />}
172176 <span className="truncate type-ui text-ink">{org.username}</span>
173177 </Link>
174178 </li>
175179 ))}
176180 </ul>
177181 </Box>
182+ <Box className="p-4">
183+ <div className="mb-2 flex items-center justify-between gap-2">
184+ <h2 className="flex items-center gap-2 type-label engraved">
185+ Your lists <Counter>{lists.length}</Counter>
186+ </h2>
187+ <ButtonLink href="/lists/new" variant="invisible" icon={<PlusIcon />}>
188+ New
189+ </ButtonLink>
190+ </div>
191+ {lists.length === 0 && <p className="type-body text-ink3">Save repositories to lists with the Save key on their page, to find them again or share them.</p>}
192+ <ul className="-mx-2 flex flex-col gap-0.5">
193+ {lists.slice(0, 8).map((list) => (
194+ <li key={list.id}>
195+ <Link href={listPath(list)} className={`flex items-center gap-2.5 px-2 py-1.5 ${liftRow}`}>
196+ <BookmarkIcon width={14} height={14} className="shrink-0 text-ink3" />
197+ <span className="min-w-0 flex-1 truncate type-ui text-ink">{list.name}</span>
198+ {list.visibility === "private" && <LockIcon width={12} height={12} className="shrink-0 text-ink3" aria-label="Private" />}
199+ </Link>
200+ </li>
201+ ))}
202+ </ul>
203+ {lists.length > 8 && (
204+ <ButtonLink href={`/${viewer.username}?tab=lists`} variant="invisible" icon={<ArrowRightIcon />} className="mt-1 -ml-2">
205+ All Lists
206+ </ButtonLink>
207+ )}
208+ </Box>
178209 </aside>
179210
180211 <main className="min-w-0">
+2−2apps/web/app/settings/organizations/page.tsxView File
@@ -7,7 +7,7 @@ import { GearIcon, OrganizationIcon, PlusIcon } from "@/components/icons";
77 import { SettingsSection } from "@/components/metal";
88 import { OrgInvitations } from "@/components/org-invitations";
99 import { ListRow } from "@/components/settings-plate";
10- import { Avatar, Blankslate, ButtonLink, Label, PageTitle } from "@/components/ui";
10+ import { Avatar, avatarUrl, Blankslate, ButtonLink, Label, PageTitle } from "@/components/ui";
1111 import { requireViewer } from "@/lib/auth";
1212 import { timeAgo } from "@/lib/format";
1313 import { listInvitationsFor, listOrgsOf, ROLE_INFO } from "@/lib/orgs";
@@ -39,7 +39,7 @@ export default async function OrganizationsSettingsPage() {
3939 {memberships.map(({ org, role, joinedAt }) => (
4040 <ListRow
4141 key={org.id}
42- lead={<Avatar name={org.name || org.username} size={32} />}
42+ lead={<Avatar name={org.name || org.username} src={avatarUrl(org)} size={32} />}
4343 title={
4444 <>
4545 <Link href={`/${org.username}`} className="truncate hover:underline">
+1−1apps/web/components/header.tsxView File
@@ -13,7 +13,7 @@ import { ButtonLink } from "./ui";
1313
1414 // The sidebar and the search box both list them; one query per request.
1515 const sidebarRepos = cache(async (viewerId: string) =>
16- (await listReposForUser(viewerId)).map((r) => ({ owner: r.owner, name: r.name, imageVersion: r.imageVersion })),
16+ (await listReposForUser(viewerId)).map((r) => ({ owner: r.owner, name: r.name, imageVersion: r.imageVersion, ownerAvatarVersion: r.ownerAvatarVersion })),
1717 );
1818
1919 const viewerOf = (viewer: User) => ({ username: viewer.username, name: viewer.name });
+4−0apps/web/components/icons.tsxView File
@@ -152,6 +152,10 @@ export const CommitIcon = icon(
152152 <path d="M3 12h5.5M15.5 12H21" />
153153 </>,
154154 );
155+ export const HeartIcon = icon(
156+ <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />,
157+ );
158+ export const BookmarkIcon = icon(<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />);
155159 export const BookIcon = icon(
156160 <>
157161 <path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z" />
+85−0apps/web/components/image-upload-form.tsxaddedView File
@@ -0,0 +1,85 @@
1+ "use client";
2+
3+ import { useActionState, useEffect, useState, type ReactNode } from "react";
4+ import type { FormState } from "@/app/actions/types";
5+ import { UndoIcon, UploadIcon } from "./icons";
6+ import { PlateBody } from "./settings-plate";
7+ import { Alert, Button, buttonClass } from "./ui";
8+
9+ /**
10+ * Uploads a picture (a repository's image, an organization's avatar) with a preview of the chosen
11+ * file before it's saved, and offers to remove the one there is.
12+ */
13+ export function ImageUploadForm({
14+ action,
15+ resetAction,
16+ hasImage,
17+ noun,
18+ resetLabel,
19+ description,
20+ renderImage,
21+ }: {
22+ action: (prev: FormState, formData: FormData) => Promise<FormState>;
23+ resetAction: () => Promise<void>;
24+ hasImage: boolean;
25+ /** "Image" or "Avatar", for the buttons. */
26+ noun: string;
27+ resetLabel: string;
28+ description: ReactNode;
29+ /** The picture as it's shown, or with `src` (the chosen file) instead. */
30+ renderImage: (src?: string) => ReactNode;
31+ }) {
32+ const [preview, setPreview] = useState<string | null>(null);
33+ const [state, formAction, pending] = useActionState(async (prev: FormState, formData: FormData) => {
34+ const result = await action(prev, formData);
35+ if (result?.success) setPreview(null);
36+ return result;
37+ }, undefined);
38+ useEffect(() => () => {
39+ if (preview) URL.revokeObjectURL(preview);
40+ }, [preview]);
41+
42+ return (
43+ <PlateBody>
44+ <div className="flex flex-wrap items-start gap-5">
45+ {renderImage(preview ?? undefined)}
46+ <div className="flex min-w-60 flex-1 flex-col gap-3">
47+ <p className="type-body text-ink2">{description} PNG, JPEG, GIF or WebP, up to 1&nbsp;MB.</p>
48+ <div className="flex flex-wrap items-center gap-2">
49+ <form action={formAction} className="flex items-center gap-2">
50+ {/* The file input stays a real input; its label is drawn as MetalUI's standard cap. */}
51+ <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")}>
52+ <UploadIcon /> {hasImage ? `Replace ${noun}` : `Upload ${noun}`}
53+ <input
54+ type="file"
55+ name="image"
56+ accept="image/png,image/jpeg,image/gif,image/webp"
57+ required
58+ className="sr-only"
59+ onChange={(e) => {
60+ const file = e.target.files?.[0];
61+ setPreview(file ? URL.createObjectURL(file) : null);
62+ }}
63+ />
64+ </label>
65+ {preview && (
66+ <Button variant="primary" disabled={pending}>
67+ {pending ? "Saving…" : `Save ${noun}`}
68+ </Button>
69+ )}
70+ </form>
71+ {hasImage && !preview && (
72+ <form action={resetAction}>
73+ <Button variant="invisible" icon={<UndoIcon />}>
74+ {resetLabel}
75+ </Button>
76+ </form>
77+ )}
78+ </div>
79+ {state?.error && <Alert>{state.error}</Alert>}
80+ {state?.success && !preview && <Alert kind="success">{state.success}</Alert>}
81+ </div>
82+ </div>
83+ </PlateBody>
84+ );
85+ }
+48−0apps/web/components/list-cards.tsxaddedView File
@@ -0,0 +1,48 @@
1+ import Link from "next/link";
2+ import { Card } from "@unlocalhosted/metalui";
3+ import { timeAgo } from "@/lib/format";
4+ import { listPath, type RepoList } from "@/lib/lists";
5+ import { BookmarkIcon, ClockIcon, RepoIcon } from "./icons";
6+ import { CardDescription, CardTitle } from "./metal";
7+ import { VisibilityLabel } from "./ui";
8+
9+ export type ListCardItem = RepoList & { repoCount: number };
10+
11+ /** Lists as cards, like RepoList: the title is the one link. */
12+ export function ListCards({ lists, showOwner = false }: { lists: ListCardItem[]; showOwner?: boolean }) {
13+ return (
14+ <ul className="grid gap-4 sm:grid-cols-2">
15+ {lists.map((l) => (
16+ <li key={l.id} className="flex">
17+ <Card className="w-full grid-rows-[auto_1fr_auto]">
18+ <div className="flex items-start gap-3">
19+ <span className="icon-tile size-10 shrink-0 rounded-[12px]">
20+ <BookmarkIcon width={18} height={18} />
21+ </span>
22+ <div className="min-w-0 flex-1">
23+ <CardTitle render={<Link href={listPath(l)} />} className="truncate">
24+ {showOwner && <span className="font-normal text-ink3">{l.owner.username} / </span>}
25+ {l.name}
26+ </CardTitle>
27+ <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
28+ <VisibilityLabel visibility={l.visibility} />
29+ </div>
30+ </div>
31+ </div>
32+ <CardDescription className={`mt-1.5 line-clamp-2 ${l.description ? "" : "text-ink3 italic"}`}>{l.description || "No description"}</CardDescription>
33+ <div className="rule-t mt-2 flex items-center gap-3 pt-2.5 type-readout text-ink3">
34+ <span className="flex items-center gap-1.5">
35+ <RepoIcon width={12} height={12} />
36+ {l.repoCount} {l.repoCount === 1 ? "repository" : "repositories"}
37+ </span>
38+ <span className="flex items-center gap-1.5">
39+ <ClockIcon width={12} height={12} />
40+ Updated {timeAgo(l.updatedAt)}
41+ </span>
42+ </div>
43+ </Card>
44+ </li>
45+ ))}
46+ </ul>
47+ );
48+ }
+92−0apps/web/components/list-form.tsxaddedView File
@@ -0,0 +1,92 @@
1+ "use client";
2+
3+ import { useActionState } from "react";
4+ import { Rule, Select } from "@unlocalhosted/metalui";
5+ import type { FormState } from "@/app/actions/types";
6+ import { DeleteZone } from "./delete-zone";
7+ import { BookmarkIcon, GlobeIcon, LockIcon, PlusIcon } from "./icons";
8+ import { ChoiceCards, TextareaField, TextField } from "./settings-fields";
9+ import { PlateBody, PlateFooter } from "./settings-plate";
10+ import { Alert, Avatar, Box, Button } from "./ui";
11+
12+ type Action = (prev: FormState, formData: FormData) => Promise<FormState>;
13+
14+ export type ListFields = { name: string; description: string; visibility: "public" | "private" };
15+
16+ /** A list's name, description and visibility; new lists also choose their owner (you, or an organization). */
17+ export function ListForm({ action, owners, owner, list }: { action: Action; owners: string[]; owner: string; list?: ListFields }) {
18+ const [state, formAction, pending] = useActionState(action, undefined);
19+ return (
20+ <form action={formAction}>
21+ <Box>
22+ <PlateBody>
23+ {!list && (
24+ <div className="flex flex-col gap-1.5">
25+ <span className="type-ui text-ink">Owner</span>
26+ {owners.length > 1 ? (
27+ <Select name="owner" aria-label="Owner" defaultValue={owner} options={owners.map((o) => ({ value: o, label: o }))} className="self-start min-w-36" />
28+ ) : (
29+ <span className="flex h-8 items-center gap-2 self-start rounded-[11px] recipe-well-field pr-3.5 pl-2 type-ui text-ink2">
30+ <Avatar name={owner} size={20} /> {owner}
31+ <input type="hidden" name="owner" value={owner} />
32+ </span>
33+ )}
34+ </div>
35+ )}
36+ <TextField
37+ label="Name"
38+ description={list ? "Renaming the list changes its address." : "Its address is made from the name, e.g. /lists/alice/tools-i-use."}
39+ name="name"
40+ required
41+ autoFocus={!list}
42+ maxLength={60}
43+ defaultValue={list?.name}
44+ placeholder="Tools I use"
45+ />
46+ <TextareaField label="Description" optional name="description" minRows={2} maxLength={300} defaultValue={list?.description} placeholder="What do these repositories have in common?" />
47+ </PlateBody>
48+ <Rule className="mx-6" />
49+ <PlateBody>
50+ <div className="flex flex-col gap-1.5">
51+ <span id="list-visibility" className="type-ui text-ink">
52+ Visibility
53+ </span>
54+ <ChoiceCards
55+ name="visibility"
56+ aria-labelledby="list-visibility"
57+ defaultValue={list?.visibility ?? "public"}
58+ choices={[
59+ { value: "public", icon: <GlobeIcon />, title: "Public", description: "Anyone can see the list. Private repositories in it stay hidden from those who can’t see them." },
60+ { value: "private", icon: <LockIcon />, title: "Private", description: "Only you can see it, or for an organization’s list, its members." },
61+ ]}
62+ />
63+ </div>
64+ {state?.error && <Alert>{state.error}</Alert>}
65+ </PlateBody>
66+ <PlateFooter>
67+ <Button variant="primary" icon={list ? <BookmarkIcon /> : <PlusIcon />} disabled={pending}>
68+ {list ? (pending ? "Saving…" : "Save List") : pending ? "Creating…" : "Create List"}
69+ </Button>
70+ </PlateFooter>
71+ </Box>
72+ </form>
73+ );
74+ }
75+
76+ export function DeleteListZone({ action, name }: { action: Action; name: string }) {
77+ return (
78+ <DeleteZone
79+ action={action}
80+ confirmText={name}
81+ title="Delete this list"
82+ description="The repositories in it stay as they are; only the list goes."
83+ consequence={
84+ <>
85+ This will permanently delete the list <strong>{name}</strong>.
86+ </>
87+ }
88+ openLabel="Delete List"
89+ confirmLabel="I Understand, Delete This List"
90+ />
91+ );
92+ }
+2−2apps/web/components/org-invitations.tsxView File
@@ -6,7 +6,7 @@ import type { OrgInvitation } from "@/lib/orgs";
66 import { ActionButton } from "./action-button";
77 import { CheckIcon } from "./icons";
88 import { ListRow } from "./settings-plate";
9- import { Avatar, Box, Label } from "./ui";
9+ import { Avatar, avatarUrl, Box, Label } from "./ui";
1010
1111 /** Invitations to join organizations, for the person invited: accept (and land on the organization) or decline. */
1212 export function OrgInvitations({ invitations }: { invitations: OrgInvitation[] }) {
@@ -16,7 +16,7 @@ export function OrgInvitations({ invitations }: { invitations: OrgInvitation[] }
1616 {invitations.map((inv) => (
1717 <ListRow
1818 key={inv.id}
19- lead={<Avatar name={inv.org.name || inv.org.username} size={32} />}
19+ lead={<Avatar name={inv.org.name || inv.org.username} src={avatarUrl(inv.org)} size={32} />}
2020 title={
2121 <>
2222 <span>
+78−0apps/web/components/profile-tabs.tsxaddedView File
@@ -0,0 +1,78 @@
1+ import Link from "next/link";
2+ import type { ReactNode } from "react";
3+ import { Field } from "@unlocalhosted/metalui";
4+ import { BookmarkIcon, HeartIcon, PlusIcon, RepoIcon, SearchIcon } from "./icons";
5+ import { FieldIcon, FieldInput } from "./metal";
6+ import { ButtonLink, Counter, Segmented, segmentClass } from "./ui";
7+
8+ export type ProfileTab = "repositories" | "lists" | "likes";
9+
10+ export const profileTab = (value: unknown): ProfileTab => (value === "lists" || value === "likes" ? value : "repositories");
11+
12+ const TABS: Record<ProfileTab, { label: string; icon: ReactNode; find: string }> = {
13+ repositories: { label: "Repositories", icon: <RepoIcon />, find: "Find a repository…" },
14+ lists: { label: "Lists", icon: <BookmarkIcon />, find: "Find a list…" },
15+ likes: { label: "Liked", icon: <HeartIcon />, find: "Find a repository…" },
16+ };
17+
18+ /**
19+ * A profile's main column: its tabs (repositories, lists and, for people, the repositories they
20+ * like) with their counts, the search box, and a New key for what the tab shows.
21+ */
22+ export function ProfileTabs({
23+ username,
24+ tab,
25+ counts,
26+ q,
27+ newHref,
28+ children,
29+ }: {
30+ username: string;
31+ tab: ProfileTab;
32+ /** Tabs without a count aren't shown. */
33+ counts: Partial<Record<ProfileTab, number>>;
34+ q: string;
35+ newHref?: string | false | null;
36+ children: ReactNode;
37+ }) {
38+ const tabs = (Object.keys(TABS) as ProfileTab[]).filter((t) => counts[t] !== undefined);
39+ return (
40+ <section>
41+ <div className="mb-5 flex flex-wrap items-center gap-3">
42+ <Segmented className="max-w-full overflow-x-auto">
43+ {tabs.map((t) => (
44+ <Link
45+ key={t}
46+ href={t === "repositories" ? `/${username}` : `/${username}?tab=${t}`}
47+ className={segmentClass(t === tab)}
48+ aria-current={t === tab ? "page" : undefined}
49+ >
50+ {TABS[t].icon} {TABS[t].label} <Counter>{counts[t]}</Counter>
51+ </Link>
52+ ))}
53+ </Segmented>
54+ <div className="ml-auto flex w-full items-center gap-2 sm:w-auto">
55+ <form role="search" className="flex-1 sm:w-72">
56+ {tab !== "repositories" && <input type="hidden" name="tab" value={tab} />}
57+ <Field size="regular">
58+ <FieldIcon>
59+ <SearchIcon />
60+ </FieldIcon>
61+ <FieldInput name="q" defaultValue={q} placeholder={TABS[tab].find} aria-label={TABS[tab].find.slice(0, -1)} />
62+ </Field>
63+ </form>
64+ {newHref && (
65+ <ButtonLink href={newHref} variant="primary" icon={<PlusIcon />}>
66+ New
67+ </ButtonLink>
68+ )}
69+ </div>
70+ </div>
71+ {children}
72+ </section>
73+ );
74+ }
75+
76+ /** Whether a repository or list matches the profile's search. */
77+ export const matches = (q: string, item: { name: string; description: string }) =>
78+ !q || item.name.toLowerCase().includes(q) || item.description.toLowerCase().includes(q);
+2−2apps/web/components/repo-filter-list.tsxView File
@@ -4,9 +4,9 @@ import Link from "next/link";
44 import { useState } from "react";
55 import { Field } from "@unlocalhosted/metalui";
66 import { LockIcon, SearchIcon } from "./icons";
7- import { RepoAvatar } from "./ui";
7+ import { RepoAvatar, type RepoImageRef } from "./ui";
88
9- type Item = { owner: string; name: string; visibility: "public" | "private"; imageVersion: number | null };
9+ type Item = RepoImageRef & { visibility: "public" | "private" };
1010
1111 /** The viewer's repositories: a compact search well that filters as you type, then rows that lift on hover. */
1212 export function RepoFilterList({ repos }: { repos: Item[] }) {
+20−8apps/web/components/repo-list.tsxView File
@@ -1,16 +1,20 @@
11 import Link from "next/link";
22 import { Card } from "@unlocalhosted/metalui";
3+ import type { ReactNode } from "react";
34 import type { ForkParent, RepoRecord } from "@/lib/repos";
45 import { timeAgo } from "@/lib/format";
5- import { ClockIcon, LockIcon, RepoIcon } from "./icons";
6+ import { ClockIcon, HeartIcon, LockIcon, RepoIcon } from "./icons";
67 import { ForkedFrom, ForkLabel } from "./forks";
78 import { CardDescription, CardTitle } from "./metal";
89 import { RepoAvatar, VisibilityLabel } from "./ui";
910
10- export type RepoListItem = RepoRecord & { updatedAt: number; empty: boolean; parent: ForkParent | null };
11+ export type RepoListItem = RepoRecord & { updatedAt: number; empty: boolean; parent: ForkParent | null; likes: number };
1112
12- /** Repositories as MetalUI cards: the title is the card's one link, so the whole plate goes to the repository. */
13- export function RepoList({ repos, showOwner = true }: { repos: RepoListItem[]; showOwner?: boolean }) {
13+ /**
14+ * Repositories as MetalUI cards: the title is the card's one link, so the whole plate goes to the
15+ * repository. `actions` adds controls to a card's footer (they sit above the link).
16+ */
17+ export function RepoList({ repos, showOwner = true, actions }: { repos: RepoListItem[]; showOwner?: boolean; actions?: (repo: RepoListItem) => ReactNode }) {
1418 return (
1519 <ul className="grid gap-4 sm:grid-cols-2">
1620 {repos.map((r) => (
@@ -33,10 +37,18 @@ export function RepoList({ repos, showOwner = true }: { repos: RepoListItem[]; s
3337 <CardDescription className={`mt-1.5 line-clamp-2 ${r.description ? "" : "text-ink3 italic"}`}>
3438 {r.description || "No description"}
3539 </CardDescription>
36- <p className="rule-t mt-2 flex items-center gap-1.5 pt-2.5 type-readout text-ink3">
37- <ClockIcon width={12} height={12} />
38- {r.empty ? "Empty repository" : `Updated ${timeAgo(r.updatedAt)}`}
39- </p>
40+ <div className="rule-t mt-2 flex items-center gap-3 pt-2.5 type-readout text-ink3">
41+ <span className="flex items-center gap-1.5">
42+ <ClockIcon width={12} height={12} />
43+ {r.empty ? "Empty repository" : `Updated ${timeAgo(r.updatedAt)}`}
44+ </span>
45+ {r.likes > 0 && (
46+ <span className="flex items-center gap-1" title={`${r.likes} ${r.likes === 1 ? "like" : "likes"}`}>
47+ <HeartIcon width={12} height={12} /> {r.likes}
48+ </span>
49+ )}
50+ {actions && <div className="relative z-1 ml-auto flex items-center gap-2">{actions(r)}</div>}
51+ </div>
4052 </Card>
4153 </li>
4254 ))}
+138−0apps/web/components/repo-save.tsxaddedView File
@@ -0,0 +1,138 @@
1+ "use client";
2+
3+ import { useActionState, useOptimistic, useState, useTransition } from "react";
4+ import { Button, Checkbox, Field, Popover, Rule, Select, Toggle, useToast } from "@unlocalhosted/metalui";
5+ import { createListAction, setLikedAction, setSavedAction } from "@/app/actions/lists";
6+ import type { FormState } from "@/app/actions/types";
7+ import { BookmarkIcon, ChevronDownIcon, HeartIcon, LockIcon, PlusIcon } from "./icons";
8+ import { ButtonLink } from "./ui";
9+
10+ /** The repository's Like key: latched (a filled heart) while the viewer likes it. Signed out, it leads to signing in. */
11+ export function LikeButton({ owner, name, liked, signedIn }: { owner: string; name: string; liked: boolean; signedIn: boolean }) {
12+ const toast = useToast();
13+ const [pressed, setPressed] = useOptimistic(liked);
14+ const [, startTransition] = useTransition();
15+ if (!signedIn) {
16+ return (
17+ <ButtonLink href={`/login?next=${encodeURIComponent(`/${owner}/${name}`)}`} icon={<HeartIcon />}>
18+ Like
19+ </ButtonLink>
20+ );
21+ }
22+ return (
23+ <Toggle
24+ lamp={false}
25+ pressed={pressed}
26+ aria-label={pressed ? "Unlike" : "Like"}
27+ onPressedChange={(next) =>
28+ startTransition(async () => {
29+ setPressed(next);
30+ const result = await setLikedAction(owner, name, next);
31+ if (result?.error) toast.show({ title: result.error, tone: "error" });
32+ })
33+ }
34+ >
35+ <HeartIcon fill={pressed ? "currentColor" : "none"} className={pressed ? "text-red" : ""} />
36+ {pressed ? "Liked" : "Like"}
37+ </Toggle>
38+ );
39+ }
40+
41+ export type SaveTarget = { id: string; owner: string; name: string; visibility: "public" | "private"; saved: boolean };
42+
43+ /**
44+ * The repository's Save key: a popover with the lists the viewer may change (theirs and their
45+ * organizations'), ticked where the repository is in them, and a field to make a new list with it.
46+ */
47+ export function SaveButton({ repoId, lists, owners, viewer }: { repoId: string; lists: SaveTarget[]; owners: string[]; viewer: string }) {
48+ const toast = useToast();
49+ const [shown, setSaved] = useOptimistic(lists, (state, change: { id: string; saved: boolean }) =>
50+ state.map((l) => (l.id === change.id ? { ...l, saved: change.saved } : l)),
51+ );
52+ const [, startTransition] = useTransition();
53+ const anySaved = shown.some((l) => l.saved);
54+ const byOwner = owners.map((owner) => ({ owner, lists: shown.filter((l) => l.owner === owner) })).filter((g) => g.lists.length > 0);
55+
56+ const toggle = (list: SaveTarget, saved: boolean) =>
57+ startTransition(async () => {
58+ setSaved({ id: list.id, saved });
59+ const result = await setSavedAction(list.id, repoId, saved);
60+ if (result?.error) toast.show({ title: result.error, tone: "error" });
61+ });
62+
63+ return (
64+ <Popover>
65+ <Popover.Trigger>
66+ <Button icon={<BookmarkIcon fill={anySaved ? "currentColor" : "none"} />}>
67+ {anySaved ? "Saved" : "Save"}
68+ <span aria-hidden className="-mr-1 inline-grid opacity-70">
69+ <ChevronDownIcon width={12} height={12} />
70+ </span>
71+ </Button>
72+ </Popover.Trigger>
73+ <Popover.Content side="bottom" align="end" className="w-[300px]">
74+ <Popover.Title className="flex items-center gap-2">
75+ <BookmarkIcon width={14} height={14} className="text-ink2" /> Save to a list
76+ </Popover.Title>
77+ <Popover.Body className="flex flex-col gap-3">
78+ {byOwner.length === 0 && <p className="type-body text-ink3">Lists gather repositories you want to find again or share. Make your first one below.</p>}
79+ {byOwner.map((group) => (
80+ <div key={group.owner} className="flex flex-col gap-0.5">
81+ {owners.length > 1 && <p className="px-1 pb-1 type-label engraved">{group.owner === viewer ? "Your lists" : group.owner}</p>}
82+ <ul className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
83+ {group.lists.map((list) => (
84+ <li key={list.id}>
85+ <label className="flex cursor-pointer items-center gap-2.5 rounded-[10px] px-1.5 py-1.5 transition-row hover:recipe-row-list-hover">
86+ <Checkbox size="row" checked={list.saved} onCheckedChange={(checked) => toggle(list, checked)} />
87+ <span className="min-w-0 flex-1 truncate type-ui text-ink">{list.name}</span>
88+ {list.visibility === "private" && <LockIcon width={12} height={12} className="shrink-0 text-ink3" aria-label="Private" />}
89+ </label>
90+ </li>
91+ ))}
92+ </ul>
93+ </div>
94+ ))}
95+ <Rule />
96+ <NewListForm repoId={repoId} owners={owners} viewer={viewer} />
97+ </Popover.Body>
98+ </Popover.Content>
99+ </Popover>
100+ );
101+ }
102+
103+ /** Makes a list with the repository in it, without leaving the page. */
104+ function NewListForm({ repoId, owners, viewer }: { repoId: string; owners: string[]; viewer: string }) {
105+ const toast = useToast();
106+ const [created, setCreated] = useState(0);
107+ const [, action, pending] = useActionState(async (prev: FormState, formData: FormData) => {
108+ const result = await createListAction(prev, formData);
109+ if (result?.error) toast.show({ title: result.error, tone: "error" });
110+ else {
111+ if (result?.success) toast.show({ title: result.success, tone: "success" });
112+ setCreated((n) => n + 1);
113+ }
114+ return result;
115+ }, undefined);
116+ return (
117+ // A new key empties the fields once the list is made.
118+ <form key={created} action={action} className="flex flex-col gap-2.5">
119+ <input type="hidden" name="repo" value={repoId} />
120+ <Field size="regular">
121+ <Field.Input name="name" required maxLength={60} placeholder="New list, e.g. Tools I use" aria-label="New list name" />
122+ </Field>
123+ <div className="flex flex-wrap items-center gap-2">
124+ {owners.length > 1 ? (
125+ <Select name="owner" aria-label="Owner of the list" size="compact" defaultValue={viewer} options={owners.map((o) => ({ value: o, label: o }))} className="min-w-24" />
126+ ) : (
127+ <input type="hidden" name="owner" value={viewer} />
128+ )}
129+ <label className="flex cursor-pointer items-center gap-2 type-ui text-ink2">
130+ <Checkbox name="visibility" value="private" /> Private
131+ </label>
132+ <Button type="submit" size="compact" icon={<PlusIcon />} disabled={pending} className="ml-auto">
133+ {pending ? "Creating…" : "Create"}
134+ </Button>
135+ </div>
136+ </form>
137+ );
138+ }
+2−2apps/web/components/search-box.tsxView File
@@ -4,12 +4,12 @@ import { useRouter } from "next/navigation";
44 import { useEffect, useState, useSyncExternalStore } from "react";
55 import { CommandPalette, SearchField, type CommandPaletteItem } from "@unlocalhosted/metalui";
66 import { CompassIcon, GearIcon, HomeIcon, KeyIcon, PlusIcon, SearchIcon, ShieldLockIcon } from "./icons";
7- import { RepoAvatar } from "./ui";
7+ import { RepoAvatar, type RepoImageRef } from "./ui";
88
99 const noSubscribe = () => () => {};
1010 const isApple = () => /Mac|iPhone|iPad/.test(navigator.platform);
1111
12- export type PaletteRepo = { owner: string; name: string; imageVersion: number | null };
12+ export type PaletteRepo = RepoImageRef;
1313
1414 /**
1515 * The header's search well (MetalUI's SearchField) and the ⌘K palette it opens: jump to a repository,
+2−2apps/web/components/sidebar-nav.tsxView File
@@ -9,11 +9,11 @@ import type { Theme } from "@/lib/theme";
99 import { BRAND, LogoMark } from "./brand";
1010 import { ChevronsUpDownIcon, CompassIcon, GearIcon, HomeIcon, KeyIcon, MenuIcon, OrganizationIcon, PersonIcon, PlusIcon, RepoIcon, ShieldLockIcon, SignOutIcon } from "./icons";
1111 import { ThemeToggle } from "./theme-toggle";
12- import { RepoAvatar } from "./ui";
12+ import { RepoAvatar, type RepoImageRef } from "./ui";
1313
1414 type Item = { href: string; label: string; icon: ComponentType<SVGProps<SVGSVGElement>>; match: (p: string) => boolean };
1515
16- export type SidebarRepo = { owner: string; name: string; imageVersion: number | null };
16+ export type SidebarRepo = RepoImageRef;
1717 export type SidebarViewer = { username: string; name: string };
1818
1919 function navItems(username: string): Item[] {
+26−7apps/web/components/ui.tsxView File
@@ -56,11 +56,24 @@ function Identicon({ name, size }: { name: string; size: number }) {
5656 );
5757 }
5858
59+ /** Where an account's uploaded avatar is served (organizations set one in their settings), if it has one. */
60+ export const avatarUrl = (account: { username: string; avatarVersion?: number | null }) =>
61+ account.avatarVersion ? `/organizations/${account.username}/avatar?v=${account.avatarVersion}` : undefined;
62+
5963 /**
60- * A person: MetalUI's raised disc with their initials. Its sizes are 24, 32 and 44; smaller avatars
61- * (inline beside a name) are the small disc scaled down.
64+ * A person or organization: their uploaded avatar in a round cap, or else MetalUI's raised disc with
65+ * their initials. Its sizes are 24, 32 and 44; smaller avatars (inline beside a name) are the small
66+ * disc scaled down.
6267 */
63- export function Avatar({ name, size = 20, className = "" }: { name: string; size?: number; className?: string }) {
68+ export function Avatar({ name, size = 20, className = "", src }: { name: string; size?: number; className?: string; src?: string }) {
69+ if (src) {
70+ return (
71+ <span className={`material-cap inline-flex shrink-0 overflow-hidden rounded-full select-none ${className}`} style={{ width: size, height: size }} aria-hidden>
72+ {/* eslint-disable-next-line @next/next/no-img-element -- tiny, already-sized images */}
73+ <img src={src} alt="" width={size} height={size} className="h-full w-full object-cover" />
74+ </span>
75+ );
76+ }
6477 const muSize = size >= 40 ? "large" : size >= 30 ? "regular" : "small";
6578 const base = muSize === "large" ? 44 : muSize === "regular" ? 32 : 24;
6679 return (
@@ -73,13 +86,19 @@ export function Avatar({ name, size = 20, className = "" }: { name: string; size
7386 );
7487 }
7588
76- export type RepoImageRef = { owner: string; name: string; imageVersion: number | null };
89+ export type RepoImageRef = {
90+ owner: string;
91+ name: string;
92+ imageVersion: number | null;
93+ /** The owning organization's avatar, shown when the repository has no image of its own. */
94+ ownerAvatarVersion?: number | null;
95+ };
7796
7897 export const repoImageUrl = (repo: RepoImageRef) => `/${repo.owner}/${repo.name}/image?v=${repo.imageVersion}`;
7998
8099 /**
81- * A repository's "logo" on a raised key: the image uploaded in the repository's settings, or else an
82- * identicon engraved from the owner's name.
100+ * A repository's "logo" on a raised key: the image uploaded in the repository's settings, or else its
101+ * organization's avatar, or else an identicon engraved from the owner's name.
83102 */
84103 export function RepoAvatar({
85104 repo,
@@ -93,7 +112,7 @@ export function RepoAvatar({
93112 /** Show this image instead, e.g. a local preview before uploading. */
94113 src?: string;
95114 }) {
96- const image = src ?? (repo.imageVersion ? repoImageUrl(repo) : null);
115+ const image = src ?? (repo.imageVersion ? repoImageUrl(repo) : avatarUrl({ username: repo.owner, avatarVersion: repo.ownerAvatarVersion }));
97116 return (
98117 <span
99118 className={`material-cap inline-flex shrink-0 overflow-hidden select-none ${className}`}
+1−1apps/web/lib/git.tsView File
@@ -11,7 +11,7 @@ import { DATA_ROOT, REPOS_ROOT, WIKIS_ROOT } from "./paths";
1111 const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
1212 const RESERVED = new Set([
1313 "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2",
14- "mcp", "oauth", "organizations",
14+ "mcp", "oauth", "organizations", "lists",
1515 ]);
1616
1717 export function isValidName(name: string) {
+7−3apps/web/lib/listing.tsView File
@@ -1,13 +1,17 @@
11 import "server-only";
22 import { lastActivity, log, type Commit } from "./git";
3+ import { countLikes } from "./lists";
34 import { visibleParent, type RepoRecord } from "./repos";
45
5- /** Adds last-activity info to repositories for list views, and for forks the repository they came from if the viewer may see it. */
6+ /**
7+ * Adds last-activity info and the number of likes to repositories for list views, and for forks the
8+ * repository they came from if the viewer may see it.
9+ */
610 export async function withActivity(repos: RepoRecord[], viewerId?: string | null) {
711 return Promise.all(
812 repos.map(async (r) => {
9- const [last, parent] = await Promise.all([lastActivity(r.dir), visibleParent(r, viewerId)]);
10- return { ...r, updatedAt: last ?? r.createdAt, empty: last === null, parent };
13+ const [last, parent, likes] = await Promise.all([lastActivity(r.dir), visibleParent(r, viewerId), countLikes(r.id)]);
14+ return { ...r, updatedAt: last ?? r.createdAt, empty: last === null, parent, likes };
1115 }),
1216 );
1317 }
+249−0apps/web/lib/lists.tsaddedView File
@@ -0,0 +1,249 @@
1+ import { count, db, filter, findById, findFirst, isUniqueViolation, type RecordModel } from "./db";
2+ import { listOrgsOf, orgRole, roleAtLeast } from "./orgs";
3+ import { REPO_EXPAND, repoAccess, toRepo, visibleTo, type RepoRecord, type Visibility } from "./repos";
4+ import { toUser, type User } from "./users";
5+
6+ // Likes and lists of repositories (see pb_migrations/1791500000_lists.js). A person likes a
7+ // repository once; people and organizations keep lists of repositories, public or private. Lists
8+ // only ever show a viewer the repositories they may see.
9+
10+ export class ListError extends Error {}
11+
12+ // ---------------------------------------------------------------------------
13+ // Likes
14+
15+ export async function countLikes(repoId: string) {
16+ return count("repo_likes", filter("repo = {:repoId}", { repoId }));
17+ }
18+
19+ export async function likes(repoId: string, userId: string | null | undefined) {
20+ if (!userId) return false;
21+ return !!(await findFirst("repo_likes", filter("repo = {:repoId} && user = {:userId}", { repoId, userId }), { fields: "id" }));
22+ }
23+
24+ /** Likes or unlikes a repository the person can see. */
25+ export async function setLiked(repo: RepoRecord, user: User, liked: boolean) {
26+ if (user.isOrg || !(await repoAccess(repo, user.id))) throw new ListError("Repository not found.");
27+ const row = await findFirst("repo_likes", filter("repo = {:repoId} && user = {:userId}", { repoId: repo.id, userId: user.id }), { fields: "id" });
28+ if (liked && !row) {
29+ try {
30+ await (await db()).collection("repo_likes").create({ repo: repo.id, user: user.id, created_at: Date.now() });
31+ } catch (e) {
32+ // Liked twice at once.
33+ if (!isUniqueViolation(e)) throw e;
34+ }
35+ } else if (!liked && row) {
36+ await (await db()).collection("repo_likes").delete(row.id);
37+ }
38+ }
39+
40+ /** The repositories a person likes that the viewer may see, the latest liked first. */
41+ export async function listLikedRepos(userId: string, viewerId: string | null) {
42+ const rows = await (await db()).collection("repo_likes").getFullList({
43+ filter: `${filter("user = {:userId}", { userId })} && ${visibleTo(viewerId, "repo.")}`,
44+ sort: "-created_at",
45+ expand: expandVia("repo"),
46+ });
47+ return rows.map((r) => toRepo(r.expand?.repo as RecordModel));
48+ }
49+
50+ export async function countLikedRepos(userId: string, viewerId: string | null) {
51+ return count("repo_likes", `${filter("user = {:userId}", { userId })} && ${visibleTo(viewerId, "repo.")}`);
52+ }
53+
54+ /** The people who like a repository, the latest first. */
55+ export async function listLikers(repoId: string) {
56+ const rows = await (await db()).collection("repo_likes").getFullList({ filter: filter("repo = {:repoId}", { repoId }), sort: "-created_at", expand: "user" });
57+ return rows.map((r) => ({ user: toUser(r.expand?.user as RecordModel), likedAt: r.created_at as number }));
58+ }
59+
60+ /** REPO_EXPAND, for a record that points at a repository through `field`. */
61+ const expandVia = (field: string) => [field, ...REPO_EXPAND.split(",").map((e) => `${field}.${e}`)].join(",");
62+
63+ // ---------------------------------------------------------------------------
64+ // Lists
65+
66+ export type RepoList = {
67+ id: string;
68+ owner: User;
69+ name: string;
70+ slug: string;
71+ description: string;
72+ visibility: Visibility;
73+ createdAt: number;
74+ updatedAt: number;
75+ };
76+
77+ const lists = async () => (await db()).collection("repo_lists");
78+ const items = async () => (await db()).collection("repo_list_items");
79+
80+ function toList(r: RecordModel): RepoList {
81+ return {
82+ id: r.id,
83+ owner: toUser(r.expand?.owner as RecordModel),
84+ name: r.name,
85+ slug: r.slug,
86+ description: r.description,
87+ visibility: r.visibility,
88+ createdAt: r.created_at,
89+ updatedAt: r.updated_at,
90+ };
91+ }
92+
93+ /** A list's address: its name in lowercase, words joined by hyphens. */
94+ export function listSlug(name: string) {
95+ return name
96+ .toLowerCase()
97+ .normalize("NFKD")
98+ .replace(/[̀-ͯ]/g, "")
99+ .replace(/[^a-z0-9]+/g, "-")
100+ .replace(/^-+|-+$/g, "")
101+ .slice(0, 60)
102+ .replace(/-+$/, "");
103+ }
104+
105+ export const listPath = (list: Pick<RepoList, "owner" | "slug">) => `/lists/${list.owner.username}/${list.slug}`;
106+
107+ /** Whether someone sees an account's private lists: the person themselves, or the organization's members. */
108+ async function seesPrivateLists(owner: User, viewerId: string | null | undefined) {
109+ if (!viewerId) return false;
110+ return owner.id === viewerId || (owner.isOrg && !!(await orgRole(owner.id, viewerId)));
111+ }
112+
113+ /** Whether someone may see a list: anyone a public one, and private ones as seesPrivateLists says. */
114+ export async function maySeeList(list: RepoList, viewerId: string | null | undefined) {
115+ return list.visibility === "public" || seesPrivateLists(list.owner, viewerId);
116+ }
117+
118+ /** Whether someone may change an owner's lists: their own, and those of organizations they can push in. */
119+ export async function mayManageLists(owner: User, viewerId: string | null | undefined) {
120+ if (!viewerId) return false;
121+ return owner.id === viewerId || (owner.isOrg && roleAtLeast(await orgRole(owner.id, viewerId), "write"));
122+ }
123+
124+ /** Accounts whose lists a person may change: their own, and organizations they can push in. */
125+ export async function listOwnersFor(user: User) {
126+ const orgs = (await listOrgsOf(user.id)).filter((m) => roleAtLeast(m.role, "write")).map((m) => m.org);
127+ return [user, ...orgs];
128+ }
129+
130+ export async function findList(owner: string, slug: string) {
131+ const row = await findFirst(
132+ "repo_lists",
133+ filter("owner.username:lower = {:owner} && slug = {:slug}", { owner: owner.toLowerCase(), slug: slug.toLowerCase() }),
134+ { expand: "owner" },
135+ );
136+ return row ? toList(row) : null;
137+ }
138+
139+ export async function findListById(id: string) {
140+ const row = await findById("repo_lists", id, { expand: "owner" });
141+ return row ? toList(row) : null;
142+ }
143+
144+ /** An account's lists the viewer may see, the most recently changed first. */
145+ export async function listListsOf(owner: User, viewerId: string | null) {
146+ const where = filter("owner = {:ownerId}", { ownerId: owner.id });
147+ const rows = await (await lists()).getFullList({
148+ filter: (await seesPrivateLists(owner, viewerId)) ? where : `${where} && visibility = 'public'`,
149+ sort: "-updated_at",
150+ expand: "owner",
151+ });
152+ return rows.map(toList);
153+ }
154+
155+ /** Lists with how many of their repositories the viewer may see. */
156+ export async function withRepoCounts(lists: RepoList[], viewerId: string | null) {
157+ return Promise.all(lists.map(async (list) => ({ ...list, repoCount: await countListRepos(list.id, viewerId) })));
158+ }
159+
160+ export async function countListRepos(listId: string, viewerId: string | null) {
161+ return count("repo_list_items", `${filter("list = {:listId}", { listId })} && ${visibleTo(viewerId, "repo.")}`);
162+ }
163+
164+ /** The repositories in a list that the viewer may see, the latest added first. */
165+ export async function listReposInList(listId: string, viewerId: string | null) {
166+ const rows = await (await items()).getFullList({
167+ filter: `${filter("list = {:listId}", { listId })} && ${visibleTo(viewerId, "repo.")}`,
168+ sort: "-created_at",
169+ expand: expandVia("repo"),
170+ });
171+ return rows.map((r) => toRepo(r.expand?.repo as RecordModel));
172+ }
173+
174+ type ListInput = { name: string; description: string; visibility: Visibility };
175+
176+ function checkInput(input: ListInput) {
177+ const name = input.name.trim().replace(/\s+/g, " ");
178+ if (!name) throw new ListError("Give the list a name.");
179+ if (name.length > 60) throw new ListError("List names can be at most 60 characters.");
180+ const slug = listSlug(name);
181+ if (!slug) throw new ListError("List names need at least one letter or digit.");
182+ if (input.visibility !== "public" && input.visibility !== "private") throw new ListError("Choose who can see the list.");
183+ return { name, slug, description: input.description.trim().slice(0, 300), visibility: input.visibility };
184+ }
185+
186+ const nameTaken = (owner: User, name: string) =>
187+ new ListError(`${owner.isOrg ? `${owner.username} already has` : "You already have"} a list named ${name}, or one with the same address.`);
188+
189+ export async function createList(owner: User, input: ListInput) {
190+ const data = checkInput(input);
191+ const now = Date.now();
192+ try {
193+ const record = await (await lists()).create({ owner: owner.id, ...data, created_at: now, updated_at: now }, { expand: "owner" });
194+ return toList(record);
195+ } catch (e) {
196+ if (isUniqueViolation(e)) throw nameTaken(owner, data.name);
197+ throw e;
198+ }
199+ }
200+
201+ /** Changes a list; renaming it changes its address. */
202+ export async function updateList(list: RepoList, input: ListInput) {
203+ const data = checkInput(input);
204+ try {
205+ return toList(await (await lists()).update(list.id, data, { expand: "owner" }));
206+ } catch (e) {
207+ if (isUniqueViolation(e)) throw nameTaken(list.owner, data.name);
208+ throw e;
209+ }
210+ }
211+
212+ /** Deletes a list; PocketBase removes its items with it. The repositories stay. */
213+ export async function deleteList(list: RepoList) {
214+ await (await lists()).delete(list.id);
215+ }
216+
217+ /** Puts a repository in a list, or takes it out. */
218+ export async function setInList(list: RepoList, repoId: string, inList: boolean) {
219+ const row = await findFirst("repo_list_items", filter("list = {:listId} && repo = {:repoId}", { listId: list.id, repoId }), { fields: "id" });
220+ if (inList && !row) {
221+ try {
222+ await (await items()).create({ list: list.id, repo: repoId, created_at: Date.now() });
223+ } catch (e) {
224+ // Added twice at once.
225+ if (!isUniqueViolation(e)) throw e;
226+ }
227+ await (await lists()).update(list.id, { updated_at: Date.now() });
228+ } else if (!inList && row) {
229+ await (await items()).delete(row.id);
230+ }
231+ }
232+
233+ /** The lists a person may change, and whether each holds the repository: for the Save menu. */
234+ export async function saveTargets(user: User, repoId: string) {
235+ const owners = await listOwnersFor(user);
236+ const ofOwners = (field: string) => `(${owners.map((o) => filter(`${field} = {:id}`, { id: o.id })).join(" || ")})`;
237+ const [rows, saved] = await Promise.all([
238+ (await lists()).getFullList({ filter: ofOwners("owner"), expand: "owner" }),
239+ (await items()).getFullList({ filter: `${filter("repo = {:repoId}", { repoId })} && ${ofOwners("list.owner")}`, fields: "list" }),
240+ ]);
241+ const savedIn = new Set(saved.map((r) => r.list as string));
242+ const order = owners.map((o) => o.id);
243+ return {
244+ owners,
245+ lists: rows
246+ .map((r) => ({ ...toList(r), saved: savedIn.has(r.id) }))
247+ .sort((a, b) => order.indexOf(a.owner.id) - order.indexOf(b.owner.id) || a.name.localeCompare(b.name)),
248+ };
249+ }
+16−0apps/web/lib/orgs.tsView File
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
22 import { count, db, filter, findById, findFirst, isUniqueViolation, withLock, type RecordModel } from "./db";
33 import { isValidOwner } from "./git";
44 import { ORG_ROLES, roleAtLeast, type OrgRole } from "./org-roles";
5+ import { readStoredImage, uploadedImage } from "./uploaded-images";
56 import { getUserByUsername, toUser, type User } from "./users";
67
78 export * from "./org-roles";
@@ -56,6 +57,21 @@ export async function updateOrgProfile(orgId: string, data: { displayName: strin
5657 await (await db()).collection("users").update(orgId, { name: data.displayName.trim().slice(0, 100), bio: data.description.trim().slice(0, 300) });
5758 }
5859
60+ export async function setOrgAvatar(orgId: string, data: Uint8Array) {
61+ const image = uploadedImage(data, "avatar");
62+ if ("error" in image) throw new OrgError(image.error);
63+ await (await db()).collection("users").update(orgId, { avatar: image.file, avatar_type: image.type, avatar_updated_at: Date.now() });
64+ }
65+
66+ export async function removeOrgAvatar(orgId: string) {
67+ await (await db()).collection("users").update(orgId, { avatar: null, avatar_type: "", avatar_updated_at: 0 });
68+ }
69+
70+ export async function getOrgAvatar(orgId: string) {
71+ const row = await findById("users", orgId);
72+ return row?.kind === "org" && row.avatar ? readStoredImage(row, row.avatar, row.avatar_type) : null;
73+ }
74+
5975 /** Deletes an organization without repositories, and its memberships and invitations with it. */
6076 export async function deleteOrg(org: User) {
6177 const repos = await count("repos", filter("owner = {:id}", { id: org.id }));
+44−38apps/web/lib/repos.tsView File
@@ -16,6 +16,7 @@ import {
1616 import { commitFiles } from "./git-write";
1717 import { deleteRepoImages } from "./registry";
1818 import { deleteRepoReleaseFiles } from "./releases";
19+ import { readStoredImage, uploadedImage } from "./uploaded-images";
1920 import type { AgentPush } from "./branch-rules";
2021 import { orgRole, repoAccessOfRole } from "./orgs";
2122 import { getPersonByUsername, type User } from "./users";
@@ -39,6 +40,8 @@ export type RepoRecord = {
3940 pushedAt: number | null;
4041 /** When a custom image was uploaded (used to bust caches), or null to show the owner's avatar. */
4142 imageVersion: number | null;
43+ /** The owning organization's avatar version, shown in place of a missing image (see lib/users.ts). */
44+ ownerAvatarVersion: number | null;
4245 wikiEnabled: boolean;
4346 /** Where agents may push (see lib/branch-rules.ts). */
4447 agentPush: AgentPush;
@@ -49,9 +52,10 @@ export type RepoRecord = {
4952 wikiDir: string;
5053 };
5154
52- const EXPAND = "owner,fork_of.owner";
55+ export const REPO_EXPAND = "owner,fork_of.owner";
5356
54- function toRepo(r: RecordModel): RepoRecord {
57+ /** A repository from its record, expanded with REPO_EXPAND. */
58+ export function toRepo(r: RecordModel): RepoRecord {
5559 const ownerRecord = r.expand?.owner as RecordModel;
5660 const owner = ownerRecord.username as string;
5761 const parent = r.expand?.fork_of as RecordModel | undefined;
@@ -66,6 +70,7 @@ function toRepo(r: RecordModel): RepoRecord {
6670 createdAt: r.created_at,
6771 pushedAt: orNull(r.pushed_at),
6872 imageVersion: r.image ? orNull(r.image_updated_at) : null,
73+ ownerAvatarVersion: ownerRecord.kind === "org" && ownerRecord.avatar ? orNull(ownerRecord.avatar_updated_at) : null,
6974 wikiEnabled: !r.wiki_disabled,
7075 agentPush: r.agent_push || "any",
7176 forkOf: parent
@@ -84,13 +89,13 @@ export async function findRepo(owner: string, name: string) {
8489 const row = await findFirst(
8590 "repos",
8691 filter("owner.username:lower = {:owner} && name:lower = {:name}", { owner: owner.toLowerCase(), name: name.toLowerCase() }),
87- { expand: EXPAND },
92+ { expand: REPO_EXPAND },
8893 );
8994 return row ? toRepo(row) : null;
9095 }
9196
9297 export async function findRepoById(id: string) {
93- const row = await findById("repos", id, { expand: EXPAND });
98+ const row = await findById("repos", id, { expand: REPO_EXPAND });
9499 return row ? toRepo(row) : null;
95100 }
96101
@@ -139,17 +144,20 @@ export async function visibleParent(repo: RepoRecord, viewerId: string | null |
139144 return repo.forkOf && (await repoAccess(repo.forkOf, viewerId)) ? repo.forkOf : null;
140145 }
141146
142- /** Repos a user may see: public ones, their own, ones they collaborate on, and their organizations'. */
143- const visibleTo = (viewerId: string | null) =>
147+ /**
148+ * Repos a user may see: public ones, their own, ones they collaborate on, and their organizations'.
149+ * `via` filters records that point at a repository instead, e.g. "repo." for those with a `repo` field.
150+ */
151+ export const visibleTo = (viewerId: string | null, via = "") =>
144152 viewerId
145153 ? filter(
146- "(visibility = 'public' || owner = {:viewer} || collaborators_via_repo.user ?= {:viewer} || owner.org_members_via_org.user ?= {:viewer})",
154+ `(${via}visibility = 'public' || ${via}owner = {:viewer} || ${via}collaborators_via_repo.user ?= {:viewer} || ${via}owner.org_members_via_org.user ?= {:viewer})`,
147155 { viewer: viewerId },
148156 )
149- : "visibility = 'public'";
157+ : `${via}visibility = 'public'`;
150158
151159 async function list(where: string, limit?: number) {
152- const options = { filter: where, sort: "-activity_at", expand: EXPAND };
160+ const options = { filter: where, sort: "-activity_at", expand: REPO_EXPAND };
153161 const rows = limit ? (await (await repos()).getList(1, limit, { ...options, skipTotal: true })).items : await (await repos()).getFullList(options);
154162 return rows.map(toRepo);
155163 }
@@ -342,6 +350,29 @@ export async function renameRepo(repo: RepoRecord, newName: string) {
342350 return (await findRepoById(repo.id))!;
343351 }
344352
353+ /**
354+ * Moves a repository to another account (an organization, or a person's) under the same name. Its
355+ * issues, pull requests, wiki, releases, images, forks and collaborators come along: all of them
356+ * are kept by the repository's id. The new owner stops being a collaborator: they own it now.
357+ */
358+ export async function transferRepo(repo: RepoRecord, newOwner: User) {
359+ if (newOwner.id === repo.ownerId) throw new RepoError(`${repo.owner}/${repo.name} already belongs to ${newOwner.username}.`);
360+ if (await findRepo(newOwner.username, repo.name)) throw new RepoError(alreadyHas(newOwner, repo.name));
361+ const newDir = repoPath(newOwner.username, repo.name);
362+ if (await pathExists(newDir)) throw new RepoError(`A repository directory for ${newOwner.username}/${repo.name} already exists on disk.`);
363+ await mkdir(path.dirname(newDir), { recursive: true });
364+ await rename(repo.dir, newDir);
365+ try {
366+ await (await repos()).update(repo.id, { owner: newOwner.id });
367+ } catch (e) {
368+ await rename(newDir, repo.dir);
369+ if (isUniqueViolation(e)) throw new RepoError(alreadyHas(newOwner, repo.name));
370+ throw e;
371+ }
372+ await removeCollaborator(repo.id, newOwner.id);
373+ return (await findRepoById(repo.id))!;
374+ }
375+
345376 /** Deletes the repository and its wiki; PocketBase deletes its issues, runs, releases, collaborators and so on with it. */
346377 export async function deleteRepo(repo: RepoRecord) {
347378 // Pull requests from it to other repositories can't change anymore. Loaded when needed: lib/pulls.ts needs this module.
@@ -362,30 +393,10 @@ export async function deleteRepo(repo: RepoRecord) {
362393 // ---------------------------------------------------------------------------
363394 // Repository image
364395
365- export const MAX_REPO_IMAGE_BYTES = 1024 * 1024;
366-
367- const IMAGE_TYPES: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
368-
369- /** Recognizes the raster formats we accept by their magic bytes; the browser-reported type isn't trusted. */
370- function sniffImageType(data: Uint8Array) {
371- const starts = (...bytes: number[]) => bytes.every((b, i) => data[i] === b);
372- if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
373- if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
374- if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif";
375- if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP") return "image/webp";
376- return null;
377- }
378-
379396 export async function setRepoImage(repoId: string, data: Uint8Array) {
380- if (data.byteLength === 0) throw new RepoError("Choose an image to upload.");
381- if (data.byteLength > MAX_REPO_IMAGE_BYTES) throw new RepoError("Images can be at most 1 MB.");
382- const contentType = sniffImageType(data);
383- if (!contentType) throw new RepoError("Use a PNG, JPEG, GIF or WebP image.");
384- await (await repos()).update(repoId, {
385- image: new File([new Uint8Array(data)], `image.${IMAGE_TYPES[contentType]}`, { type: contentType }),
386- image_type: contentType,
387- image_updated_at: Date.now(),
388- });
397+ const image = uploadedImage(data, "image");
398+ if ("error" in image) throw new RepoError(image.error);
399+ await (await repos()).update(repoId, { image: image.file, image_type: image.type, image_updated_at: Date.now() });
389400 }
390401
391402 export async function removeRepoImage(repoId: string) {
@@ -394,12 +405,7 @@ export async function removeRepoImage(repoId: string) {
394405
395406 export async function getRepoImage(repoId: string) {
396407 const row = await findById("repos", repoId);
397- if (!row?.image) return null;
398- const pb = await db();
399- // The file field is protected: downloads need a short-lived file token.
400- const res = await fetch(pb.files.getURL(row, row.image, { token: await pb.files.getToken() }));
401- if (!res.ok) return null;
402- return { contentType: row.image_type as string, data: new Uint8Array(await res.arrayBuffer()) };
408+ return row?.image ? readStoredImage(row, row.image, row.image_type) : null;
403409 }
404410
405411 // ---------------------------------------------------------------------------
+48−0apps/web/lib/uploaded-images.tsaddedView File
@@ -0,0 +1,48 @@
1+ import { db, type RecordModel } from "./db";
2+
3+ // Pictures people upload: repositories' images and organizations' avatars. They're stored in
4+ // protected PocketBase file fields and served by the app with their sniffed type.
5+
6+ export const MAX_UPLOADED_IMAGE_BYTES = 1024 * 1024;
7+
8+ const EXTENSIONS: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
9+
10+ /** Recognizes the raster formats we accept by their magic bytes; the browser-reported type isn't trusted. */
11+ function sniffImageType(data: Uint8Array) {
12+ const starts = (...bytes: number[]) => bytes.every((b, i) => data[i] === b);
13+ if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
14+ if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
15+ if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif";
16+ if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP") return "image/webp";
17+ return null;
18+ }
19+
20+ /** The upload as a file named `<name>.<ext>` to store, or why it's refused. */
21+ export function uploadedImage(data: Uint8Array, name: string): { file: File; type: string } | { error: string } {
22+ if (data.byteLength === 0) return { error: "Choose an image to upload." };
23+ if (data.byteLength > MAX_UPLOADED_IMAGE_BYTES) return { error: "Images can be at most 1 MB." };
24+ const type = sniffImageType(data);
25+ if (!type) return { error: "Use a PNG, JPEG, GIF or WebP image." };
26+ return { file: new File([new Uint8Array(data)], `${name}.${EXTENSIONS[type]}`, { type }), type };
27+ }
28+
29+ /** A stored image's bytes, or null. */
30+ export async function readStoredImage(record: RecordModel, filename: string, type: string) {
31+ const pb = await db();
32+ // The file fields are protected: downloads need a short-lived file token.
33+ const res = await fetch(pb.files.getURL(record, filename, { token: await pb.files.getToken() }));
34+ if (!res.ok) return null;
35+ return { contentType: type, data: new Uint8Array(await res.arrayBuffer()) };
36+ }
37+
38+ /** The response for a stored image: cacheable for good (URLs carry `?v=<upload time>`) and inert. */
39+ export function imageResponse(image: { contentType: string; data: Uint8Array }, visibility: "public" | "private") {
40+ return new Response(new Uint8Array(image.data), {
41+ headers: {
42+ "Content-Type": image.contentType,
43+ "X-Content-Type-Options": "nosniff",
44+ "Cache-Control": `${visibility}, max-age=31536000, immutable`,
45+ "Content-Security-Policy": "default-src 'none'; sandbox",
46+ },
47+ });
48+ }
+14−2apps/web/lib/users.tsView File
@@ -1,5 +1,5 @@
11 import { ClientResponseError } from "pocketbase";
2- import { client, db, filter, findById, findFirst, isUniqueViolation, type RecordModel } from "./db";
2+ import { client, db, filter, findById, findFirst, isUniqueViolation, orNull, type RecordModel } from "./db";
33 import { isValidOwner } from "./git";
44
55 // Accounts are PocketBase auth records: PocketBase hashes and checks passwords and issues the
@@ -14,6 +14,8 @@ export type User = {
1414 createdAt: number;
1515 /** An organization (lib/orgs.ts): it owns repositories, but nobody signs in as it. */
1616 isOrg: boolean;
17+ /** When an avatar was uploaded (used to bust caches), or null to draw the initials. */
18+ avatarVersion: number | null;
1719 /** Whether the person confirmed their email address with a link sent to it (lib/emails.ts). */
1820 verified: boolean;
1921 };
@@ -22,7 +24,17 @@ export type User = {
2224 export const parseDate = (value: string) => Date.parse(value.replace(" ", "T"));
2325
2426 export function toUser(r: RecordModel): User {
25- return { id: r.id, username: r.username, name: r.name, email: r.email, bio: r.bio, createdAt: parseDate(r.created), isOrg: r.kind === "org", verified: !!r.verified };
27+ return {
28+ id: r.id,
29+ username: r.username,
30+ name: r.name,
31+ email: r.email,
32+ bio: r.bio,
33+ createdAt: parseDate(r.created),
34+ isOrg: r.kind === "org",
35+ avatarVersion: r.avatar ? orNull(r.avatar_updated_at) : null,
36+ verified: !!r.verified,
37+ };
2638 }
2739
2840 export function validatePassword(password: string) {
+79−0apps/web/pb_migrations/1791500000_lists.jsaddedView File
@@ -0,0 +1,79 @@
1+ // Likes and lists of repositories.
2+ //
3+ // `repo_likes`: a person likes a repository, once. A repository shows how many people like it, and
4+ // a person's profile the repositories they like.
5+ //
6+ // `repo_lists`: named collections of repositories that a person or an organization keeps, public
7+ // (anyone can see them) or private (only that person, or the organization's members). `slug` is the
8+ // list's address under /lists/<owner>/<slug>, made from its name. `repo_list_items` puts a
9+ // repository in a list; a list never shows anyone a repository they couldn't see anyway.
10+
11+ const text = (name, opts = {}) => ({ type: "text", name, max: 1000, ...opts });
12+ const number = (name, opts = {}) => ({ type: "number", name, onlyInt: true, ...opts });
13+ const time = (name, opts = {}) => number(name, { min: 0, ...opts });
14+ const select = (name, values, opts = {}) => ({ type: "select", name, values, maxSelect: 1, required: true, ...opts });
15+ const relation = (name, collection, opts = {}) => ({
16+ type: "relation",
17+ name,
18+ collectionId: collection.id,
19+ maxSelect: 1,
20+ cascadeDelete: false,
21+ ...opts,
22+ });
23+ const parent = (name, collection) => relation(name, collection, { required: true, cascadeDelete: true });
24+
25+ function create(app, name, fields, indexes = []) {
26+ const collection = new Collection({
27+ type: "base",
28+ name,
29+ listRule: null,
30+ viewRule: null,
31+ createRule: null,
32+ updateRule: null,
33+ deleteRule: null,
34+ fields,
35+ indexes,
36+ });
37+ app.save(collection);
38+ return collection;
39+ }
40+
41+ migrate(
42+ (app) => {
43+ const users = app.findCollectionByNameOrId("users");
44+ const repos = app.findCollectionByNameOrId("repos");
45+
46+ create(
47+ app,
48+ "repo_likes",
49+ [parent("repo", repos), parent("user", users), time("created_at", { required: true })],
50+ ["CREATE UNIQUE INDEX idx_repo_likes ON repo_likes (repo, user)", "CREATE INDEX idx_repo_likes_user ON repo_likes (user, created_at)"],
51+ );
52+
53+ const lists = create(
54+ app,
55+ "repo_lists",
56+ [
57+ parent("owner", users),
58+ text("name", { required: true, max: 60 }),
59+ text("slug", { required: true, max: 60 }),
60+ text("description", { max: 300 }),
61+ select("visibility", ["public", "private"]),
62+ time("created_at", { required: true }),
63+ // When a repository was last added, or the list was made; lists are sorted by it.
64+ time("updated_at", { required: true }),
65+ ],
66+ ["CREATE UNIQUE INDEX idx_repo_lists_slug ON repo_lists (owner, slug)"],
67+ );
68+
69+ create(
70+ app,
71+ "repo_list_items",
72+ [parent("list", lists), parent("repo", repos), time("created_at", { required: true })],
73+ ["CREATE UNIQUE INDEX idx_repo_list_items ON repo_list_items (list, repo)", "CREATE INDEX idx_repo_list_items_repo ON repo_list_items (repo)"],
74+ );
75+ },
76+ (app) => {
77+ for (const name of ["repo_list_items", "repo_lists", "repo_likes"]) app.delete(app.findCollectionByNameOrId(name));
78+ },
79+ );
+30−0apps/web/pb_migrations/1791600000_avatars.jsaddedView File
@@ -0,0 +1,30 @@
1+ // Uploaded avatars for accounts (for now, organizations set one in their settings; see lib/orgs.ts).
2+ // Like a repository's image: one raster file, protected so only the server reads it, with its type
3+ // and upload time (avatar_updated_at, which busts caches) beside it.
4+
5+ const MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
6+
7+ migrate(
8+ (app) => {
9+ const users = app.findCollectionByNameOrId("users");
10+ // PocketBase's default users collection comes with an open "avatar" field that takes SVGs.
11+ const avatar = users.fields.getByName("avatar");
12+ if (avatar) {
13+ avatar.maxSelect = 1;
14+ avatar.maxSize = 1024 * 1024;
15+ avatar.mimeTypes = MIME_TYPES;
16+ avatar.thumbs = [];
17+ avatar.protected = true;
18+ } else {
19+ users.fields.add(new FileField({ name: "avatar", maxSelect: 1, maxSize: 1024 * 1024, mimeTypes: MIME_TYPES, protected: true }));
20+ }
21+ users.fields.add(new TextField({ name: "avatar_type", max: 20 }));
22+ users.fields.add(new NumberField({ name: "avatar_updated_at", onlyInt: true, min: 0 }));
23+ app.save(users);
24+ },
25+ (app) => {
26+ const users = app.findCollectionByNameOrId("users");
27+ for (const name of ["avatar_type", "avatar_updated_at"]) users.fields.removeByName(name);
28+ app.save(users);
29+ },
30+ );
+1−1apps/web/scripts/create-user.mjsView File
@@ -19,7 +19,7 @@ if (!username || !email) {
1919 // The same rules as createUser() in lib/users.ts and isValidOwner() in lib/git.ts.
2020 const RESERVED = new Set([
2121 "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2",
22- "mcp", "oauth", "organizations",
22+ "mcp", "oauth", "organizations", "lists",
2323 ]);
2424 const fail = (message) => (console.error(message), process.exit(1));
2525 if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(username) || RESERVED.has(username.toLowerCase())) {
+3−2docs/features.mdView File
@@ -3,9 +3,10 @@
33 - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions)
44 - **Email** through an SMTP server of your own ([Configuration](installation.md#email)): confirming addresses, resetting a forgotten password, organization invitations, collaborator access, and notices when a password or email address changes
55 - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password, on the website and in the Android app; SSH keys, API tokens and connected apps work as before
6- - **Repositories**: public or private, rename, delete, optional README on creation
6+ - **Repositories**: public or private, rename, transfer to an organization (or back to your account), delete, optional README on creation
77 - **Collaborators**: give other users push access to a repository
8- - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`)
8+ - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Owners can upload an avatar for the organization (PNG, JPEG, GIF or WebP, up to 1 MB) in its *General* settings; its repositories without an image of their own show it too. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`)
9+ - **Likes and lists**: anyone signed in can *Like* a repository they can see; the repository shows how many people like it (and who, under *likes*), and a person's profile lists what they like under *Liked*. The *Save* key puts a repository in lists, or makes a new one on the spot. Lists belong to a person or an organization, live at `/lists/<owner>/<list>` and on the profile's *Lists* tab, and are public (anyone can see them) or private (only that person, or the organization's members). Members with *Write* or more manage an organization's lists. A list never shows a repository to someone who couldn't see it anyway, so private repositories can go in public lists
910 - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private
1011 - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys*
1112 - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens
+1−0docs/layout.mdView File
@@ -18,6 +18,7 @@ Paths are relative to `apps/web`.
1818 - `lib/users.ts`, `lib/auth.ts`: accounts and sessions (PocketBase auth records and tokens); `lib/totp.ts`, `lib/two-factor.ts`: two-factor authentication with an authenticator app
1919 - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators
2020 - `lib/orgs.ts`, `lib/org-roles.ts`: organizations (accounts in `users` with kind "org"), their members' roles and invitations; UI in `app/organizations/` and `app/settings/organizations/`
21+ - `lib/lists.ts`: likes and lists of repositories; UI in `app/lists/`, `components/repo-save.tsx` (the Like and Save keys) and the profile's tabs (`app/[owner]/tabs.tsx`)
2122 - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/`
2223 - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens
2324 - `lib/oauth.ts`, `lib/oauth-rules.ts`: the OAuth 2.1 authorization server for agents (`app/oauth/*`, `app/.well-known/*`); `lib/mcp/*`: the MCP endpoint (`app/mcp`) and its tools