Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 26 changed files with 330 additions and 130 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 | } |
| @@ -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,14 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | - import { useActionState, useEffect, useState } from "react"; | |
| 3 | + import { useActionState } from "react"; | |
| 4 | 4 | import { Checkbox } from "@unlocalhosted/metalui"; |
| 5 | 5 | import type { FormState } from "@/app/actions/types"; |
| 6 | 6 | import { DeleteZone } from "@/components/delete-zone"; |
| 7 | - import { BookIcon, GlobeIcon, LockIcon, PencilIcon, UndoIcon, UploadIcon } from "@/components/icons"; | |
| 7 | + import { ImageUploadForm } from "@/components/image-upload-form"; | |
| 8 | + import { BookIcon, GlobeIcon, LockIcon, PencilIcon } from "@/components/icons"; | |
| 8 | 9 | import { ChoiceCards, TextField } from "@/components/settings-fields"; |
| 9 | 10 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 10 | - import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui"; | |
| 11 | + import { Alert, Button, RepoAvatar, type RepoImageRef } from "@/components/ui"; | |
| 11 | 12 | |
| 12 | 13 | type Action = (prev: FormState, formData: FormData) => Promise<FormState>; |
| 13 | 14 | |
| @@ -20,60 +21,16 @@ export function ImageForm({ | ||
| 20 | 21 | resetAction: () => Promise<void>; |
| 21 | 22 | repo: RepoImageRef; |
| 22 | 23 | }) { |
| 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 | 24 | 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> | |
| 25 | + <ImageUploadForm | |
| 26 | + action={action} | |
| 27 | + resetAction={resetAction} | |
| 28 | + hasImage={!!repo.imageVersion} | |
| 29 | + noun="Image" | |
| 30 | + resetLabel="Reset to Owner’s Avatar" | |
| 31 | + description="Shown next to the repository everywhere. Until you upload one, it’s the owner’s avatar." | |
| 32 | + renderImage={(src) => <RepoAvatar repo={repo} src={src} size={72} />} | |
| 33 | + /> | |
| 77 | 34 | ); |
| 78 | 35 | } |
| 79 | 36 | |
| @@ -2,7 +2,7 @@ import Link from "next/link"; | ||
| 2 | 2 | import { CalendarIcon, GearIcon, OrganizationIcon, PeopleIcon, PlusIcon } from "@/components/icons"; |
| 3 | 3 | import { OrgInvitations } from "@/components/org-invitations"; |
| 4 | 4 | import { matches, ProfileTabs, type ProfileTab } from "@/components/profile-tabs"; |
| 5 | - import { Avatar, Box, ButtonLink, Counter, Label } from "@/components/ui"; | |
| 5 | + import { Avatar, avatarUrl, Box, ButtonLink, Counter, Label } from "@/components/ui"; | |
| 6 | 6 | import { formatDate } from "@/lib/format"; |
| 7 | 7 | import { listListsOf } from "@/lib/lists"; |
| 8 | 8 | import { listInvitationsFor, listOrgMembers, orgRole, ROLE_INFO, roleAtLeast } from "@/lib/orgs"; |
| @@ -32,7 +32,7 @@ export async function OrgProfile({ org, viewer, q, tab: wanted }: { org: User; v | ||
| 32 | 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"> |
| 33 | 33 | <aside className="flex flex-col gap-4"> |
| 34 | 34 | <Box className="p-6 md:sticky md:top-20"> |
| 35 | - <Avatar name={org.name || org.username} size={88} /> | |
| 35 | + <Avatar name={org.name || org.username} src={avatarUrl(org)} size={88} /> | |
| 36 | 36 | <h1 className="mt-5"> |
| 37 | 37 | {org.name && <span className={`block ${heading}`}>{org.name}</span>} |
| 38 | 38 | <span className={`block ${org.name ? "mt-1 type-readout text-ink3" : heading}`}>@{org.username}</span> |
| @@ -11,8 +11,10 @@ import { | ||
| 11 | 11 | isOrgRole, |
| 12 | 12 | OrgError, |
| 13 | 13 | removeMember, |
| 14 | + removeOrgAvatar, | |
| 14 | 15 | ROLE_INFO, |
| 15 | 16 | setMemberRole, |
| 17 | + setOrgAvatar, | |
| 16 | 18 | updateOrgProfile, |
| 17 | 19 | } from "@/lib/orgs"; |
| 18 | 20 | import { ActionError, orgForAction, viewerOrThrow } from "./guard"; |
| @@ -64,6 +66,24 @@ export async function updateOrgAction(name: string, _prev: FormState, formData: | ||
| 64 | 66 | }); |
| 65 | 67 | } |
| 66 | 68 | |
| 69 | + export async function updateOrgAvatarAction(name: string, _prev: FormState, formData: FormData) { | |
| 70 | + return attempt(async () => { | |
| 71 | + const { org } = await orgForAction(name, "owner"); | |
| 72 | + const file = formData.get("image"); | |
| 73 | + if (!(file instanceof File)) throw new OrgError("Choose an image to upload."); | |
| 74 | + await setOrgAvatar(org.id, new Uint8Array(await file.arrayBuffer())); | |
| 75 | + // The avatar shows up in sidebars and lists everywhere, beside the organization's repositories too. | |
| 76 | + refresh(); | |
| 77 | + return { success: "Avatar updated." }; | |
| 78 | + }); | |
| 79 | + } | |
| 80 | + | |
| 81 | + export async function removeOrgAvatarAction(name: string) { | |
| 82 | + const { org } = await orgForAction(name, "owner"); | |
| 83 | + await removeOrgAvatar(org.id); | |
| 84 | + refresh(); | |
| 85 | + } | |
| 86 | + | |
| 67 | 87 | export async function deleteOrgAction(name: string, _prev: FormState, formData: FormData) { |
| 68 | 88 | const result = await attempt(async () => { |
| 69 | 89 | const { org } = await orgForAction(name, "owner"); |
| @@ -3,7 +3,7 @@ import { setSavedAction } from "@/app/actions/lists"; | ||
| 3 | 3 | import { ActionButton } from "@/components/action-button"; |
| 4 | 4 | import { BookmarkIcon, CalendarIcon, PencilIcon, XIcon } from "@/components/icons"; |
| 5 | 5 | import { RepoList } from "@/components/repo-list"; |
| 6 | - import { Avatar, Blankslate, Box, ButtonLink, Counter, VisibilityLabel } from "@/components/ui"; | |
| 6 | + import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, Counter, VisibilityLabel } from "@/components/ui"; | |
| 7 | 7 | import { formatDate } from "@/lib/format"; |
| 8 | 8 | import { withActivity } from "@/lib/listing"; |
| 9 | 9 | import { listReposInList } from "@/lib/lists"; |
| @@ -28,7 +28,7 @@ export default async function ListPage({ params }: PageProps<"/lists/[owner]/[sl | ||
| 28 | 28 | </span> |
| 29 | 29 | <div className="min-w-0 flex-1"> |
| 30 | 30 | <Link href={`/${list.owner.username}?tab=lists`} className="inline-flex items-center gap-2 type-ui text-ink2 transition-colors hover:text-ink"> |
| 31 | - <Avatar name={list.owner.name || list.owner.username} size={18} /> {list.owner.username} | |
| 31 | + <Avatar name={list.owner.name || list.owner.username} src={avatarUrl(list.owner)} size={18} /> {list.owner.username} | |
| 32 | 32 | </Link> |
| 33 | 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 | 34 | {list.name} <VisibilityLabel visibility={list.visibility} /> |
| @@ -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 |
| @@ -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> |
| @@ -22,7 +22,7 @@ import { | ||
| 22 | 22 | } from "@/components/icons"; |
| 23 | 23 | import { CardDescription, CardTitle } from "@/components/metal"; |
| 24 | 24 | import { RepoFilterList } from "@/components/repo-filter-list"; |
| 25 | - 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"; | |
| 26 | 26 | import { getViewer } from "@/lib/auth"; |
| 27 | 27 | import { shortSha, timeAgo } from "@/lib/format"; |
| 28 | 28 | import { recentActivity, withActivity } from "@/lib/listing"; |
| @@ -154,7 +154,7 @@ async function Dashboard({ viewer }: { viewer: User }) { | ||
| 154 | 154 | New |
| 155 | 155 | </ButtonLink> |
| 156 | 156 | </div> |
| 157 | - <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion }))} /> | |
| 157 | + <RepoFilterList repos={mine.map((r) => ({ owner: r.owner, name: r.name, visibility: r.visibility, imageVersion: r.imageVersion, ownerAvatarVersion: r.ownerAvatarVersion }))} /> | |
| 158 | 158 | </Box> |
| 159 | 159 | <Box className="p-4"> |
| 160 | 160 | <div className="mb-2 flex items-center justify-between gap-2"> |
| @@ -170,7 +170,7 @@ async function Dashboard({ viewer }: { viewer: User }) { | ||
| 170 | 170 | {orgs.map(({ org }) => ( |
| 171 | 171 | <li key={org.id}> |
| 172 | 172 | <Link href={`/${org.username}`} className={`flex items-center gap-2.5 px-2 py-1.5 ${liftRow}`}> |
| 173 | - <OrganizationIcon width={14} height={14} className="shrink-0 text-ink3" /> | |
| 173 | + {org.avatarVersion ? <Avatar name={org.username} src={avatarUrl(org)} size={16} /> : <OrganizationIcon width={14} height={14} className="shrink-0 text-ink3" />} | |
| 174 | 174 | <span className="truncate type-ui text-ink">{org.username}</span> |
| 175 | 175 | </Link> |
| 176 | 176 | </li> |
| @@ -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 }); |
| @@ -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 | + } | |
| @@ -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> |
| @@ -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[] }) { |
| @@ -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}`} |
| @@ -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; |
| @@ -67,6 +70,7 @@ export function toRepo(r: RecordModel): RepoRecord { | ||
| 67 | 70 | createdAt: r.created_at, |
| 68 | 71 | pushedAt: orNull(r.pushed_at), |
| 69 | 72 | imageVersion: r.image ? orNull(r.image_updated_at) : null, |
| 73 | + ownerAvatarVersion: ownerRecord.kind === "org" && ownerRecord.avatar ? orNull(ownerRecord.avatar_updated_at) : null, | |
| 70 | 74 | wikiEnabled: !r.wiki_disabled, |
| 71 | 75 | agentPush: r.agent_push || "any", |
| 72 | 76 | forkOf: parent |
| @@ -366,30 +370,10 @@ export async function deleteRepo(repo: RepoRecord) { | ||
| 366 | 370 | // --------------------------------------------------------------------------- |
| 367 | 371 | // Repository image |
| 368 | 372 | |
| 369 | - export const MAX_REPO_IMAGE_BYTES = 1024 * 1024; | |
| 370 | - | |
| 371 | - const IMAGE_TYPES: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" }; | |
| 372 | - | |
| 373 | - /** Recognizes the raster formats we accept by their magic bytes; the browser-reported type isn't trusted. */ | |
| 374 | - function sniffImageType(data: Uint8Array) { | |
| 375 | - const starts = (...bytes: number[]) => bytes.every((b, i) => data[i] === b); | |
| 376 | - if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"; | |
| 377 | - if (starts(0xff, 0xd8, 0xff)) return "image/jpeg"; | |
| 378 | - if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; | |
| 379 | - if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP") return "image/webp"; | |
| 380 | - return null; | |
| 381 | - } | |
| 382 | - | |
| 383 | 373 | export async function setRepoImage(repoId: string, data: Uint8Array) { |
| 384 | - if (data.byteLength === 0) throw new RepoError("Choose an image to upload."); | |
| 385 | - if (data.byteLength > MAX_REPO_IMAGE_BYTES) throw new RepoError("Images can be at most 1 MB."); | |
| 386 | - const contentType = sniffImageType(data); | |
| 387 | - if (!contentType) throw new RepoError("Use a PNG, JPEG, GIF or WebP image."); | |
| 388 | - await (await repos()).update(repoId, { | |
| 389 | - image: new File([new Uint8Array(data)], `image.${IMAGE_TYPES[contentType]}`, { type: contentType }), | |
| 390 | - image_type: contentType, | |
| 391 | - image_updated_at: Date.now(), | |
| 392 | - }); | |
| 374 | + const image = uploadedImage(data, "image"); | |
| 375 | + if ("error" in image) throw new RepoError(image.error); | |
| 376 | + await (await repos()).update(repoId, { image: image.file, image_type: image.type, image_updated_at: Date.now() }); | |
| 393 | 377 | } |
| 394 | 378 | |
| 395 | 379 | export async function removeRepoImage(repoId: string) { |
| @@ -398,12 +382,7 @@ export async function removeRepoImage(repoId: string) { | ||
| 398 | 382 | |
| 399 | 383 | export async function getRepoImage(repoId: string) { |
| 400 | 384 | const row = await findById("repos", repoId); |
| 401 | - if (!row?.image) return null; | |
| 402 | - const pb = await db(); | |
| 403 | - // The file field is protected: downloads need a short-lived file token. | |
| 404 | - const res = await fetch(pb.files.getURL(row, row.image, { token: await pb.files.getToken() })); | |
| 405 | - if (!res.ok) return null; | |
| 406 | - return { contentType: row.image_type as string, data: new Uint8Array(await res.arrayBuffer()) }; | |
| 385 | + return row?.image ? readStoredImage(row, row.image, row.image_type) : null; | |
| 407 | 386 | } |
| 408 | 387 | |
| 409 | 388 | // --------------------------------------------------------------------------- |
| @@ -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,13 +14,24 @@ 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 | }; |
| 18 | 20 | |
| 19 | 21 | /** PocketBase dates read "2026-01-02 03:04:05.678Z". */ |
| 20 | 22 | export const parseDate = (value: string) => Date.parse(value.replace(" ", "T")); |
| 21 | 23 | |
| 22 | 24 | export function toUser(r: RecordModel): User { |
| 23 | - return { id: r.id, username: r.username, name: r.name, email: r.email, bio: r.bio, createdAt: parseDate(r.created), isOrg: r.kind === "org" }; | |
| 25 | + return { | |
| 26 | + id: r.id, | |
| 27 | + username: r.username, | |
| 28 | + name: r.name, | |
| 29 | + email: r.email, | |
| 30 | + bio: r.bio, | |
| 31 | + createdAt: parseDate(r.created), | |
| 32 | + isOrg: r.kind === "org", | |
| 33 | + avatarVersion: r.avatar ? orNull(r.avatar_updated_at) : null, | |
| 34 | + }; | |
| 24 | 35 | } |
| 25 | 36 | |
| 26 | 37 | export function validatePassword(password: string) { |
| @@ -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 | + ); | |
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | - **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 |
| 5 | 5 | - **Repositories**: public or private, rename, delete, optional README on creation |
| 6 | 6 | - **Collaborators**: give other users push access to a repository |
| 7 | - - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`) | |
| 7 | + - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Owners can upload an avatar for the organization (PNG, JPEG, GIF or WebP, up to 1 MB) in its *General* settings; its repositories without an image of their own show it too. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`) | |
| 8 | 8 | - **Likes and lists**: anyone signed in can *Like* a repository they can see; the repository shows how many people like it (and who, under *likes*), and a person's profile lists what they like under *Liked*. The *Save* key puts a repository in lists, or makes a new one on the spot. Lists belong to a person or an organization, live at `/lists/<owner>/<list>` and on the profile's *Lists* tab, and are public (anyone can see them) or private (only that person, or the organization's members). Members with *Write* or more manage an organization's lists. A list never shows a repository to someone who couldn't see it anyway, so private repositories can go in public lists. The Android app does all of this too |
| 9 | 9 | - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private |
| 10 | 10 | - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys* |