chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { GlobeIcon, LockIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Input } from '#/components/atoms/input.jsx';
import { Label } from '#/components/atoms/label.jsx';
import { Textarea } from '#/components/atoms/textarea.jsx';
import { Screen } from '#/components/molecules/screen.jsx';
import { createRepo } from '#/lib/create.js';
import { account, loadRepos } from '#/lib/git.js';
import { useNavigate } from '#/lib/navigation.jsx';
import { useSession } from '#/lib/session.jsx';
import { spaceSupport } from '#/lib/spaceSupport.js';
import { cn } from '#/lib/utils.js';
/** The characters a repository name may hold, from the server's own rule. */
const NAME = /^[A-Za-z0-9._~-]{1,512}$/;
/** The two visibilities, each with the sentence that says what it means. */
const VISIBILITIES = [
{
value: 'public',
label: 'Public',
Icon: GlobeIcon,
says: 'Anyone on the network can read it.',
},
{
value: 'private',
label: 'Private',
Icon: LockIcon,
says: 'Only people you add can read it.',
},
];
/**
* Creating a repository, on a page of its own.
*
* The forge is one account's, so the owner is the account and the form only
* asks for the name, a description and the visibility. A private repository
* lives in a permissioned space, which the account's own PDS has to serve;
* the form offers one only when it does. The space itself needs nothing: one
* is created for the repository, keyed by its name, and members join from the
* repository page.
*/
export function NewRepoPage() {
const { session } = useSession();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [visibility, setVisibility] = useState('public');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
// Whether the account's PDS serves the spaces a private repository needs.
const { data: supported } = useQuery({
queryKey: ['space-support'],
queryFn: spaceSupport,
staleTime: Number.POSITIVE_INFINITY,
});
const mine = Boolean(session && session.did === account.did);
const validName = NAME.test(name) && name !== '.' && name !== '..';
const submit = async (event) => {
event.preventDefault();
if (!validName || !mine) return;
setBusy(true);
setError('');
try {
const payload =
visibility === 'private'
? { name, description, private: true }
: { name, description };
await createRepo(
/** @type {import('#/lib/oauth.js').Session} */ (session),
payload,
);
// The record the new page reads from is in hand before the page opens,
// the way a merge reloads before the reads that hang off it are.
await loadRepos();
await queryClient.invalidateQueries();
navigate(`/${encodeURIComponent(name)}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
};
return (
<Screen>
{/* A form stands on its own, centred, the way a forge's does: nothing
on this page but the question it asks. */}
<div className="mx-auto w-full max-w-2xl px-(--gutter) py-8">
<h2 className="font-semibold text-[20px] leading-tight tracking-[-0.01em]">
Create a new repository
</h2>
<p className="mt-1.5 text-[13.5px] text-muted-foreground">
A repository is a record in {account.handle}'s own repo, with its
bundles in blob storage. Push to it with the git helper once it
exists.
</p>
{!mine ? (
<p className="mt-6 text-[13.5px] text-muted-foreground">
Creating writes the record into {account.handle}'s own repo, so it
needs this account's sign-in rather than permission from anybody
else. Sign in as {account.handle} from the bar above.
</p>
) : (
<form onSubmit={submit} className="mt-6 flex flex-col gap-5">
<div>
<Label htmlFor="repo-name">Repository name</Label>
<div className="flex items-center gap-2">
<span className="shrink-0 text-[14px] text-faint">
{account.handle} /
</span>
<Input
id="repo-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="my-project"
pattern="[A-Za-z0-9._~\-]{1,512}"
autoFocus
disabled={busy}
/>
</div>
{name && (
<p className="mt-1.5 font-mono text-[12px] text-faint">
atproto://{account.handle}/{name}
</p>
)}
</div>
<div>
<Label htmlFor="repo-description">
Description <span className="text-faint">(optional)</span>
</Label>
<Textarea
id="repo-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="What this repository is for"
rows={2}
maxLength={3000}
className="resize-none"
disabled={busy}
/>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="mb-1.5 font-medium text-[12.5px] text-muted-foreground">
Visibility
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{VISIBILITIES.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setVisibility(option.value)}
aria-pressed={visibility === option.value}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors',
visibility === option.value
? 'border-ring bg-accent'
: 'border-input bg-background hover:bg-accent',
)}
>
<option.Icon className="mt-0.5 size-4 shrink-0 text-faint" />
<span className="min-w-0">
<span className="block font-semibold text-[13.5px]">
{option.label}
</span>
<span className="mt-0.5 block text-[12px] text-faint">
{option.says}
</span>
</span>
</button>
))}
</div>
{visibility === 'private' &&
(supported === undefined ? null : supported ? (
<p className="mt-1 text-[12.5px] text-faint">
Only you can read it until you add people, from the
repository's settings after the first push.
</p>
) : (
<p className="mt-1 text-[12.5px] text-faint">
This server cannot host private repositories yet, so only
public ones can be created here.
</p>
))}
</fieldset>
{error && <p className="text-[12.5px] text-destructive">{error}</p>}
<div className="flex items-center gap-3">
<Button type="submit" disabled={busy || !validName}>
{busy
? 'Creating…'
: visibility === 'private'
? 'Create private repository'
: 'Create repository'}
</Button>
</div>
</form>
)}
</div>
</Screen>
);
}