Vectrum
Sign In

Add likes and lists of repositories

Anyone signed in can like a repository they can see, once. The repository
header shows a Like key and how many people like it (who, under /likes),
and a person's profile has a Liked tab.

The Save key puts a repository in lists, or makes a new list with it on
the spot. Lists belong to a person or an organization, live at
/lists/<owner>/<slug> and on the profile's Lists tab, and are public or
private (only the person, or the organization's members). Members with
write or more manage an organization's lists. A list only ever shows a
viewer the repositories they could see anyway, so private repositories
can sit in public lists. "lists" becomes a reserved username.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent dd295d0commit9e2c1b7

Showing 26 changed files with 1199 additions and 103 deletions.

+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+ }
+29−40apps/web/app/[owner]/org-profile.tsxView File
@@ -1,29 +1,31 @@
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, 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 (
@@ -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+ }
+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, 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} 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+ }
+1−1apps/web/app/organizations/[org]/settings/forms.tsxView File
@@ -43,7 +43,7 @@ export function DeleteOrgZone({ action, name }: { action: Action; name: string }
4343 description="Only an organization without repositories can be deleted. Delete its repositories first."
4444 consequence={
4545 <>
46- This will permanently delete <strong>{name}</strong>, its members and pending invitations. The name becomes free for anyone to take.
46+ This will permanently delete <strong>{name}</strong>, its members, lists and pending invitations. The name becomes free for anyone to take.
4747 </>
4848 }
4949 openLabel="Delete Organization"
+32−1apps/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,
@@ -24,6 +26,7 @@ import { Avatar, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvata
2426 import { getViewer } from "@/lib/auth";
2527 import { shortSha, timeAgo } from "@/lib/format";
2628 import { recentActivity, withActivity } from "@/lib/listing";
29+ import { listListsOf, listPath } from "@/lib/lists";
2730 import { listInvitationsFor, listOrgsOf } from "@/lib/orgs";
2831 import { listReposForUser, searchRepos } from "@/lib/repos";
2932 import { serverHost } from "@/lib/request-origin";
@@ -127,11 +130,12 @@ function greeting() {
127130 const liftRow = "rounded-row transition-row outline-none hover:recipe-row-list-hover focus-visible:recipe-row-list-hover focus-visible:focus-ring-flush";
128131
129132 async function Dashboard({ viewer }: { viewer: User }) {
130- const [mine, recent, orgs, invitations] = await Promise.all([
133+ const [mine, recent, orgs, invitations, lists] = await Promise.all([
131134 listReposForUser(viewer.id),
132135 searchRepos("", viewer.id, 30),
133136 listOrgsOf(viewer.id),
134137 listInvitationsFor(viewer.id),
138+ listListsOf(viewer, viewer.id),
135139 ]);
136140 const [activity, explore] = await Promise.all([
137141 recentActivity(mine),
@@ -173,6 +177,33 @@ async function Dashboard({ viewer }: { viewer: User }) {
173177 ))}
174178 </ul>
175179 </Box>
180+ <Box className="p-4">
181+ <div className="mb-2 flex items-center justify-between gap-2">
182+ <h2 className="flex items-center gap-2 type-label engraved">
183+ Your lists <Counter>{lists.length}</Counter>
184+ </h2>
185+ <ButtonLink href="/lists/new" variant="invisible" icon={<PlusIcon />}>
186+ New
187+ </ButtonLink>
188+ </div>
189+ {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>}
190+ <ul className="-mx-2 flex flex-col gap-0.5">
191+ {lists.slice(0, 8).map((list) => (
192+ <li key={list.id}>
193+ <Link href={listPath(list)} className={`flex items-center gap-2.5 px-2 py-1.5 ${liftRow}`}>
194+ <BookmarkIcon width={14} height={14} className="shrink-0 text-ink3" />
195+ <span className="min-w-0 flex-1 truncate type-ui text-ink">{list.name}</span>
196+ {list.visibility === "private" && <LockIcon width={12} height={12} className="shrink-0 text-ink3" aria-label="Private" />}
197+ </Link>
198+ </li>
199+ ))}
200+ </ul>
201+ {lists.length > 8 && (
202+ <ButtonLink href={`/${viewer.username}?tab=lists`} variant="invisible" icon={<ArrowRightIcon />} className="mt-1 -ml-2">
203+ All Lists
204+ </ButtonLink>
205+ )}
206+ </Box>
176207 </aside>
177208
178209 <main className="min-w-0">
+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" />
+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+ }
+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);
+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+ }
+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+ }
+13−9apps/web/lib/repos.tsView File
@@ -49,9 +49,10 @@ export type RepoRecord = {
4949 wikiDir: string;
5050 };
5151
52- const EXPAND = "owner,fork_of.owner";
52+ export const REPO_EXPAND = "owner,fork_of.owner";
5353
54- function toRepo(r: RecordModel): RepoRecord {
54+ /** A repository from its record, expanded with REPO_EXPAND. */
55+ export function toRepo(r: RecordModel): RepoRecord {
5556 const ownerRecord = r.expand?.owner as RecordModel;
5657 const owner = ownerRecord.username as string;
5758 const parent = r.expand?.fork_of as RecordModel | undefined;
@@ -84,13 +85,13 @@ export async function findRepo(owner: string, name: string) {
8485 const row = await findFirst(
8586 "repos",
8687 filter("owner.username:lower = {:owner} && name:lower = {:name}", { owner: owner.toLowerCase(), name: name.toLowerCase() }),
87- { expand: EXPAND },
88+ { expand: REPO_EXPAND },
8889 );
8990 return row ? toRepo(row) : null;
9091 }
9192
9293 export async function findRepoById(id: string) {
93- const row = await findById("repos", id, { expand: EXPAND });
94+ const row = await findById("repos", id, { expand: REPO_EXPAND });
9495 return row ? toRepo(row) : null;
9596 }
9697
@@ -139,17 +140,20 @@ export async function visibleParent(repo: RepoRecord, viewerId: string | null |
139140 return repo.forkOf && (await repoAccess(repo.forkOf, viewerId)) ? repo.forkOf : null;
140141 }
141142
142- /** Repos a user may see: public ones, their own, ones they collaborate on, and their organizations'. */
143- const visibleTo = (viewerId: string | null) =>
143+ /**
144+ * Repos a user may see: public ones, their own, ones they collaborate on, and their organizations'.
145+ * `via` filters records that point at a repository instead, e.g. "repo." for those with a `repo` field.
146+ */
147+ export const visibleTo = (viewerId: string | null, via = "") =>
144148 viewerId
145149 ? filter(
146- "(visibility = 'public' || owner = {:viewer} || collaborators_via_repo.user ?= {:viewer} || owner.org_members_via_org.user ?= {:viewer})",
150+ `(${via}visibility = 'public' || ${via}owner = {:viewer} || ${via}collaborators_via_repo.user ?= {:viewer} || ${via}owner.org_members_via_org.user ?= {:viewer})`,
147151 { viewer: viewerId },
148152 )
149- : "visibility = 'public'";
153+ : `${via}visibility = 'public'`;
150154
151155 async function list(where: string, limit?: number) {
152- const options = { filter: where, sort: "-activity_at", expand: EXPAND };
156+ const options = { filter: where, sort: "-activity_at", expand: REPO_EXPAND };
153157 const rows = limit ? (await (await repos()).getList(1, limit, { ...options, skipTotal: true })).items : await (await repos()).getFullList(options);
154158 return rows.map(toRepo);
155159 }
+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+ );
+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())) {
+1−0docs/features.mdView File
@@ -5,6 +5,7 @@
55 - **Repositories**: public or private, rename, delete, optional README on creation
66 - **Collaborators**: give other users push access to a repository
77 - **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+ - **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
89 - **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
910 - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys*
1011 - **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