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>
Showing 26 changed files with 1199 additions and 103 deletions.
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import { ForkedFrom, ForkLabel } from "@/components/forks"; |
| 3 | 3 | import { ForkIcon } from "@/components/icons"; |
| 4 | + import { LikeButton, SaveButton } from "@/components/repo-save"; | |
| 4 | 5 | import { RepoTabs } from "@/components/repo-tabs"; |
| 5 | 6 | import { ButtonLink, Label, RepoAvatar, VisibilityLabel } from "@/components/ui"; |
| 6 | 7 | import { openIssueCount } from "@/lib/issues"; |
| 8 | + import { countLikes, likes, saveTargets } from "@/lib/lists"; | |
| 7 | 9 | import { countOpenPulls } from "@/lib/pulls"; |
| 8 | 10 | import { countImages } from "@/lib/registry"; |
| 9 | 11 | import { getRepo } from "@/lib/repo"; |
| @@ -24,7 +26,14 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow | ||
| 24 | 26 | const repo = await getRepo(owner, repoParam); |
| 25 | 27 | // Like GitHub, readers only see the tab once the wiki has a page. |
| 26 | 28 | 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 | + ]); | |
| 28 | 37 | |
| 29 | 38 | return ( |
| 30 | 39 | <> |
| @@ -49,7 +58,19 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow | ||
| 49 | 58 | {repo.forkOf && <ForkedFrom parent={parent} className="ml-1 hidden lg:inline-flex" />} |
| 50 | 59 | </div> |
| 51 | 60 | <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 | + )} | |
| 53 | 74 | <ButtonLink href={`${repo.base}/fork`} icon={<ForkIcon />}> |
| 54 | 75 | Fork |
| 55 | 76 | </ButtonLink> |
| @@ -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 | + } | |
| @@ -1,29 +1,31 @@ | ||
| 1 | 1 | 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"; | |
| 5 | 3 | 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"; | |
| 8 | 6 | import { formatDate } from "@/lib/format"; |
| 9 | - import { withActivity } from "@/lib/listing"; | |
| 7 | + import { listListsOf } from "@/lib/lists"; | |
| 10 | 8 | import { listInvitationsFor, listOrgMembers, orgRole, ROLE_INFO, roleAtLeast } from "@/lib/orgs"; |
| 11 | 9 | import { listReposByOwner } from "@/lib/repos"; |
| 12 | 10 | import type { User } from "@/lib/users"; |
| 11 | + import { ListsTab, RepositoriesTab } from "./tabs"; | |
| 13 | 12 | |
| 14 | 13 | const heading = "text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink"; |
| 15 | 14 | |
| 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), | |
| 20 | 20 | 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), | |
| 22 | 23 | ]); |
| 24 | + // Organizations don't like repositories; only people do. | |
| 25 | + const tab = wanted === "likes" ? "repositories" : wanted; | |
| 23 | 26 | // Who belongs to an organization is for its members to see. |
| 24 | 27 | const members = role ? await listOrgMembers(org.id) : []; |
| 25 | 28 | 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; | |
| 27 | 29 | const publicCount = all.filter((r) => r.visibility === "public").length; |
| 28 | 30 | |
| 29 | 31 | return ( |
| @@ -66,35 +68,22 @@ export async function OrgProfile({ org, viewer, q }: { org: User; viewer: User | | ||
| 66 | 68 | <main className="flex min-w-0 flex-col gap-8"> |
| 67 | 69 | {invitation.length > 0 && <OrgInvitations invitations={invitation} />} |
| 68 | 70 | |
| 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.`} /> | |
| 96 | 84 | )} |
| 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> | |
| 98 | 87 | |
| 99 | 88 | {role && ( |
| 100 | 89 | <section> |
| @@ -1,15 +1,14 @@ | ||
| 1 | 1 | import { notFound } from "next/navigation"; |
| 2 | - import { Field } from "@unlocalhosted/metalui"; | |
| 3 | - import { CalendarIcon, PencilIcon, PlusIcon, RepoIcon, SearchIcon } from "@/components/icons"; | |
| 4 | - import { FieldIcon, FieldInput } from "@/components/metal"; | |
| 5 | - import { RepoList } from "@/components/repo-list"; | |
| 6 | - import { Avatar, Blankslate, Box, ButtonLink, Counter } from "@/components/ui"; | |
| 2 | + import { CalendarIcon, PencilIcon } from "@/components/icons"; | |
| 3 | + import { matches, profileTab, ProfileTabs } from "@/components/profile-tabs"; | |
| 4 | + import { Avatar, Box, ButtonLink } from "@/components/ui"; | |
| 7 | 5 | import { getViewer } from "@/lib/auth"; |
| 8 | 6 | import { formatDate } from "@/lib/format"; |
| 9 | - import { withActivity } from "@/lib/listing"; | |
| 7 | + import { countLikedRepos, listListsOf } from "@/lib/lists"; | |
| 10 | 8 | import { listReposByOwner } from "@/lib/repos"; |
| 11 | 9 | import { getUserByUsername } from "@/lib/users"; |
| 12 | 10 | import { OrgProfile } from "./org-profile"; |
| 11 | + import { LikedTab, ListsTab, RepositoriesTab } from "./tabs"; | |
| 13 | 12 | |
| 14 | 13 | export async function generateMetadata({ params }: PageProps<"/[owner]">) { |
| 15 | 14 | const user = await getUserByUsername((await params).owner); |
| @@ -21,11 +20,13 @@ const heading = "text-[22px] leading-7 font-semibold tracking-[-0.025em] text-in | ||
| 21 | 20 | export default async function ProfilePage({ params, searchParams }: PageProps<"/[owner]">) { |
| 22 | 21 | const user = await getUserByUsername((await params).owner); |
| 23 | 22 | 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); | |
| 25 | 26 | 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)]); | |
| 29 | 30 | const isSelf = viewer?.id === user.id; |
| 30 | 31 | const publicCount = all.filter((r) => r.visibility === "public").length; |
| 31 | 32 | |
| @@ -61,33 +62,17 @@ export default async function ProfilePage({ params, searchParams }: PageProps<"/ | ||
| 61 | 62 | </aside> |
| 62 | 63 | |
| 63 | 64 | <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> | |
| 91 | 76 | </main> |
| 92 | 77 | </div> |
| 93 | 78 | ); |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + }); | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -43,7 +43,7 @@ export function DeleteOrgZone({ action, name }: { action: Action; name: string } | ||
| 43 | 43 | description="Only an organization without repositories can be deleted. Delete its repositories first." |
| 44 | 44 | consequence={ |
| 45 | 45 | <> |
| 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. | |
| 47 | 47 | </> |
| 48 | 48 | } |
| 49 | 49 | openLabel="Delete Organization" |
| @@ -5,12 +5,14 @@ import { HomeTerminal } from "@/components/home-terminal"; | ||
| 5 | 5 | import { OrgInvitations } from "@/components/org-invitations"; |
| 6 | 6 | import { |
| 7 | 7 | ArrowRightIcon, |
| 8 | + BookmarkIcon, | |
| 8 | 9 | BranchIcon, |
| 9 | 10 | CodeIcon, |
| 10 | 11 | CommitIcon, |
| 11 | 12 | CompassIcon, |
| 12 | 13 | IssueOpenedIcon, |
| 13 | 14 | KeyIcon, |
| 15 | + LockIcon, | |
| 14 | 16 | OrganizationIcon, |
| 15 | 17 | PencilIcon, |
| 16 | 18 | PeopleIcon, |
| @@ -24,6 +26,7 @@ import { Avatar, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvata | ||
| 24 | 26 | import { getViewer } from "@/lib/auth"; |
| 25 | 27 | import { shortSha, timeAgo } from "@/lib/format"; |
| 26 | 28 | import { recentActivity, withActivity } from "@/lib/listing"; |
| 29 | + import { listListsOf, listPath } from "@/lib/lists"; | |
| 27 | 30 | import { listInvitationsFor, listOrgsOf } from "@/lib/orgs"; |
| 28 | 31 | import { listReposForUser, searchRepos } from "@/lib/repos"; |
| 29 | 32 | import { serverHost } from "@/lib/request-origin"; |
| @@ -127,11 +130,12 @@ function greeting() { | ||
| 127 | 130 | const liftRow = "rounded-row transition-row outline-none hover:recipe-row-list-hover focus-visible:recipe-row-list-hover focus-visible:focus-ring-flush"; |
| 128 | 131 | |
| 129 | 132 | 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([ | |
| 131 | 134 | listReposForUser(viewer.id), |
| 132 | 135 | searchRepos("", viewer.id, 30), |
| 133 | 136 | listOrgsOf(viewer.id), |
| 134 | 137 | listInvitationsFor(viewer.id), |
| 138 | + listListsOf(viewer, viewer.id), | |
| 135 | 139 | ]); |
| 136 | 140 | const [activity, explore] = await Promise.all([ |
| 137 | 141 | recentActivity(mine), |
| @@ -173,6 +177,33 @@ async function Dashboard({ viewer }: { viewer: User }) { | ||
| 173 | 177 | ))} |
| 174 | 178 | </ul> |
| 175 | 179 | </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> | |
| 176 | 207 | </aside> |
| 177 | 208 | |
| 178 | 209 | <main className="min-w-0"> |
| @@ -152,6 +152,10 @@ export const CommitIcon = icon( | ||
| 152 | 152 | <path d="M3 12h5.5M15.5 12H21" /> |
| 153 | 153 | </>, |
| 154 | 154 | ); |
| 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" />); | |
| 155 | 159 | export const BookIcon = icon( |
| 156 | 160 | <> |
| 157 | 161 | <path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z" /> |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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); | |
| @@ -1,16 +1,20 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import { Card } from "@unlocalhosted/metalui"; |
| 3 | + import type { ReactNode } from "react"; | |
| 3 | 4 | import type { ForkParent, RepoRecord } from "@/lib/repos"; |
| 4 | 5 | import { timeAgo } from "@/lib/format"; |
| 5 | - import { ClockIcon, LockIcon, RepoIcon } from "./icons"; | |
| 6 | + import { ClockIcon, HeartIcon, LockIcon, RepoIcon } from "./icons"; | |
| 6 | 7 | import { ForkedFrom, ForkLabel } from "./forks"; |
| 7 | 8 | import { CardDescription, CardTitle } from "./metal"; |
| 8 | 9 | import { RepoAvatar, VisibilityLabel } from "./ui"; |
| 9 | 10 | |
| 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 }; | |
| 11 | 12 | |
| 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 }) { | |
| 14 | 18 | return ( |
| 15 | 19 | <ul className="grid gap-4 sm:grid-cols-2"> |
| 16 | 20 | {repos.map((r) => ( |
| @@ -33,10 +37,18 @@ export function RepoList({ repos, showOwner = true }: { repos: RepoListItem[]; s | ||
| 33 | 37 | <CardDescription className={`mt-1.5 line-clamp-2 ${r.description ? "" : "text-ink3 italic"}`}> |
| 34 | 38 | {r.description || "No description"} |
| 35 | 39 | </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> | |
| 40 | 52 | </Card> |
| 41 | 53 | </li> |
| 42 | 54 | ))} |
| @@ -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 | + } | |
| @@ -11,7 +11,7 @@ import { DATA_ROOT, REPOS_ROOT, WIKIS_ROOT } from "./paths"; | ||
| 11 | 11 | const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; |
| 12 | 12 | const RESERVED = new Set([ |
| 13 | 13 | "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2", |
| 14 | - "mcp", "oauth", "organizations", | |
| 14 | + "mcp", "oauth", "organizations", "lists", | |
| 15 | 15 | ]); |
| 16 | 16 | |
| 17 | 17 | export function isValidName(name: string) { |
| @@ -1,13 +1,17 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | 2 | import { lastActivity, log, type Commit } from "./git"; |
| 3 | + import { countLikes } from "./lists"; | |
| 3 | 4 | import { visibleParent, type RepoRecord } from "./repos"; |
| 4 | 5 | |
| 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 | + */ | |
| 6 | 10 | export async function withActivity(repos: RepoRecord[], viewerId?: string | null) { |
| 7 | 11 | return Promise.all( |
| 8 | 12 | 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 }; | |
| 11 | 15 | }), |
| 12 | 16 | ); |
| 13 | 17 | } |
| @@ -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 | + } | |
| @@ -49,9 +49,10 @@ export type RepoRecord = { | ||
| 49 | 49 | wikiDir: string; |
| 50 | 50 | }; |
| 51 | 51 | |
| 52 | - const EXPAND = "owner,fork_of.owner"; | |
| 52 | + export const REPO_EXPAND = "owner,fork_of.owner"; | |
| 53 | 53 | |
| 54 | - function toRepo(r: RecordModel): RepoRecord { | |
| 54 | + /** A repository from its record, expanded with REPO_EXPAND. */ | |
| 55 | + export function toRepo(r: RecordModel): RepoRecord { | |
| 55 | 56 | const ownerRecord = r.expand?.owner as RecordModel; |
| 56 | 57 | const owner = ownerRecord.username as string; |
| 57 | 58 | const parent = r.expand?.fork_of as RecordModel | undefined; |
| @@ -84,13 +85,13 @@ export async function findRepo(owner: string, name: string) { | ||
| 84 | 85 | const row = await findFirst( |
| 85 | 86 | "repos", |
| 86 | 87 | filter("owner.username:lower = {:owner} && name:lower = {:name}", { owner: owner.toLowerCase(), name: name.toLowerCase() }), |
| 87 | - { expand: EXPAND }, | |
| 88 | + { expand: REPO_EXPAND }, | |
| 88 | 89 | ); |
| 89 | 90 | return row ? toRepo(row) : null; |
| 90 | 91 | } |
| 91 | 92 | |
| 92 | 93 | 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 }); | |
| 94 | 95 | return row ? toRepo(row) : null; |
| 95 | 96 | } |
| 96 | 97 | |
| @@ -139,17 +140,20 @@ export async function visibleParent(repo: RepoRecord, viewerId: string | null | | ||
| 139 | 140 | return repo.forkOf && (await repoAccess(repo.forkOf, viewerId)) ? repo.forkOf : null; |
| 140 | 141 | } |
| 141 | 142 | |
| 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 = "") => | |
| 144 | 148 | viewerId |
| 145 | 149 | ? 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})`, | |
| 147 | 151 | { viewer: viewerId }, |
| 148 | 152 | ) |
| 149 | - : "visibility = 'public'"; | |
| 153 | + : `${via}visibility = 'public'`; | |
| 150 | 154 | |
| 151 | 155 | 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 }; | |
| 153 | 157 | const rows = limit ? (await (await repos()).getList(1, limit, { ...options, skipTotal: true })).items : await (await repos()).getFullList(options); |
| 154 | 158 | return rows.map(toRepo); |
| 155 | 159 | } |
| @@ -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 | + ); | |
| @@ -19,7 +19,7 @@ if (!username || !email) { | ||
| 19 | 19 | // The same rules as createUser() in lib/users.ts and isValidOwner() in lib/git.ts. |
| 20 | 20 | const RESERVED = new Set([ |
| 21 | 21 | "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2", |
| 22 | - "mcp", "oauth", "organizations", | |
| 22 | + "mcp", "oauth", "organizations", "lists", | |
| 23 | 23 | ]); |
| 24 | 24 | const fail = (message) => (console.error(message), process.exit(1)); |
| 25 | 25 | if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(username) || RESERVED.has(username.toLowerCase())) { |
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | - **Repositories**: public or private, rename, delete, optional README on creation |
| 6 | 6 | - **Collaborators**: give other users push access to a repository |
| 7 | 7 | - **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 | |
| 8 | 9 | - **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 |
| 9 | 10 | - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys* |
| 10 | 11 | - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens |
| @@ -18,6 +18,7 @@ Paths are relative to `apps/web`. | ||
| 18 | 18 | - `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 |
| 19 | 19 | - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators |
| 20 | 20 | - `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`) | |
| 21 | 22 | - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/` |
| 22 | 23 | - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens |
| 23 | 24 | - `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 |