Vectrum
Sign In

Merge branch 'master' into t3/organization-profile-pictures

# Conflicts:
#	apps/web/app/[owner]/org-profile.tsx
#	docs/features.md
JjohannSocommitted 2 parents ec0f953 9e2c1b7commit647975d

Showing 26 changed files with 1199 additions and 103 deletions.

Merge commit — showing changes against the first parent.

+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, avatarUrl, 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 (
@@ -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, 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+ }
+1−1apps/web/app/organizations/[org]/settings/forms.tsxView File
@@ -66,7 +66,7 @@ export function DeleteOrgZone({ action, name }: { action: Action; name: string }
6666 description="Only an organization without repositories can be deleted. Delete its repositories first."
6767 consequence={
6868 <>
69- 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.
7070 </>
7171 }
7272 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, avatarUrl, Blankslate, Box, ButtonLink, Counter, Label, RefChip
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
@@ -52,9 +52,10 @@ export type RepoRecord = {
5252 wikiDir: string;
5353 };
5454
55- const EXPAND = "owner,fork_of.owner";
55+ export const REPO_EXPAND = "owner,fork_of.owner";
5656
57- function toRepo(r: RecordModel): RepoRecord {
57+ /** A repository from its record, expanded with REPO_EXPAND. */
58+ export function toRepo(r: RecordModel): RepoRecord {
5859 const ownerRecord = r.expand?.owner as RecordModel;
5960 const owner = ownerRecord.username as string;
6061 const parent = r.expand?.fork_of as RecordModel | undefined;
@@ -88,13 +89,13 @@ export async function findRepo(owner: string, name: string) {
8889 const row = await findFirst(
8990 "repos",
9091 filter("owner.username:lower = {:owner} && name:lower = {:name}", { owner: owner.toLowerCase(), name: name.toLowerCase() }),
91- { expand: EXPAND },
92+ { expand: REPO_EXPAND },
9293 );
9394 return row ? toRepo(row) : null;
9495 }
9596
9697 export async function findRepoById(id: string) {
97- const row = await findById("repos", id, { expand: EXPAND });
98+ const row = await findById("repos", id, { expand: REPO_EXPAND });
9899 return row ? toRepo(row) : null;
99100 }
100101
@@ -143,17 +144,20 @@ export async function visibleParent(repo: RepoRecord, viewerId: string | null |
143144 return repo.forkOf && (await repoAccess(repo.forkOf, viewerId)) ? repo.forkOf : null;
144145 }
145146
146- /** Repos a user may see: public ones, their own, ones they collaborate on, and their organizations'. */
147- 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 = "") =>
148152 viewerId
149153 ? filter(
150- "(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})`,
151155 { viewer: viewerId },
152156 )
153- : "visibility = 'public'";
157+ : `${via}visibility = 'public'`;
154158
155159 async function list(where: string, limit?: number) {
156- const options = { filter: where, sort: "-activity_at", expand: EXPAND };
160+ const options = { filter: where, sort: "-activity_at", expand: REPO_EXPAND };
157161 const rows = limit ? (await (await repos()).getList(1, limit, { ...options, skipTotal: true })).items : await (await repos()).getFullList(options);
158162 return rows.map(toRepo);
159163 }
+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. 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`)
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