# Conflicts: # apps/web/app/page.tsx # apps/web/lib/users.ts
Showing 48 changed files with 1619 additions and 233 deletions.
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { getViewer } from "@/lib/auth"; |
| 2 | 2 | import { normalizeRepoName } from "@/lib/git"; |
| 3 | 3 | import { findRepo, getRepoImage, repoAccess } from "@/lib/repos"; |
| 4 | + import { imageResponse } from "@/lib/uploaded-images"; | |
| 4 | 5 | |
| 5 | 6 | /** The repository's uploaded image. URLs carry `?v=<upload time>`, so responses can be cached for good. */ |
| 6 | 7 | export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/image">) { |
| @@ -11,12 +12,5 @@ export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/ | ||
| 11 | 12 | |
| 12 | 13 | const image = await getRepoImage(repo.id); |
| 13 | 14 | if (!image) return new Response("Not found", { status: 404 }); |
| 14 | - return new Response(new Uint8Array(image.data), { | |
| 15 | - headers: { | |
| 16 | - "Content-Type": image.contentType, | |
| 17 | - "X-Content-Type-Options": "nosniff", | |
| 18 | - "Cache-Control": `${repo.visibility === "private" ? "private" : "public"}, max-age=31536000, immutable`, | |
| 19 | - "Content-Security-Policy": "default-src 'none'; sandbox", | |
| 20 | - }, | |
| 21 | - }); | |
| 15 | + return imageResponse(image, repo.visibility); | |
| 22 | 16 | } |
| @@ -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 | + } | |
| @@ -17,7 +17,7 @@ import { | ||
| 17 | 17 | } from "@/components/icons"; |
| 18 | 18 | import { RecentPushes } from "@/components/pulls/recent-pushes"; |
| 19 | 19 | import { TreeView } from "@/components/tree-view"; |
| 20 | - import { Avatar, Blankslate, Box, ButtonLink, inputClass, Label } from "@/components/ui"; | |
| 20 | + import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, inputClass, Label } from "@/components/ui"; | |
| 21 | 21 | import { countOpenPulls } from "@/lib/pulls"; |
| 22 | 22 | import { encodePath, formatDate, timeAgo } from "@/lib/format"; |
| 23 | 23 | import { commitCount, listBranches, listContributors, listFilesWithSizes, listTags } from "@/lib/git"; |
| @@ -244,7 +244,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">) | ||
| 244 | 244 | /> |
| 245 | 245 | ) : ( |
| 246 | 246 | <Link href={`/${repo.owner}`} title={repo.owner} className="inline-flex rounded-full focus-visible:focus-ring"> |
| 247 | - <Avatar name={repo.owner} size={32} /> | |
| 247 | + <Avatar name={repo.owner} src={avatarUrl({ username: repo.owner, avatarVersion: repo.ownerAvatarVersion })} size={32} /> | |
| 248 | 248 | </Link> |
| 249 | 249 | )} |
| 250 | 250 | </section> |
| @@ -6,7 +6,7 @@ import { ConfirmButton } from "@/components/confirm-button"; | ||
| 6 | 6 | import { PeopleIcon } from "@/components/icons"; |
| 7 | 7 | import { SettingsSection } from "@/components/metal"; |
| 8 | 8 | import { ListRow, PlateSection } from "@/components/settings-plate"; |
| 9 | - import { Avatar, Blankslate, Label, PageTitle } from "@/components/ui"; | |
| 9 | + import { Avatar, avatarUrl, Blankslate, Label, PageTitle } from "@/components/ui"; | |
| 10 | 10 | import { timeAgo } from "@/lib/format"; |
| 11 | 11 | import { orgRole } from "@/lib/orgs"; |
| 12 | 12 | import { getRepo } from "@/lib/repo"; |
| @@ -30,7 +30,7 @@ export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/ | ||
| 30 | 30 | <div className="flex flex-col gap-7"> |
| 31 | 31 | <SettingsSection title={`People with access · ${collaborators.length + 1}`}> |
| 32 | 32 | <ListRow |
| 33 | - lead={<Avatar name={repo.owner} size={32} />} | |
| 33 | + lead={<Avatar name={repo.owner} src={avatarUrl({ username: repo.owner, avatarVersion: repo.ownerAvatarVersion })} size={32} />} | |
| 34 | 34 | title={ |
| 35 | 35 | <> |
| 36 | 36 | <Link href={`/${repo.owner}`} className="truncate hover:underline"> |
| @@ -1,13 +1,15 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | - import { useActionState, useEffect, useState } from "react"; | |
| 4 | - import { Checkbox } from "@unlocalhosted/metalui"; | |
| 3 | + import { useActionState } from "react"; | |
| 4 | + import Link from "next/link"; | |
| 5 | + import { Checkbox, Select } from "@unlocalhosted/metalui"; | |
| 5 | 6 | import type { FormState } from "@/app/actions/types"; |
| 6 | 7 | import { DeleteZone } from "@/components/delete-zone"; |
| 7 | - import { BookIcon, GlobeIcon, LockIcon, PencilIcon, UndoIcon, UploadIcon } from "@/components/icons"; | |
| 8 | + import { ImageUploadForm } from "@/components/image-upload-form"; | |
| 9 | + import { ArrowRightIcon, BookIcon, GlobeIcon, LockIcon, PencilIcon } from "@/components/icons"; | |
| 8 | 10 | import { ChoiceCards, TextField } from "@/components/settings-fields"; |
| 9 | 11 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 10 | - import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui"; | |
| 12 | + import { Alert, Button, RepoAvatar, type RepoImageRef } from "@/components/ui"; | |
| 11 | 13 | |
| 12 | 14 | type Action = (prev: FormState, formData: FormData) => Promise<FormState>; |
| 13 | 15 | |
| @@ -20,60 +22,16 @@ export function ImageForm({ | ||
| 20 | 22 | resetAction: () => Promise<void>; |
| 21 | 23 | repo: RepoImageRef; |
| 22 | 24 | }) { |
| 23 | - const [preview, setPreview] = useState<string | null>(null); | |
| 24 | - const [state, formAction, pending] = useActionState(async (prev: FormState, formData: FormData) => { | |
| 25 | - const result = await action(prev, formData); | |
| 26 | - if (result?.success) setPreview(null); | |
| 27 | - return result; | |
| 28 | - }, undefined); | |
| 29 | - useEffect(() => () => { | |
| 30 | - if (preview) URL.revokeObjectURL(preview); | |
| 31 | - }, [preview]); | |
| 32 | - | |
| 33 | 25 | return ( |
| 34 | - <PlateBody> | |
| 35 | - <div className="flex flex-wrap items-start gap-5"> | |
| 36 | - <RepoAvatar repo={repo} src={preview ?? undefined} size={72} /> | |
| 37 | - <div className="flex min-w-60 flex-1 flex-col gap-3"> | |
| 38 | - <p className="type-body text-ink2"> | |
| 39 | - Shown next to the repository everywhere. Until you upload one, it’s the owner’s avatar. PNG, JPEG, GIF or WebP, up to 1 MB. | |
| 40 | - </p> | |
| 41 | - <div className="flex flex-wrap items-center gap-2"> | |
| 42 | - <form action={formAction} className="flex items-center gap-2"> | |
| 43 | - {/* The file input stays a real input; its label is drawn as MetalUI's standard cap. */} | |
| 44 | - <label className={buttonClass("default", "has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-green-deep has-focus-visible:outline-solid")}> | |
| 45 | - <UploadIcon /> {repo.imageVersion ? "Replace Image" : "Upload Image"} | |
| 46 | - <input | |
| 47 | - type="file" | |
| 48 | - name="image" | |
| 49 | - accept="image/png,image/jpeg,image/gif,image/webp" | |
| 50 | - required | |
| 51 | - className="sr-only" | |
| 52 | - onChange={(e) => { | |
| 53 | - const file = e.target.files?.[0]; | |
| 54 | - setPreview(file ? URL.createObjectURL(file) : null); | |
| 55 | - }} | |
| 56 | - /> | |
| 57 | - </label> | |
| 58 | - {preview && ( | |
| 59 | - <Button variant="primary" disabled={pending}> | |
| 60 | - {pending ? "Saving…" : "Save Image"} | |
| 61 | - </Button> | |
| 62 | - )} | |
| 63 | - </form> | |
| 64 | - {repo.imageVersion && !preview && ( | |
| 65 | - <form action={resetAction}> | |
| 66 | - <Button variant="invisible" icon={<UndoIcon />}> | |
| 67 | - Reset to Owner’s Avatar | |
| 68 | - </Button> | |
| 69 | - </form> | |
| 70 | - )} | |
| 71 | - </div> | |
| 72 | - {state?.error && <Alert>{state.error}</Alert>} | |
| 73 | - {state?.success && !preview && <Alert kind="success">{state.success}</Alert>} | |
| 74 | - </div> | |
| 75 | - </div> | |
| 76 | - </PlateBody> | |
| 26 | + <ImageUploadForm | |
| 27 | + action={action} | |
| 28 | + resetAction={resetAction} | |
| 29 | + hasImage={!!repo.imageVersion} | |
| 30 | + noun="Image" | |
| 31 | + resetLabel="Reset to Owner’s Avatar" | |
| 32 | + description="Shown next to the repository everywhere. Until you upload one, it’s the owner’s avatar." | |
| 33 | + renderImage={(src) => <RepoAvatar repo={repo} src={src} size={72} />} | |
| 34 | + /> | |
| 77 | 35 | ); |
| 78 | 36 | } |
| 79 | 37 | |
| @@ -94,6 +52,47 @@ export function RenameForm({ action, name }: { action: Action; name: string }) { | ||
| 94 | 52 | ); |
| 95 | 53 | } |
| 96 | 54 | |
| 55 | + /** Moving the repository to another account: one of `owners`, the accounts the viewer may create repositories in. */ | |
| 56 | + export function TransferForm({ action, owners, name }: { action: Action; owners: string[]; name: string }) { | |
| 57 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 58 | + if (owners.length === 0) { | |
| 59 | + return ( | |
| 60 | + <PlateBody> | |
| 61 | + <p className="type-body text-ink2"> | |
| 62 | + Move this repository to an organization you’re an admin or owner of. You don’t have one yet:{" "} | |
| 63 | + <Link href="/organizations/new" className="text-ink underline"> | |
| 64 | + create an organization | |
| 65 | + </Link>{" "} | |
| 66 | + first. | |
| 67 | + </p> | |
| 68 | + </PlateBody> | |
| 69 | + ); | |
| 70 | + } | |
| 71 | + return ( | |
| 72 | + <form action={formAction}> | |
| 73 | + <PlateBody> | |
| 74 | + <p className="type-body text-ink2"> | |
| 75 | + Its issues, pull requests, wiki, releases, packages and collaborators move with it, and the new owner’s access rules apply. Clone | |
| 76 | + URLs and links change to the new owner’s name. | |
| 77 | + </p> | |
| 78 | + <div className="flex max-w-xl flex-wrap items-end gap-2"> | |
| 79 | + <div className="flex flex-col gap-1.5"> | |
| 80 | + <span className="type-ui text-ink">New owner</span> | |
| 81 | + <Select name="owner" aria-label="New owner" defaultValue={owners[0]} options={owners.map((o) => ({ value: o, label: o }))} className="min-w-36" /> | |
| 82 | + </div> | |
| 83 | + <span aria-hidden className="flex h-8 items-center type-content text-ink3"> | |
| 84 | + / {name} | |
| 85 | + </span> | |
| 86 | + <Button icon={<ArrowRightIcon />} disabled={pending}> | |
| 87 | + {pending ? "Transferring…" : "Transfer"} | |
| 88 | + </Button> | |
| 89 | + </div> | |
| 90 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 91 | + </PlateBody> | |
| 92 | + </form> | |
| 93 | + ); | |
| 94 | + } | |
| 95 | + | |
| 97 | 96 | export function GeneralForm({ |
| 98 | 97 | action, |
| 99 | 98 | description, |
| @@ -3,20 +3,24 @@ import { | ||
| 3 | 3 | deleteRepoAction, |
| 4 | 4 | removeRepoImageAction, |
| 5 | 5 | renameRepoAction, |
| 6 | + transferRepoAction, | |
| 6 | 7 | updateFeaturesAction, |
| 7 | 8 | updateRepoAction, |
| 8 | 9 | updateRepoImageAction, |
| 9 | 10 | } from "@/app/actions/repo"; |
| 10 | 11 | import { PlateSection } from "@/components/settings-plate"; |
| 11 | 12 | import { PageTitle } from "@/components/ui"; |
| 13 | + import { repoOwnersFor } from "@/lib/orgs"; | |
| 12 | 14 | import { getRepo } from "@/lib/repo"; |
| 13 | - import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm } from "./forms"; | |
| 15 | + import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm, TransferForm } from "./forms"; | |
| 14 | 16 | |
| 15 | 17 | export const metadata: Metadata = { title: "Settings" }; |
| 16 | 18 | |
| 17 | 19 | export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[repo]/settings">) { |
| 18 | 20 | const { owner, repo: repoParam } = await params; |
| 19 | 21 | const repo = await getRepo(owner, repoParam); |
| 22 | + // Where it can move: accounts the viewer may create repositories in, other than its own. | |
| 23 | + const owners = repo.viewer ? (await repoOwnersFor(repo.viewer)).map((o) => o.username).filter((o) => o !== repo.owner) : []; | |
| 20 | 24 | return ( |
| 21 | 25 | <div> |
| 22 | 26 | <PageTitle title="General" /> |
| @@ -41,6 +45,9 @@ export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[ | ||
| 41 | 45 | <PlateSection title="Features"> |
| 42 | 46 | <FeaturesForm action={updateFeaturesAction.bind(null, repo.owner, repo.name)} wiki={repo.wikiEnabled} /> |
| 43 | 47 | </PlateSection> |
| 48 | + <PlateSection title="Transfer"> | |
| 49 | + <TransferForm action={transferRepoAction.bind(null, repo.owner, repo.name)} owners={owners} name={repo.name} /> | |
| 50 | + </PlateSection> | |
| 44 | 51 | <PlateSection title="Danger zone"> |
| 45 | 52 | <DangerZone action={deleteRepoAction.bind(null, repo.owner, repo.name)} fullName={`${repo.owner}/${repo.name}`} /> |
| 46 | 53 | </PlateSection> |
| @@ -1,36 +1,38 @@ | ||
| 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, avatarUrl, 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 ( |
| 30 | 32 | <div className="mx-auto grid w-full max-w-[1280px] gap-8 px-4 py-10 md:grid-cols-[300px_1fr] md:px-6"> |
| 31 | 33 | <aside className="flex flex-col gap-4"> |
| 32 | 34 | <Box className="p-6 md:sticky md:top-20"> |
| 33 | - <Avatar name={org.name || org.username} size={88} /> | |
| 35 | + <Avatar name={org.name || org.username} src={avatarUrl(org)} size={88} /> | |
| 34 | 36 | <h1 className="mt-5"> |
| 35 | 37 | {org.name && <span className={`block ${heading}`}>{org.name}</span>} |
| 36 | 38 | <span className={`block ${org.name ? "mt-1 type-readout text-ink3" : heading}`}>@{org.username}</span> |
| @@ -66,35 +68,22 @@ export async function OrgProfile({ org, viewer, q }: { org: User; viewer: User | | ||
| 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 | + } | |
| @@ -12,8 +12,10 @@ import { | ||
| 12 | 12 | isOrgRole, |
| 13 | 13 | OrgError, |
| 14 | 14 | removeMember, |
| 15 | + removeOrgAvatar, | |
| 15 | 16 | ROLE_INFO, |
| 16 | 17 | setMemberRole, |
| 18 | + setOrgAvatar, | |
| 17 | 19 | updateOrgProfile, |
| 18 | 20 | } from "@/lib/orgs"; |
| 19 | 21 | import { ActionError, orgForAction, viewerOrThrow } from "./guard"; |
| @@ -65,6 +67,24 @@ export async function updateOrgAction(name: string, _prev: FormState, formData: | ||
| 65 | 67 | }); |
| 66 | 68 | } |
| 67 | 69 | |
| 70 | + export async function updateOrgAvatarAction(name: string, _prev: FormState, formData: FormData) { | |
| 71 | + return attempt(async () => { | |
| 72 | + const { org } = await orgForAction(name, "owner"); | |
| 73 | + const file = formData.get("image"); | |
| 74 | + if (!(file instanceof File)) throw new OrgError("Choose an image to upload."); | |
| 75 | + await setOrgAvatar(org.id, new Uint8Array(await file.arrayBuffer())); | |
| 76 | + // The avatar shows up in sidebars and lists everywhere, beside the organization's repositories too. | |
| 77 | + refresh(); | |
| 78 | + return { success: "Avatar updated." }; | |
| 79 | + }); | |
| 80 | + } | |
| 81 | + | |
| 82 | + export async function removeOrgAvatarAction(name: string) { | |
| 83 | + const { org } = await orgForAction(name, "owner"); | |
| 84 | + await removeOrgAvatar(org.id); | |
| 85 | + refresh(); | |
| 86 | + } | |
| 87 | + | |
| 68 | 88 | export async function deleteOrgAction(name: string, _prev: FormState, formData: FormData) { |
| 69 | 89 | const result = await attempt(async () => { |
| 70 | 90 | const { org } = await orgForAction(name, "owner"); |
| @@ -12,6 +12,7 @@ import { | ||
| 12 | 12 | RepoError, |
| 13 | 13 | setRepoImage, |
| 14 | 14 | setWikiEnabled, |
| 15 | + transferRepo, | |
| 15 | 16 | updateRepo, |
| 16 | 17 | type Visibility, |
| 17 | 18 | } from "@/lib/repos"; |
| @@ -104,6 +105,22 @@ export async function renameRepoAction(owner: string, name: string, _prev: FormS | ||
| 104 | 105 | redirect(path); |
| 105 | 106 | } |
| 106 | 107 | |
| 108 | + /** Moves the repository to an account the viewer may create repositories in: theirs, or an organization they're an admin of. */ | |
| 109 | + export async function transferRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) { | |
| 110 | + let path = ""; | |
| 111 | + const result = await attempt(async () => { | |
| 112 | + const { repo, viewer } = await repoForAction(owner, name, "admin"); | |
| 113 | + const ownerName = String(formData.get("owner") ?? ""); | |
| 114 | + const newOwner = ownerName.toLowerCase() === viewer.username.toLowerCase() ? viewer : await getOrg(ownerName); | |
| 115 | + if (!newOwner || !(await mayCreateRepos(newOwner, viewer))) throw new ActionError(`You can't move repositories to ${ownerName}.`); | |
| 116 | + const moved = await transferRepo(repo, newOwner); | |
| 117 | + revalidatePath("/", "layout"); | |
| 118 | + path = `/${moved.owner}/${moved.name}/settings`; | |
| 119 | + }); | |
| 120 | + if (result?.error) return result; | |
| 121 | + redirect(path); | |
| 122 | + } | |
| 123 | + | |
| 107 | 124 | export async function deleteRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) { |
| 108 | 125 | const result = await attempt(async () => { |
| 109 | 126 | const { repo } = await repoForAction(owner, name, "admin"); |
| @@ -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, 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 | + } | |
| @@ -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 | + } | |
| @@ -0,0 +1,11 @@ | ||
| 1 | + import { getOrg, getOrgAvatar } from "@/lib/orgs"; | |
| 2 | + import { imageResponse } from "@/lib/uploaded-images"; | |
| 3 | + | |
| 4 | + /** The organization's uploaded avatar. URLs carry `?v=<upload time>`, so responses can be cached for good. */ | |
| 5 | + export async function GET(_request: Request, ctx: RouteContext<"/organizations/[org]/avatar">) { | |
| 6 | + const org = await getOrg((await ctx.params).org); | |
| 7 | + const image = org ? await getOrgAvatar(org.id) : null; | |
| 8 | + if (!image) return new Response("Not found", { status: 404 }); | |
| 9 | + // Organizations and their profiles are public. | |
| 10 | + return imageResponse(image, "public"); | |
| 11 | + } | |
| @@ -5,9 +5,10 @@ import { FormField, Select, useToast } from "@unlocalhosted/metalui"; | ||
| 5 | 5 | import type { FormState } from "@/app/actions/types"; |
| 6 | 6 | import { DeleteZone } from "@/components/delete-zone"; |
| 7 | 7 | import { PersonIcon, PlusIcon } from "@/components/icons"; |
| 8 | + import { ImageUploadForm } from "@/components/image-upload-form"; | |
| 8 | 9 | import { ChoiceCards, TextareaField, TextField } from "@/components/settings-fields"; |
| 9 | 10 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 10 | - import { Alert, Button } from "@/components/ui"; | |
| 11 | + import { Alert, Avatar, avatarUrl, Button } from "@/components/ui"; | |
| 11 | 12 | import { ORG_ROLES, ROLE_INFO, type OrgRole } from "@/lib/org-roles"; |
| 12 | 13 | |
| 13 | 14 | type Action = (prev: FormState, formData: FormData) => Promise<FormState>; |
| @@ -34,6 +35,28 @@ export function OrgProfileForm({ action, org }: { action: Action; org: { usernam | ||
| 34 | 35 | ); |
| 35 | 36 | } |
| 36 | 37 | |
| 38 | + export function OrgAvatarForm({ | |
| 39 | + action, | |
| 40 | + resetAction, | |
| 41 | + org, | |
| 42 | + }: { | |
| 43 | + action: Action; | |
| 44 | + resetAction: () => Promise<void>; | |
| 45 | + org: { username: string; name: string; avatarVersion: number | null }; | |
| 46 | + }) { | |
| 47 | + return ( | |
| 48 | + <ImageUploadForm | |
| 49 | + action={action} | |
| 50 | + resetAction={resetAction} | |
| 51 | + hasImage={!!org.avatarVersion} | |
| 52 | + noun="Avatar" | |
| 53 | + resetLabel="Remove Avatar" | |
| 54 | + description="Shown on the organization’s profile and next to it everywhere, and on its repositories that have no image of their own. Until you upload one, it’s the organization’s initials." | |
| 55 | + renderImage={(src) => <Avatar name={org.name || org.username} src={src ?? avatarUrl(org)} size={72} />} | |
| 56 | + /> | |
| 57 | + ); | |
| 58 | + } | |
| 59 | + | |
| 37 | 60 | export function DeleteOrgZone({ action, name }: { action: Action; name: string }) { |
| 38 | 61 | return ( |
| 39 | 62 | <DeleteZone |
| @@ -43,7 +66,7 @@ export function DeleteOrgZone({ action, name }: { action: Action; name: string } | ||
| 43 | 66 | description="Only an organization without repositories can be deleted. Delete its repositories first." |
| 44 | 67 | consequence={ |
| 45 | 68 | <> |
| 46 | - This will permanently delete <strong>{name}</strong>, its members and pending invitations. The name becomes free for anyone to take. | |
| 69 | + This will permanently delete <strong>{name}</strong>, its members, lists and pending invitations. The name becomes free for anyone to take. | |
| 47 | 70 | </> |
| 48 | 71 | } |
| 49 | 72 | openLabel="Delete Organization" |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import { GearIcon, PeopleIcon } from "@/components/icons"; |
| 3 | 3 | import { SettingsNav } from "@/components/settings-nav"; |
| 4 | - import { Avatar } from "@/components/ui"; | |
| 4 | + import { Avatar, avatarUrl } from "@/components/ui"; | |
| 5 | 5 | import { getOwnedOrg } from "./load"; |
| 6 | 6 | |
| 7 | 7 | export default async function OrgSettingsLayout({ params, children }: LayoutProps<"/organizations/[org]/settings">) { |
| @@ -10,7 +10,7 @@ export default async function OrgSettingsLayout({ params, children }: LayoutProp | ||
| 10 | 10 | return ( |
| 11 | 11 | <div className="mx-auto w-full max-w-[1100px] px-4 py-10 md:px-6"> |
| 12 | 12 | <div className="mb-8 flex items-center gap-4"> |
| 13 | - <Avatar name={org.name || org.username} size={52} /> | |
| 13 | + <Avatar name={org.name || org.username} src={avatarUrl(org)} size={52} /> | |
| 14 | 14 | <div> |
| 15 | 15 | <h1 className="text-2xl font-semibold tracking-[-0.03em]"> |
| 16 | 16 | <Link href={`/${org.username}`} className="hover:underline"> |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | - import { deleteOrgAction, updateOrgAction } from "@/app/actions/orgs"; | |
| 2 | + import { deleteOrgAction, removeOrgAvatarAction, updateOrgAction, updateOrgAvatarAction } from "@/app/actions/orgs"; | |
| 3 | 3 | import { PlateSection } from "@/components/settings-plate"; |
| 4 | 4 | import { PageTitle } from "@/components/ui"; |
| 5 | - import { DeleteOrgZone, OrgProfileForm } from "./forms"; | |
| 5 | + import { DeleteOrgZone, OrgAvatarForm, OrgProfileForm } from "./forms"; | |
| 6 | 6 | import { getOwnedOrg } from "./load"; |
| 7 | 7 | |
| 8 | 8 | export const metadata: Metadata = { title: "Organization settings" }; |
| @@ -16,6 +16,13 @@ export default async function OrgSettingsPage({ params }: PageProps<"/organizati | ||
| 16 | 16 | <PlateSection title="Profile"> |
| 17 | 17 | <OrgProfileForm action={updateOrgAction.bind(null, org.username)} org={{ username: org.username, name: org.name, bio: org.bio }} /> |
| 18 | 18 | </PlateSection> |
| 19 | + <PlateSection title="Avatar"> | |
| 20 | + <OrgAvatarForm | |
| 21 | + action={updateOrgAvatarAction.bind(null, org.username)} | |
| 22 | + resetAction={removeOrgAvatarAction.bind(null, org.username)} | |
| 23 | + org={{ username: org.username, name: org.name, avatarVersion: org.avatarVersion }} | |
| 24 | + /> | |
| 25 | + </PlateSection> | |
| 19 | 26 | <PlateSection title="Danger zone"> |
| 20 | 27 | <DeleteOrgZone action={deleteOrgAction.bind(null, org.username)} name={org.username} /> |
| 21 | 28 | </PlateSection> |
| @@ -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, |
| @@ -20,11 +22,12 @@ import { | ||
| 20 | 22 | } from "@/components/icons"; |
| 21 | 23 | import { CardDescription, CardTitle } from "@/components/metal"; |
| 22 | 24 | import { RepoFilterList } from "@/components/repo-filter-list"; |
| 23 | - import { Avatar, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvatar } from "@/components/ui"; | |
| 25 | + import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvatar } from "@/components/ui"; | |
| 24 | 26 | import { VerifyEmailNotice } from "@/components/verify-email-notice"; |
| 25 | 27 | import { getViewer } from "@/lib/auth"; |
| 26 | 28 | import { shortSha, timeAgo } from "@/lib/format"; |
| 27 | 29 | import { recentActivity, withActivity } from "@/lib/listing"; |
| 30 | + import { listListsOf, listPath } from "@/lib/lists"; | |
| 28 | 31 | import { MAIL_ENABLED } from "@/lib/mail"; |
| 29 | 32 | import { listInvitationsFor, listOrgsOf } from "@/lib/orgs"; |
| 30 | 33 | import { listReposForUser, searchRepos } from "@/lib/repos"; |
| @@ -129,11 +132,12 @@ function greeting() { | ||
| 129 | 132 | const liftRow = "rounded-row transition-row outline-none hover:recipe-row-list-hover focus-visible:recipe-row-list-hover focus-visible:focus-ring-flush"; |
| 130 | 133 | |
| 131 | 134 | async function Dashboard({ viewer }: { viewer: User }) { |
| 132 | - const [mine, recent, orgs, invitations] = await Promise.all([ | |
| 135 | + const [mine, recent, orgs, invitations, lists] = await Promise.all([ | |
| 133 | 136 | listReposForUser(viewer.id), |
| 134 | 137 | searchRepos("", viewer.id, 30), |
| 135 | 138 | listOrgsOf(viewer.id), |
| 136 | 139 | listInvitationsFor(viewer.id), |
| 140 | + listListsOf(viewer, viewer.id), | |
| 137 | 141 | ]); |
| 138 | 142 | const [activity, explore] = await Promise.all([ |
| 139 | 143 | recentActivity(mine), |
| @@ -152,7 +156,7 @@ async function Dashboard({ viewer }: { viewer: User }) { | ||
| 152 | 156 | New |
| 153 | 157 | </ButtonLink> |
| 154 | 158 | </div> |
| 155 | - <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion }))} /> | |
| 159 | + <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion, ownerAvatarVersion: r.ownerAvatarVersion }))} /> | |
| 156 | 160 | </Box> |
| 157 | 161 | <Box className="p-4"> |
| 158 | 162 | <div className="mb-2 flex items-center justify-between gap-2"> |
| @@ -168,13 +172,40 @@ async function Dashboard({ viewer }: { viewer: User }) { | ||
| 168 | 172 | {orgs.map(({ org }) => ( |
| 169 | 173 | <li key={org.id}> |
| 170 | 174 | <Link href={`/${org.username}`} className={`flex items-center gap-2.5 px-2 py-1.5 ${liftRow}`}> |
| 171 | - <OrganizationIcon width={14} height={14} className="shrink-0 text-ink3" /> | |
| 175 | + {org.avatarVersion ? <Avatar name={org.username} src={avatarUrl(org)} size={16} /> : <OrganizationIcon width={14} height={14} className="shrink-0 text-ink3" />} | |
| 172 | 176 | <span className="truncate type-ui text-ink">{org.username}</span> |
| 173 | 177 | </Link> |
| 174 | 178 | </li> |
| 175 | 179 | ))} |
| 176 | 180 | </ul> |
| 177 | 181 | </Box> |
| 182 | + <Box className="p-4"> | |
| 183 | + <div className="mb-2 flex items-center justify-between gap-2"> | |
| 184 | + <h2 className="flex items-center gap-2 type-label engraved"> | |
| 185 | + Your lists <Counter>{lists.length}</Counter> | |
| 186 | + </h2> | |
| 187 | + <ButtonLink href="/lists/new" variant="invisible" icon={<PlusIcon />}> | |
| 188 | + New | |
| 189 | + </ButtonLink> | |
| 190 | + </div> | |
| 191 | + {lists.length === 0 && <p className="type-body text-ink3">Save repositories to lists with the Save key on their page, to find them again or share them.</p>} | |
| 192 | + <ul className="-mx-2 flex flex-col gap-0.5"> | |
| 193 | + {lists.slice(0, 8).map((list) => ( | |
| 194 | + <li key={list.id}> | |
| 195 | + <Link href={listPath(list)} className={`flex items-center gap-2.5 px-2 py-1.5 ${liftRow}`}> | |
| 196 | + <BookmarkIcon width={14} height={14} className="shrink-0 text-ink3" /> | |
| 197 | + <span className="min-w-0 flex-1 truncate type-ui text-ink">{list.name}</span> | |
| 198 | + {list.visibility === "private" && <LockIcon width={12} height={12} className="shrink-0 text-ink3" aria-label="Private" />} | |
| 199 | + </Link> | |
| 200 | + </li> | |
| 201 | + ))} | |
| 202 | + </ul> | |
| 203 | + {lists.length > 8 && ( | |
| 204 | + <ButtonLink href={`/${viewer.username}?tab=lists`} variant="invisible" icon={<ArrowRightIcon />} className="mt-1 -ml-2"> | |
| 205 | + All Lists | |
| 206 | + </ButtonLink> | |
| 207 | + )} | |
| 208 | + </Box> | |
| 178 | 209 | </aside> |
| 179 | 210 | |
| 180 | 211 | <main className="min-w-0"> |
| @@ -7,7 +7,7 @@ import { GearIcon, OrganizationIcon, PlusIcon } from "@/components/icons"; | ||
| 7 | 7 | import { SettingsSection } from "@/components/metal"; |
| 8 | 8 | import { OrgInvitations } from "@/components/org-invitations"; |
| 9 | 9 | import { ListRow } from "@/components/settings-plate"; |
| 10 | - import { Avatar, Blankslate, ButtonLink, Label, PageTitle } from "@/components/ui"; | |
| 10 | + import { Avatar, avatarUrl, Blankslate, ButtonLink, Label, PageTitle } from "@/components/ui"; | |
| 11 | 11 | import { requireViewer } from "@/lib/auth"; |
| 12 | 12 | import { timeAgo } from "@/lib/format"; |
| 13 | 13 | import { listInvitationsFor, listOrgsOf, ROLE_INFO } from "@/lib/orgs"; |
| @@ -39,7 +39,7 @@ export default async function OrganizationsSettingsPage() { | ||
| 39 | 39 | {memberships.map(({ org, role, joinedAt }) => ( |
| 40 | 40 | <ListRow |
| 41 | 41 | key={org.id} |
| 42 | - lead={<Avatar name={org.name || org.username} size={32} />} | |
| 42 | + lead={<Avatar name={org.name || org.username} src={avatarUrl(org)} size={32} />} | |
| 43 | 43 | title={ |
| 44 | 44 | <> |
| 45 | 45 | <Link href={`/${org.username}`} className="truncate hover:underline"> |
| @@ -13,7 +13,7 @@ import { ButtonLink } from "./ui"; | ||
| 13 | 13 | |
| 14 | 14 | // The sidebar and the search box both list them; one query per request. |
| 15 | 15 | const sidebarRepos = cache(async (viewerId: string) => |
| 16 | - (await listReposForUser(viewerId)).map((r) => ({ owner: r.owner, name: r.name, imageVersion: r.imageVersion })), | |
| 16 | + (await listReposForUser(viewerId)).map((r) => ({ owner: r.owner, name: r.name, imageVersion: r.imageVersion, ownerAvatarVersion: r.ownerAvatarVersion })), | |
| 17 | 17 | ); |
| 18 | 18 | |
| 19 | 19 | const viewerOf = (viewer: User) => ({ username: viewer.username, name: viewer.name }); |
| @@ -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,85 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useActionState, useEffect, useState, type ReactNode } from "react"; | |
| 4 | + import type { FormState } from "@/app/actions/types"; | |
| 5 | + import { UndoIcon, UploadIcon } from "./icons"; | |
| 6 | + import { PlateBody } from "./settings-plate"; | |
| 7 | + import { Alert, Button, buttonClass } from "./ui"; | |
| 8 | + | |
| 9 | + /** | |
| 10 | + * Uploads a picture (a repository's image, an organization's avatar) with a preview of the chosen | |
| 11 | + * file before it's saved, and offers to remove the one there is. | |
| 12 | + */ | |
| 13 | + export function ImageUploadForm({ | |
| 14 | + action, | |
| 15 | + resetAction, | |
| 16 | + hasImage, | |
| 17 | + noun, | |
| 18 | + resetLabel, | |
| 19 | + description, | |
| 20 | + renderImage, | |
| 21 | + }: { | |
| 22 | + action: (prev: FormState, formData: FormData) => Promise<FormState>; | |
| 23 | + resetAction: () => Promise<void>; | |
| 24 | + hasImage: boolean; | |
| 25 | + /** "Image" or "Avatar", for the buttons. */ | |
| 26 | + noun: string; | |
| 27 | + resetLabel: string; | |
| 28 | + description: ReactNode; | |
| 29 | + /** The picture as it's shown, or with `src` (the chosen file) instead. */ | |
| 30 | + renderImage: (src?: string) => ReactNode; | |
| 31 | + }) { | |
| 32 | + const [preview, setPreview] = useState<string | null>(null); | |
| 33 | + const [state, formAction, pending] = useActionState(async (prev: FormState, formData: FormData) => { | |
| 34 | + const result = await action(prev, formData); | |
| 35 | + if (result?.success) setPreview(null); | |
| 36 | + return result; | |
| 37 | + }, undefined); | |
| 38 | + useEffect(() => () => { | |
| 39 | + if (preview) URL.revokeObjectURL(preview); | |
| 40 | + }, [preview]); | |
| 41 | + | |
| 42 | + return ( | |
| 43 | + <PlateBody> | |
| 44 | + <div className="flex flex-wrap items-start gap-5"> | |
| 45 | + {renderImage(preview ?? undefined)} | |
| 46 | + <div className="flex min-w-60 flex-1 flex-col gap-3"> | |
| 47 | + <p className="type-body text-ink2">{description} PNG, JPEG, GIF or WebP, up to 1 MB.</p> | |
| 48 | + <div className="flex flex-wrap items-center gap-2"> | |
| 49 | + <form action={formAction} className="flex items-center gap-2"> | |
| 50 | + {/* The file input stays a real input; its label is drawn as MetalUI's standard cap. */} | |
| 51 | + <label className={buttonClass("default", "has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-green-deep has-focus-visible:outline-solid")}> | |
| 52 | + <UploadIcon /> {hasImage ? `Replace ${noun}` : `Upload ${noun}`} | |
| 53 | + <input | |
| 54 | + type="file" | |
| 55 | + name="image" | |
| 56 | + accept="image/png,image/jpeg,image/gif,image/webp" | |
| 57 | + required | |
| 58 | + className="sr-only" | |
| 59 | + onChange={(e) => { | |
| 60 | + const file = e.target.files?.[0]; | |
| 61 | + setPreview(file ? URL.createObjectURL(file) : null); | |
| 62 | + }} | |
| 63 | + /> | |
| 64 | + </label> | |
| 65 | + {preview && ( | |
| 66 | + <Button variant="primary" disabled={pending}> | |
| 67 | + {pending ? "Saving…" : `Save ${noun}`} | |
| 68 | + </Button> | |
| 69 | + )} | |
| 70 | + </form> | |
| 71 | + {hasImage && !preview && ( | |
| 72 | + <form action={resetAction}> | |
| 73 | + <Button variant="invisible" icon={<UndoIcon />}> | |
| 74 | + {resetLabel} | |
| 75 | + </Button> | |
| 76 | + </form> | |
| 77 | + )} | |
| 78 | + </div> | |
| 79 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 80 | + {state?.success && !preview && <Alert kind="success">{state.success}</Alert>} | |
| 81 | + </div> | |
| 82 | + </div> | |
| 83 | + </PlateBody> | |
| 84 | + ); | |
| 85 | + } | |
| @@ -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 | + } | |
| @@ -6,7 +6,7 @@ import type { OrgInvitation } from "@/lib/orgs"; | ||
| 6 | 6 | import { ActionButton } from "./action-button"; |
| 7 | 7 | import { CheckIcon } from "./icons"; |
| 8 | 8 | import { ListRow } from "./settings-plate"; |
| 9 | - import { Avatar, Box, Label } from "./ui"; | |
| 9 | + import { Avatar, avatarUrl, Box, Label } from "./ui"; | |
| 10 | 10 | |
| 11 | 11 | /** Invitations to join organizations, for the person invited: accept (and land on the organization) or decline. */ |
| 12 | 12 | export function OrgInvitations({ invitations }: { invitations: OrgInvitation[] }) { |
| @@ -16,7 +16,7 @@ export function OrgInvitations({ invitations }: { invitations: OrgInvitation[] } | ||
| 16 | 16 | {invitations.map((inv) => ( |
| 17 | 17 | <ListRow |
| 18 | 18 | key={inv.id} |
| 19 | - lead={<Avatar name={inv.org.name || inv.org.username} size={32} />} | |
| 19 | + lead={<Avatar name={inv.org.name || inv.org.username} src={avatarUrl(inv.org)} size={32} />} | |
| 20 | 20 | title={ |
| 21 | 21 | <> |
| 22 | 22 | <span> |
| @@ -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); | |
| @@ -4,9 +4,9 @@ import Link from "next/link"; | ||
| 4 | 4 | import { useState } from "react"; |
| 5 | 5 | import { Field } from "@unlocalhosted/metalui"; |
| 6 | 6 | import { LockIcon, SearchIcon } from "./icons"; |
| 7 | - import { RepoAvatar } from "./ui"; | |
| 7 | + import { RepoAvatar, type RepoImageRef } from "./ui"; | |
| 8 | 8 | |
| 9 | - type Item = { owner: string; name: string; visibility: "public" | "private"; imageVersion: number | null }; | |
| 9 | + type Item = RepoImageRef & { visibility: "public" | "private" }; | |
| 10 | 10 | |
| 11 | 11 | /** The viewer's repositories: a compact search well that filters as you type, then rows that lift on hover. */ |
| 12 | 12 | export function RepoFilterList({ repos }: { repos: Item[] }) { |
| @@ -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 | + } | |
| @@ -4,12 +4,12 @@ import { useRouter } from "next/navigation"; | ||
| 4 | 4 | import { useEffect, useState, useSyncExternalStore } from "react"; |
| 5 | 5 | import { CommandPalette, SearchField, type CommandPaletteItem } from "@unlocalhosted/metalui"; |
| 6 | 6 | import { CompassIcon, GearIcon, HomeIcon, KeyIcon, PlusIcon, SearchIcon, ShieldLockIcon } from "./icons"; |
| 7 | - import { RepoAvatar } from "./ui"; | |
| 7 | + import { RepoAvatar, type RepoImageRef } from "./ui"; | |
| 8 | 8 | |
| 9 | 9 | const noSubscribe = () => () => {}; |
| 10 | 10 | const isApple = () => /Mac|iPhone|iPad/.test(navigator.platform); |
| 11 | 11 | |
| 12 | - export type PaletteRepo = { owner: string; name: string; imageVersion: number | null }; | |
| 12 | + export type PaletteRepo = RepoImageRef; | |
| 13 | 13 | |
| 14 | 14 | /** |
| 15 | 15 | * The header's search well (MetalUI's SearchField) and the ⌘K palette it opens: jump to a repository, |
| @@ -56,11 +56,24 @@ function Identicon({ name, size }: { name: string; size: number }) { | ||
| 56 | 56 | ); |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | + /** Where an account's uploaded avatar is served (organizations set one in their settings), if it has one. */ | |
| 60 | + export const avatarUrl = (account: { username: string; avatarVersion?: number | null }) => | |
| 61 | + account.avatarVersion ? `/organizations/${account.username}/avatar?v=${account.avatarVersion}` : undefined; | |
| 62 | + | |
| 59 | 63 | /** |
| 60 | - * A person: MetalUI's raised disc with their initials. Its sizes are 24, 32 and 44; smaller avatars | |
| 61 | - * (inline beside a name) are the small disc scaled down. | |
| 64 | + * A person or organization: their uploaded avatar in a round cap, or else MetalUI's raised disc with | |
| 65 | + * their initials. Its sizes are 24, 32 and 44; smaller avatars (inline beside a name) are the small | |
| 66 | + * disc scaled down. | |
| 62 | 67 | */ |
| 63 | - export function Avatar({ name, size = 20, className = "" }: { name: string; size?: number; className?: string }) { | |
| 68 | + export function Avatar({ name, size = 20, className = "", src }: { name: string; size?: number; className?: string; src?: string }) { | |
| 69 | + if (src) { | |
| 70 | + return ( | |
| 71 | + <span className={`material-cap inline-flex shrink-0 overflow-hidden rounded-full select-none ${className}`} style={{ width: size, height: size }} aria-hidden> | |
| 72 | + {/* eslint-disable-next-line @next/next/no-img-element -- tiny, already-sized images */} | |
| 73 | + <img src={src} alt="" width={size} height={size} className="h-full w-full object-cover" /> | |
| 74 | + </span> | |
| 75 | + ); | |
| 76 | + } | |
| 64 | 77 | const muSize = size >= 40 ? "large" : size >= 30 ? "regular" : "small"; |
| 65 | 78 | const base = muSize === "large" ? 44 : muSize === "regular" ? 32 : 24; |
| 66 | 79 | return ( |
| @@ -73,13 +86,19 @@ export function Avatar({ name, size = 20, className = "" }: { name: string; size | ||
| 73 | 86 | ); |
| 74 | 87 | } |
| 75 | 88 | |
| 76 | - export type RepoImageRef = { owner: string; name: string; imageVersion: number | null }; | |
| 89 | + export type RepoImageRef = { | |
| 90 | + owner: string; | |
| 91 | + name: string; | |
| 92 | + imageVersion: number | null; | |
| 93 | + /** The owning organization's avatar, shown when the repository has no image of its own. */ | |
| 94 | + ownerAvatarVersion?: number | null; | |
| 95 | + }; | |
| 77 | 96 | |
| 78 | 97 | export const repoImageUrl = (repo: RepoImageRef) => `/${repo.owner}/${repo.name}/image?v=${repo.imageVersion}`; |
| 79 | 98 | |
| 80 | 99 | /** |
| 81 | - * A repository's "logo" on a raised key: the image uploaded in the repository's settings, or else an | |
| 82 | - * identicon engraved from the owner's name. | |
| 100 | + * A repository's "logo" on a raised key: the image uploaded in the repository's settings, or else its | |
| 101 | + * organization's avatar, or else an identicon engraved from the owner's name. | |
| 83 | 102 | */ |
| 84 | 103 | export function RepoAvatar({ |
| 85 | 104 | repo, |
| @@ -93,7 +112,7 @@ export function RepoAvatar({ | ||
| 93 | 112 | /** Show this image instead, e.g. a local preview before uploading. */ |
| 94 | 113 | src?: string; |
| 95 | 114 | }) { |
| 96 | - const image = src ?? (repo.imageVersion ? repoImageUrl(repo) : null); | |
| 115 | + const image = src ?? (repo.imageVersion ? repoImageUrl(repo) : avatarUrl({ username: repo.owner, avatarVersion: repo.ownerAvatarVersion })); | |
| 97 | 116 | return ( |
| 98 | 117 | <span |
| 99 | 118 | className={`material-cap inline-flex shrink-0 overflow-hidden select-none ${className}`} |
| @@ -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 | + } | |
| @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; | ||
| 2 | 2 | import { count, db, filter, findById, findFirst, isUniqueViolation, withLock, type RecordModel } from "./db"; |
| 3 | 3 | import { isValidOwner } from "./git"; |
| 4 | 4 | import { ORG_ROLES, roleAtLeast, type OrgRole } from "./org-roles"; |
| 5 | + import { readStoredImage, uploadedImage } from "./uploaded-images"; | |
| 5 | 6 | import { getUserByUsername, toUser, type User } from "./users"; |
| 6 | 7 | |
| 7 | 8 | export * from "./org-roles"; |
| @@ -56,6 +57,21 @@ export async function updateOrgProfile(orgId: string, data: { displayName: strin | ||
| 56 | 57 | await (await db()).collection("users").update(orgId, { name: data.displayName.trim().slice(0, 100), bio: data.description.trim().slice(0, 300) }); |
| 57 | 58 | } |
| 58 | 59 | |
| 60 | + export async function setOrgAvatar(orgId: string, data: Uint8Array) { | |
| 61 | + const image = uploadedImage(data, "avatar"); | |
| 62 | + if ("error" in image) throw new OrgError(image.error); | |
| 63 | + await (await db()).collection("users").update(orgId, { avatar: image.file, avatar_type: image.type, avatar_updated_at: Date.now() }); | |
| 64 | + } | |
| 65 | + | |
| 66 | + export async function removeOrgAvatar(orgId: string) { | |
| 67 | + await (await db()).collection("users").update(orgId, { avatar: null, avatar_type: "", avatar_updated_at: 0 }); | |
| 68 | + } | |
| 69 | + | |
| 70 | + export async function getOrgAvatar(orgId: string) { | |
| 71 | + const row = await findById("users", orgId); | |
| 72 | + return row?.kind === "org" && row.avatar ? readStoredImage(row, row.avatar, row.avatar_type) : null; | |
| 73 | + } | |
| 74 | + | |
| 59 | 75 | /** Deletes an organization without repositories, and its memberships and invitations with it. */ |
| 60 | 76 | export async function deleteOrg(org: User) { |
| 61 | 77 | const repos = await count("repos", filter("owner = {:id}", { id: org.id })); |
| @@ -16,6 +16,7 @@ import { | ||
| 16 | 16 | import { commitFiles } from "./git-write"; |
| 17 | 17 | import { deleteRepoImages } from "./registry"; |
| 18 | 18 | import { deleteRepoReleaseFiles } from "./releases"; |
| 19 | + import { readStoredImage, uploadedImage } from "./uploaded-images"; | |
| 19 | 20 | import type { AgentPush } from "./branch-rules"; |
| 20 | 21 | import { orgRole, repoAccessOfRole } from "./orgs"; |
| 21 | 22 | import { getPersonByUsername, type User } from "./users"; |
| @@ -39,6 +40,8 @@ export type RepoRecord = { | ||
| 39 | 40 | pushedAt: number | null; |
| 40 | 41 | /** When a custom image was uploaded (used to bust caches), or null to show the owner's avatar. */ |
| 41 | 42 | imageVersion: number | null; |
| 43 | + /** The owning organization's avatar version, shown in place of a missing image (see lib/users.ts). */ | |
| 44 | + ownerAvatarVersion: number | null; | |
| 42 | 45 | wikiEnabled: boolean; |
| 43 | 46 | /** Where agents may push (see lib/branch-rules.ts). */ |
| 44 | 47 | agentPush: AgentPush; |
| @@ -49,9 +52,10 @@ export type RepoRecord = { | ||
| 49 | 52 | wikiDir: string; |
| 50 | 53 | }; |
| 51 | 54 | |
| 52 | - const EXPAND = "owner,fork_of.owner"; | |
| 55 | + export const REPO_EXPAND = "owner,fork_of.owner"; | |
| 53 | 56 | |
| 54 | - function toRepo(r: RecordModel): RepoRecord { | |
| 57 | + /** A repository from its record, expanded with REPO_EXPAND. */ | |
| 58 | + export function toRepo(r: RecordModel): RepoRecord { | |
| 55 | 59 | const ownerRecord = r.expand?.owner as RecordModel; |
| 56 | 60 | const owner = ownerRecord.username as string; |
| 57 | 61 | const parent = r.expand?.fork_of as RecordModel | undefined; |
| @@ -66,6 +70,7 @@ function toRepo(r: RecordModel): RepoRecord { | ||
| 66 | 70 | createdAt: r.created_at, |
| 67 | 71 | pushedAt: orNull(r.pushed_at), |
| 68 | 72 | imageVersion: r.image ? orNull(r.image_updated_at) : null, |
| 73 | + ownerAvatarVersion: ownerRecord.kind === "org" && ownerRecord.avatar ? orNull(ownerRecord.avatar_updated_at) : null, | |
| 69 | 74 | wikiEnabled: !r.wiki_disabled, |
| 70 | 75 | agentPush: r.agent_push || "any", |
| 71 | 76 | forkOf: parent |
| @@ -84,13 +89,13 @@ export async function findRepo(owner: string, name: string) { | ||
| 84 | 89 | const row = await findFirst( |
| 85 | 90 | "repos", |
| 86 | 91 | filter("owner.username:lower = {:owner} && name:lower = {:name}", { owner: owner.toLowerCase(), name: name.toLowerCase() }), |
| 87 | - { expand: EXPAND }, | |
| 92 | + { expand: REPO_EXPAND }, | |
| 88 | 93 | ); |
| 89 | 94 | return row ? toRepo(row) : null; |
| 90 | 95 | } |
| 91 | 96 | |
| 92 | 97 | export async function findRepoById(id: string) { |
| 93 | - const row = await findById("repos", id, { expand: EXPAND }); | |
| 98 | + const row = await findById("repos", id, { expand: REPO_EXPAND }); | |
| 94 | 99 | return row ? toRepo(row) : null; |
| 95 | 100 | } |
| 96 | 101 | |
| @@ -139,17 +144,20 @@ export async function visibleParent(repo: RepoRecord, viewerId: string | null | | ||
| 139 | 144 | return repo.forkOf && (await repoAccess(repo.forkOf, viewerId)) ? repo.forkOf : null; |
| 140 | 145 | } |
| 141 | 146 | |
| 142 | - /** Repos a user may see: public ones, their own, ones they collaborate on, and their organizations'. */ | |
| 143 | - const visibleTo = (viewerId: string | null) => | |
| 147 | + /** | |
| 148 | + * Repos a user may see: public ones, their own, ones they collaborate on, and their organizations'. | |
| 149 | + * `via` filters records that point at a repository instead, e.g. "repo." for those with a `repo` field. | |
| 150 | + */ | |
| 151 | + export const visibleTo = (viewerId: string | null, via = "") => | |
| 144 | 152 | viewerId |
| 145 | 153 | ? filter( |
| 146 | - "(visibility = 'public' || owner = {:viewer} || collaborators_via_repo.user ?= {:viewer} || owner.org_members_via_org.user ?= {:viewer})", | |
| 154 | + `(${via}visibility = 'public' || ${via}owner = {:viewer} || ${via}collaborators_via_repo.user ?= {:viewer} || ${via}owner.org_members_via_org.user ?= {:viewer})`, | |
| 147 | 155 | { viewer: viewerId }, |
| 148 | 156 | ) |
| 149 | - : "visibility = 'public'"; | |
| 157 | + : `${via}visibility = 'public'`; | |
| 150 | 158 | |
| 151 | 159 | async function list(where: string, limit?: number) { |
| 152 | - const options = { filter: where, sort: "-activity_at", expand: EXPAND }; | |
| 160 | + const options = { filter: where, sort: "-activity_at", expand: REPO_EXPAND }; | |
| 153 | 161 | const rows = limit ? (await (await repos()).getList(1, limit, { ...options, skipTotal: true })).items : await (await repos()).getFullList(options); |
| 154 | 162 | return rows.map(toRepo); |
| 155 | 163 | } |
| @@ -342,6 +350,29 @@ export async function renameRepo(repo: RepoRecord, newName: string) { | ||
| 342 | 350 | return (await findRepoById(repo.id))!; |
| 343 | 351 | } |
| 344 | 352 | |
| 353 | + /** | |
| 354 | + * Moves a repository to another account (an organization, or a person's) under the same name. Its | |
| 355 | + * issues, pull requests, wiki, releases, images, forks and collaborators come along: all of them | |
| 356 | + * are kept by the repository's id. The new owner stops being a collaborator: they own it now. | |
| 357 | + */ | |
| 358 | + export async function transferRepo(repo: RepoRecord, newOwner: User) { | |
| 359 | + if (newOwner.id === repo.ownerId) throw new RepoError(`${repo.owner}/${repo.name} already belongs to ${newOwner.username}.`); | |
| 360 | + if (await findRepo(newOwner.username, repo.name)) throw new RepoError(alreadyHas(newOwner, repo.name)); | |
| 361 | + const newDir = repoPath(newOwner.username, repo.name); | |
| 362 | + if (await pathExists(newDir)) throw new RepoError(`A repository directory for ${newOwner.username}/${repo.name} already exists on disk.`); | |
| 363 | + await mkdir(path.dirname(newDir), { recursive: true }); | |
| 364 | + await rename(repo.dir, newDir); | |
| 365 | + try { | |
| 366 | + await (await repos()).update(repo.id, { owner: newOwner.id }); | |
| 367 | + } catch (e) { | |
| 368 | + await rename(newDir, repo.dir); | |
| 369 | + if (isUniqueViolation(e)) throw new RepoError(alreadyHas(newOwner, repo.name)); | |
| 370 | + throw e; | |
| 371 | + } | |
| 372 | + await removeCollaborator(repo.id, newOwner.id); | |
| 373 | + return (await findRepoById(repo.id))!; | |
| 374 | + } | |
| 375 | + | |
| 345 | 376 | /** Deletes the repository and its wiki; PocketBase deletes its issues, runs, releases, collaborators and so on with it. */ |
| 346 | 377 | export async function deleteRepo(repo: RepoRecord) { |
| 347 | 378 | // Pull requests from it to other repositories can't change anymore. Loaded when needed: lib/pulls.ts needs this module. |
| @@ -362,30 +393,10 @@ export async function deleteRepo(repo: RepoRecord) { | ||
| 362 | 393 | // --------------------------------------------------------------------------- |
| 363 | 394 | // Repository image |
| 364 | 395 | |
| 365 | - export const MAX_REPO_IMAGE_BYTES = 1024 * 1024; | |
| 366 | - | |
| 367 | - const IMAGE_TYPES: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" }; | |
| 368 | - | |
| 369 | - /** Recognizes the raster formats we accept by their magic bytes; the browser-reported type isn't trusted. */ | |
| 370 | - function sniffImageType(data: Uint8Array) { | |
| 371 | - const starts = (...bytes: number[]) => bytes.every((b, i) => data[i] === b); | |
| 372 | - if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"; | |
| 373 | - if (starts(0xff, 0xd8, 0xff)) return "image/jpeg"; | |
| 374 | - if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; | |
| 375 | - if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP") return "image/webp"; | |
| 376 | - return null; | |
| 377 | - } | |
| 378 | - | |
| 379 | 396 | export async function setRepoImage(repoId: string, data: Uint8Array) { |
| 380 | - if (data.byteLength === 0) throw new RepoError("Choose an image to upload."); | |
| 381 | - if (data.byteLength > MAX_REPO_IMAGE_BYTES) throw new RepoError("Images can be at most 1 MB."); | |
| 382 | - const contentType = sniffImageType(data); | |
| 383 | - if (!contentType) throw new RepoError("Use a PNG, JPEG, GIF or WebP image."); | |
| 384 | - await (await repos()).update(repoId, { | |
| 385 | - image: new File([new Uint8Array(data)], `image.${IMAGE_TYPES[contentType]}`, { type: contentType }), | |
| 386 | - image_type: contentType, | |
| 387 | - image_updated_at: Date.now(), | |
| 388 | - }); | |
| 397 | + const image = uploadedImage(data, "image"); | |
| 398 | + if ("error" in image) throw new RepoError(image.error); | |
| 399 | + await (await repos()).update(repoId, { image: image.file, image_type: image.type, image_updated_at: Date.now() }); | |
| 389 | 400 | } |
| 390 | 401 | |
| 391 | 402 | export async function removeRepoImage(repoId: string) { |
| @@ -394,12 +405,7 @@ export async function removeRepoImage(repoId: string) { | ||
| 394 | 405 | |
| 395 | 406 | export async function getRepoImage(repoId: string) { |
| 396 | 407 | const row = await findById("repos", repoId); |
| 397 | - if (!row?.image) return null; | |
| 398 | - const pb = await db(); | |
| 399 | - // The file field is protected: downloads need a short-lived file token. | |
| 400 | - const res = await fetch(pb.files.getURL(row, row.image, { token: await pb.files.getToken() })); | |
| 401 | - if (!res.ok) return null; | |
| 402 | - return { contentType: row.image_type as string, data: new Uint8Array(await res.arrayBuffer()) }; | |
| 408 | + return row?.image ? readStoredImage(row, row.image, row.image_type) : null; | |
| 403 | 409 | } |
| 404 | 410 | |
| 405 | 411 | // --------------------------------------------------------------------------- |
| @@ -0,0 +1,48 @@ | ||
| 1 | + import { db, type RecordModel } from "./db"; | |
| 2 | + | |
| 3 | + // Pictures people upload: repositories' images and organizations' avatars. They're stored in | |
| 4 | + // protected PocketBase file fields and served by the app with their sniffed type. | |
| 5 | + | |
| 6 | + export const MAX_UPLOADED_IMAGE_BYTES = 1024 * 1024; | |
| 7 | + | |
| 8 | + const EXTENSIONS: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" }; | |
| 9 | + | |
| 10 | + /** Recognizes the raster formats we accept by their magic bytes; the browser-reported type isn't trusted. */ | |
| 11 | + function sniffImageType(data: Uint8Array) { | |
| 12 | + const starts = (...bytes: number[]) => bytes.every((b, i) => data[i] === b); | |
| 13 | + if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"; | |
| 14 | + if (starts(0xff, 0xd8, 0xff)) return "image/jpeg"; | |
| 15 | + if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; | |
| 16 | + if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP") return "image/webp"; | |
| 17 | + return null; | |
| 18 | + } | |
| 19 | + | |
| 20 | + /** The upload as a file named `<name>.<ext>` to store, or why it's refused. */ | |
| 21 | + export function uploadedImage(data: Uint8Array, name: string): { file: File; type: string } | { error: string } { | |
| 22 | + if (data.byteLength === 0) return { error: "Choose an image to upload." }; | |
| 23 | + if (data.byteLength > MAX_UPLOADED_IMAGE_BYTES) return { error: "Images can be at most 1 MB." }; | |
| 24 | + const type = sniffImageType(data); | |
| 25 | + if (!type) return { error: "Use a PNG, JPEG, GIF or WebP image." }; | |
| 26 | + return { file: new File([new Uint8Array(data)], `${name}.${EXTENSIONS[type]}`, { type }), type }; | |
| 27 | + } | |
| 28 | + | |
| 29 | + /** A stored image's bytes, or null. */ | |
| 30 | + export async function readStoredImage(record: RecordModel, filename: string, type: string) { | |
| 31 | + const pb = await db(); | |
| 32 | + // The file fields are protected: downloads need a short-lived file token. | |
| 33 | + const res = await fetch(pb.files.getURL(record, filename, { token: await pb.files.getToken() })); | |
| 34 | + if (!res.ok) return null; | |
| 35 | + return { contentType: type, data: new Uint8Array(await res.arrayBuffer()) }; | |
| 36 | + } | |
| 37 | + | |
| 38 | + /** The response for a stored image: cacheable for good (URLs carry `?v=<upload time>`) and inert. */ | |
| 39 | + export function imageResponse(image: { contentType: string; data: Uint8Array }, visibility: "public" | "private") { | |
| 40 | + return new Response(new Uint8Array(image.data), { | |
| 41 | + headers: { | |
| 42 | + "Content-Type": image.contentType, | |
| 43 | + "X-Content-Type-Options": "nosniff", | |
| 44 | + "Cache-Control": `${visibility}, max-age=31536000, immutable`, | |
| 45 | + "Content-Security-Policy": "default-src 'none'; sandbox", | |
| 46 | + }, | |
| 47 | + }); | |
| 48 | + } | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { ClientResponseError } from "pocketbase"; |
| 2 | - import { client, db, filter, findById, findFirst, isUniqueViolation, type RecordModel } from "./db"; | |
| 2 | + import { client, db, filter, findById, findFirst, isUniqueViolation, orNull, type RecordModel } from "./db"; | |
| 3 | 3 | import { isValidOwner } from "./git"; |
| 4 | 4 | |
| 5 | 5 | // Accounts are PocketBase auth records: PocketBase hashes and checks passwords and issues the |
| @@ -14,6 +14,8 @@ export type User = { | ||
| 14 | 14 | createdAt: number; |
| 15 | 15 | /** An organization (lib/orgs.ts): it owns repositories, but nobody signs in as it. */ |
| 16 | 16 | isOrg: boolean; |
| 17 | + /** When an avatar was uploaded (used to bust caches), or null to draw the initials. */ | |
| 18 | + avatarVersion: number | null; | |
| 17 | 19 | /** Whether the person confirmed their email address with a link sent to it (lib/emails.ts). */ |
| 18 | 20 | verified: boolean; |
| 19 | 21 | }; |
| @@ -22,7 +24,17 @@ export type User = { | ||
| 22 | 24 | export const parseDate = (value: string) => Date.parse(value.replace(" ", "T")); |
| 23 | 25 | |
| 24 | 26 | export function toUser(r: RecordModel): User { |
| 25 | - return { id: r.id, username: r.username, name: r.name, email: r.email, bio: r.bio, createdAt: parseDate(r.created), isOrg: r.kind === "org", verified: !!r.verified }; | |
| 27 | + return { | |
| 28 | + id: r.id, | |
| 29 | + username: r.username, | |
| 30 | + name: r.name, | |
| 31 | + email: r.email, | |
| 32 | + bio: r.bio, | |
| 33 | + createdAt: parseDate(r.created), | |
| 34 | + isOrg: r.kind === "org", | |
| 35 | + avatarVersion: r.avatar ? orNull(r.avatar_updated_at) : null, | |
| 36 | + verified: !!r.verified, | |
| 37 | + }; | |
| 26 | 38 | } |
| 27 | 39 | |
| 28 | 40 | export function validatePassword(password: string) { |
| @@ -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 | + ); | |
| @@ -0,0 +1,30 @@ | ||
| 1 | + // Uploaded avatars for accounts (for now, organizations set one in their settings; see lib/orgs.ts). | |
| 2 | + // Like a repository's image: one raster file, protected so only the server reads it, with its type | |
| 3 | + // and upload time (avatar_updated_at, which busts caches) beside it. | |
| 4 | + | |
| 5 | + const MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; | |
| 6 | + | |
| 7 | + migrate( | |
| 8 | + (app) => { | |
| 9 | + const users = app.findCollectionByNameOrId("users"); | |
| 10 | + // PocketBase's default users collection comes with an open "avatar" field that takes SVGs. | |
| 11 | + const avatar = users.fields.getByName("avatar"); | |
| 12 | + if (avatar) { | |
| 13 | + avatar.maxSelect = 1; | |
| 14 | + avatar.maxSize = 1024 * 1024; | |
| 15 | + avatar.mimeTypes = MIME_TYPES; | |
| 16 | + avatar.thumbs = []; | |
| 17 | + avatar.protected = true; | |
| 18 | + } else { | |
| 19 | + users.fields.add(new FileField({ name: "avatar", maxSelect: 1, maxSize: 1024 * 1024, mimeTypes: MIME_TYPES, protected: true })); | |
| 20 | + } | |
| 21 | + users.fields.add(new TextField({ name: "avatar_type", max: 20 })); | |
| 22 | + users.fields.add(new NumberField({ name: "avatar_updated_at", onlyInt: true, min: 0 })); | |
| 23 | + app.save(users); | |
| 24 | + }, | |
| 25 | + (app) => { | |
| 26 | + const users = app.findCollectionByNameOrId("users"); | |
| 27 | + for (const name of ["avatar_type", "avatar_updated_at"]) users.fields.removeByName(name); | |
| 28 | + app.save(users); | |
| 29 | + }, | |
| 30 | + ); | |
| @@ -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())) { |
| @@ -3,9 +3,10 @@ | ||
| 3 | 3 | - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions) |
| 4 | 4 | - **Email** through an SMTP server of your own ([Configuration](installation.md#email)): confirming addresses, resetting a forgotten password, organization invitations, collaborator access, and notices when a password or email address changes |
| 5 | 5 | - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password, on the website and in the Android app; SSH keys, API tokens and connected apps work as before |
| 6 | - - **Repositories**: public or private, rename, delete, optional README on creation | |
| 6 | + - **Repositories**: public or private, rename, transfer to an organization (or back to your account), delete, optional README on creation | |
| 7 | 7 | - **Collaborators**: give other users push access to a repository |
| 8 | - - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`) | |
| 8 | + - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Owners can upload an avatar for the organization (PNG, JPEG, GIF or WebP, up to 1 MB) in its *General* settings; its repositories without an image of their own show it too. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`) | |
| 9 | + - **Likes and lists**: anyone signed in can *Like* a repository they can see; the repository shows how many people like it (and who, under *likes*), and a person's profile lists what they like under *Liked*. The *Save* key puts a repository in lists, or makes a new one on the spot. Lists belong to a person or an organization, live at `/lists/<owner>/<list>` and on the profile's *Lists* tab, and are public (anyone can see them) or private (only that person, or the organization's members). Members with *Write* or more manage an organization's lists. A list never shows a repository to someone who couldn't see it anyway, so private repositories can go in public lists | |
| 9 | 10 | - **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 |
| 10 | 11 | - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys* |
| 11 | 12 | - **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 |