chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { CheckIcon, CopyIcon, PlusIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Dialog } from '#/components/molecules/dialog.jsx';
import { SelectField } from '#/components/molecules/select-field.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import { xrpcPost } from '#/lib/api.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';
/** The shell commands GitHub shows after creating a repository, our spelling. */
function pushInstructions(cloneUrl) {
return [`git remote add origin ${cloneUrl}`, 'git push -u origin main'].join(
'\n',
);
}
export function CommandBlock({ text }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="relative rounded-lg border border-border bg-background">
<pre className="overflow-x-auto px-3 py-2 pr-10 font-mono text-[12px] leading-relaxed">
{text}
</pre>
<button
type="button"
aria-label="Copy commands"
onClick={copy}
className="absolute top-2 right-2 text-muted-foreground hover:text-foreground"
>
{copied ? (
<CheckIcon className="size-3.5" />
) : (
<CopyIcon className="size-3.5" />
)}
</button>
</div>
);
}
/**
* The push commands for a repository nothing has been pushed to. The dialog
* shows these right after creating, and the repository page keeps showing
* them until the first push, so closing the dialog loses nothing.
* @param {{name: string, cloneUrl: string, authority: string, isPrivate: boolean}} props
*/
export function SetupInstructions({ name, cloneUrl, authority, isPrivate }) {
return (
<div className="flex flex-col gap-3">
{isPrivate && (
<Notice>
This repository is private. Space members you add can read it; nobody
else can.
</Notice>
)}
<div className="text-[13px] text-muted-foreground">
Nothing has been pushed yet. From an existing local repository:
</div>
<CommandBlock text={pushInstructions(cloneUrl)} />
<div className="text-[13px] text-muted-foreground">
Starting from nothing:
</div>
<CommandBlock
text={[
`git init -b main ${name}`,
`cd ${name}`,
'git add . && git commit -m "initial commit"',
...pushInstructions(cloneUrl).split('\n'),
].join('\n')}
/>
<div className="text-[12px] text-faint">
Pushing needs the helper: npm install -g @pdsjs/git, then
git-remote-atproto login {authority}.
</div>
</div>
);
}
/**
* The dialog behind the New repository button. Creating writes an empty
* repository record at once, private ones into their space, so the list and
* the repository page exist before the first push.
* @param {{data: {handle?: string|null, did: string, features: Record<string, boolean>}, onNavigate: (href: string) => void}} props
*/
export function NewRepoDialog({ data, onNavigate }) {
const authority = data.handle || data.did;
const canPrivate = Boolean(data.features.spaces && data.features.spaceAdmin);
const [name, setName] = useState('');
const [visibility, setVisibility] = useState('public');
/** @type {[{cloneUrl: string, space: string|null}|null, Function]} */
const [created, setCreated] = useState(null);
const action = useApiMutation([
queryKeys.spaces,
queryKeys.gitRepos,
queryKeys.overview,
]);
const validName = /^[A-Za-z0-9._~-]{1,512}$/.test(name);
const submit = async (event) => {
event.preventDefault();
if (!validName) return;
await action.run(async () => {
const payload =
visibility === 'private' ? { name, private: true } : { name };
const { space } = await xrpcPost('dev.pdsjs.git.createRepo', payload);
// Public or private, the address is the same: the helper finds the
// space a private repository keeps under its own name.
setCreated({ space, cloneUrl: `atproto://${authority}/${name}` });
});
};
const repoHref = created
? `/account/git/${encodeURIComponent(name)}${created.space ? `?space=${encodeURIComponent(created.space)}` : ''}`
: '';
return (
<Dialog
trigger={
<Button size="sm">
<PlusIcon /> New repository
</Button>
}
title={created ? name : 'New repository'}
>
{created ? (
<div className="flex flex-col gap-3">
<SetupInstructions
name={name}
cloneUrl={created.cloneUrl}
authority={authority}
isPrivate={Boolean(created.space)}
/>
<div className="text-[12px] text-faint">
These commands stay on the repository page until the first push.
</div>
<Button variant="outline" onClick={() => onNavigate(repoHref)}>
Go to repository
</Button>
</div>
) : (
<form onSubmit={submit} className="flex flex-col gap-4">
<TextField
name="name"
label="Repository name"
placeholder="my-project"
value={name}
onChange={(event) => setName(event.target.value)}
required
pattern="[A-Za-z0-9._~\-]{1,512}"
hint="Letters, digits, and . _ ~ -"
/>
<SelectField
name="visibility"
label="Visibility"
value={visibility}
onChange={(value) => setVisibility(value)}
options={
canPrivate
? [
['public', 'Public'],
['private', 'Private'],
]
: [['public', 'Public']]
}
/>
{!canPrivate && (
<div className="text-[12px] text-faint">
Private repositories need spaces enabled on this server.
</div>
)}
{visibility === 'private' && (
<div className="text-[12px] text-faint">
Only the people you add can read it. Add them from the repository
page.
</div>
)}
{action.error && <Notice variant="error">{action.error}</Notice>}
<Button type="submit" disabled={!validName || action.busy}>
{visibility === 'private'
? 'Create private repository'
: 'Create repository'}
</Button>
</form>
)}
</Dialog>
);
}