chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { Dialog as BaseDialog } from '@base-ui/react/dialog';
import { useQuery } from '@tanstack/react-query';
import { ExternalLinkIcon, GlobeIcon } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { RowIcon } from '#/components/atoms/row-icon.jsx';
import { Dialog } from '#/components/molecules/dialog.jsx';
import { EmptyState } from '#/components/molecules/empty-state.jsx';
import { ListRow } from '#/components/molecules/list-row.jsx';
import { TextLink } from '#/components/molecules/text-link.jsx';
import { get, post } from '#/lib/api.js';
import { bytes, num, timeAgo } from '#/lib/format.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';
/** The expanded site's manifest: every served path with its size and type. */
function SiteFiles({ site }) {
const { data, error } = useQuery({
queryKey: queryKeys.siteFiles(site.name),
queryFn: () => get(`/sites/files?name=${encodeURIComponent(site.name)}`),
});
if (error) {
return (
<div className="px-5 py-3">
<Notice variant="error">{error.message}</Notice>
</div>
);
}
if (!data) {
return (
<div className="px-5 py-3 text-[12px] text-muted-foreground">
Loading files…
</div>
);
}
return (
<div className="max-h-72 overflow-y-auto rounded-2xl border border-border bg-card px-5 py-2">
{data.files.map((file) => (
<div
key={file.path}
className="flex items-center gap-3 border-border border-b py-2 text-[12.5px] last:border-b-0"
>
<TextLink
external
href={`${site.url}/${file.path}`}
className="min-w-0 flex-1 truncate font-mono font-normal"
>
/{file.path}
</TextLink>
<span className="shrink-0 text-muted-foreground">
{file.contentType}
</span>
<span className="w-16 shrink-0 text-right text-muted-foreground">
{bytes(file.size)}
</span>
</div>
))}
</div>
);
}
/** A form generated from an install's settings schema snapshot. */
function InstallSettings({ install }) {
const [draft, setDraft] = useState(install.config);
const [site, setSite] = useState(install.site || '');
const action = useApiMutation([queryKeys.installs, queryKeys.sites]);
const dirty =
JSON.stringify(draft) !== JSON.stringify(install.config) ||
site !== install.site;
const save = () =>
action.run(async () => {
if (site !== install.site) {
await post('/installs/rename', { uri: install.uri, site });
}
if (JSON.stringify(draft) !== JSON.stringify(install.config)) {
await post('/installs/config', { uri: install.uri, config: draft });
}
});
return (
<div className="rounded-2xl border border-border bg-card px-5 py-4">
{action.error && <Notice variant="error">{action.error}</Notice>}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4 text-[13px]">
<span className="text-muted-foreground">Subdomain</span>
<input
type="text"
aria-label="Subdomain"
value={site}
onChange={(event) => setSite(event.target.value)}
className="w-32 rounded-lg border border-border bg-background px-2 py-1 text-right font-mono text-[12.5px]"
/>
</div>
{install.settings.map((field) => (
<div
key={field.key}
className="flex items-center justify-between gap-4 text-[13px]"
>
<span className="text-muted-foreground">{field.label}</span>
{field.type === 'boolean' && (
<input
type="checkbox"
aria-label={field.label}
checked={Boolean(draft[field.key])}
onChange={(event) =>
setDraft({ ...draft, [field.key]: event.target.checked })
}
/>
)}
{field.type === 'string' && (
<input
type="text"
aria-label={field.label}
value={String(draft[field.key] ?? '')}
onChange={(event) =>
setDraft({ ...draft, [field.key]: event.target.value })
}
className="rounded-lg border border-border bg-background px-2 py-1 text-[13px]"
/>
)}
{field.type === 'select' && (
<select
aria-label={field.label}
value={String(draft[field.key] ?? '')}
onChange={(event) =>
setDraft({ ...draft, [field.key]: event.target.value })
}
className="rounded-lg border border-border bg-background px-2 py-1 text-[13px]"
>
{(field.options || []).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)}
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<Button size="sm" disabled={!dirty || action.busy} onClick={save}>
Save settings
</Button>
</div>
</div>
);
}
/** Paste an at-uri, see what it is, install it. */
function InstallDialog() {
const [uri, setUri] = useState('');
const [preview, setPreview] = useState(/** @type {any} */ (null));
const [config, setConfig] = useState(/** @type {Record<string, any>} */ ({}));
const [siteName, setSiteName] = useState('');
const action = useApiMutation([queryKeys.installs, queryKeys.sites]);
const [done, setDone] = useState(/** @type {string|null} */ (null));
const loadPreview = () =>
action.run(async () => {
const sheet = await post('/installs/preview', { uri: uri.trim() });
const defaults = {};
for (const field of sheet.settings) {
if (field.default !== undefined) defaults[field.key] = field.default;
}
setConfig(defaults);
setSiteName(sheet.defaultSite || '');
setPreview(sheet);
});
const install = () =>
action.run(async () => {
const result = await post('/installs/install', {
uri: uri.trim(),
site: siteName.trim() || undefined,
config,
});
setDone(result.site);
});
return (
<Dialog
title="Install an app"
trigger={<TextLink>Install an app</TextLink>}
>
<div className="space-y-3 text-[13px]">
{action.error && <Notice variant="error">{action.error}</Notice>}
{done ? (
<p className="text-muted-foreground">
Installed. It serves as <span className="font-mono">{done}</span> on
your site domain, and appears under Installed apps.
</p>
) : !preview ? (
<>
<p className="text-muted-foreground">
Paste the app's manifest address, an{' '}
<span className="font-mono">at://</span> URI naming a{' '}
<span className="font-mono">dev.pdsjs.app.manifest</span> record.
</p>
<input
type="text"
value={uri}
onChange={(event) => setUri(event.target.value)}
placeholder="at://handle/dev.pdsjs.app.manifest/name"
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-[12.5px]"
/>
<div className="flex justify-end">
<Button
size="sm"
disabled={!uri.trim() || action.busy}
onClick={loadPreview}
>
Preview
</Button>
</div>
</>
) : (
<>
<div className="flex items-center gap-3">
{preview.iconUrl ? (
<img
src={preview.iconUrl}
alt=""
className="size-11 rounded-xl bg-muted object-cover"
/>
) : (
<RowIcon>
<GlobeIcon />
</RowIcon>
)}
<div className="min-w-0">
<div className="font-extrabold text-[15px] text-foreground">
{preview.name}
</div>
<div className="text-[12px] text-muted-foreground">
by {preview.author.handle} · {num(preview.files.count)} file
{preview.files.count === 1 ? '' : 's'} ·{' '}
{bytes(preview.files.totalSize)}
</div>
</div>
</div>
{preview.description && (
<p className="text-muted-foreground leading-relaxed">
{preview.description}
</p>
)}
{preview.external && (
<Notice variant="error">
This app talks to services beyond your own PDS.
</Notice>
)}
{preview.pinDrifted && (
<Notice variant="error">
The developer changed this app after publishing the manifest. It
cannot be installed until they publish an update.
</Notice>
)}
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Serves at</span>
<span className="flex items-center gap-1 font-mono text-[12.5px]">
<input
type="text"
aria-label="Subdomain"
value={siteName}
onChange={(event) => setSiteName(event.target.value)}
className="w-28 rounded-lg border border-border bg-background px-2 py-1 text-right"
/>
.{'{'}your domain{'}'}
</span>
</div>
{preview.settings.length > 0 && (
<div className="flex flex-col gap-2 rounded-xl border border-border p-3">
{preview.settings.map((field) => (
<div
key={field.key}
className="flex items-center justify-between gap-4"
>
<span className="text-muted-foreground">{field.label}</span>
{field.type === 'boolean' && (
<input
type="checkbox"
aria-label={field.label}
checked={Boolean(config[field.key])}
onChange={(event) =>
setConfig({
...config,
[field.key]: event.target.checked,
})
}
/>
)}
{field.type === 'string' && (
<input
type="text"
aria-label={field.label}
value={String(config[field.key] ?? '')}
onChange={(event) =>
setConfig({
...config,
[field.key]: event.target.value,
})
}
className="rounded-lg border border-border bg-background px-2 py-1"
/>
)}
{field.type === 'select' && (
<select
aria-label={field.label}
value={String(config[field.key] ?? '')}
onChange={(event) =>
setConfig({
...config,
[field.key]: event.target.value,
})
}
className="rounded-lg border border-border bg-background px-2 py-1"
>
{(field.options || []).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)}
</div>
))}
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setPreview(null)}
>
Back
</Button>
<Button
size="sm"
disabled={action.busy || preview.pinDrifted}
onClick={install}
>
{action.busy ? 'Installing\u2026' : 'Install'}
</Button>
</div>
</>
)}
</div>
</Dialog>
);
}
/** Installed apps: each install record with its settings form. */
function InstalledApps({ domain }) {
const { data, error } = useQuery({
queryKey: queryKeys.installs,
queryFn: () => get('/installs'),
});
const updates = useQuery({
queryKey: queryKeys.installUpdates,
queryFn: () => get('/installs/updates'),
staleTime: 5 * 60 * 1000,
});
const action = useApiMutation([queryKeys.installs, queryKeys.installUpdates]);
const [open, setOpen] = useState(/** @type {string|null} */ (null));
const update = (manifestUri) =>
action.run(async () => {
await post('/installs/install', { uri: manifestUri });
});
if (error || !data || !data.installs.length) return null;
return (
<div className="mt-6 flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="font-extrabold text-[15px] text-foreground">
Installed apps
</h2>
<InstallDialog />
</div>
{action.error && <Notice variant="error">{action.error}</Notice>}
{data.installs.map((install) => (
<div key={install.uri} className="flex flex-col gap-2">
<ListRow
className="px-5 py-4"
media={
install.iconUrl ? (
<img
src={install.iconUrl}
alt=""
className="size-9 rounded-xl bg-muted object-cover"
/>
) : (
<RowIcon>
<GlobeIcon />
</RowIcon>
)
}
title={
<span className="text-[16px]">
{install.name || install.site}
</span>
}
description={
<>
Installed from{' '}
<span className="font-mono">
{install.manifest?.uri?.replace('at://', '')}
</span>
</>
}
actions={
<div className="flex shrink-0 items-center gap-3">
{updates.data?.updates?.[install.uri]?.changed && (
<TextLink
disabled={action.busy}
onClick={() => update(install.manifest?.uri)}
>
Update available
</TextLink>
)}
<TextLink
onClick={() =>
setOpen(open === install.uri ? null : install.uri)
}
>
{open === install.uri ? 'Hide settings' : 'Settings'}
</TextLink>
<TextLink
external
href={`https://${install.site}.${domain}`}
className="gap-1"
>
{install.site}.{domain}
<ExternalLinkIcon className="size-3.5" />
</TextLink>
</div>
}
/>
{open === install.uri && <InstallSettings install={install} />}
</div>
))}
</div>
);
}
/** The address an installer pastes: the manifest at-uri, one click away. */
function CopyInstallLink({ manifestUri }) {
const [copied, setCopied] = useState(false);
return (
<TextLink
onClick={async () => {
await navigator.clipboard.writeText(manifestUri);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? 'Copied' : 'Copy install link'}
</TextLink>
);
}
export function SitesPage() {
const { data, error } = useQuery({
queryKey: queryKeys.sites,
queryFn: () => get('/sites'),
});
const action = useApiMutation([queryKeys.sites, queryKeys.overview]);
const [openFiles, setOpenFiles] = useState(/** @type {string|null} */ (null));
if (error) {
return <Notice variant="error">{error.message}</Notice>;
}
if (!data) return null;
const remove = (name) =>
action.run(async () => {
await post('/sites/delete', { name });
});
if (!data.sites.filter((site) => !site.installed).length) {
return (
<div className="flex flex-col gap-3">
<EmptyState
icon={<GlobeIcon />}
title="No sites deployed"
description={
<>
A site is a folder of files stored in your repo and served at{' '}
<span className="font-mono">name.{data.domain}</span> (the "
{data.apexName}" site serves at {data.domain} itself). Deploy one
with{' '}
<span className="font-mono text-[12px]">
pdsjs-site deploy ./dist --site {data.apexName} --pds
https://your-pds --handle your.handle
</span>
</>
}
/>
<InstalledApps domain={data.domain} />
</div>
);
}
return (
<div className="flex flex-col gap-3">
{data.apexShadowed && (
<Notice variant="error">
This PDS answers at {data.domain} itself, so the "{data.apexName}"
site is unreachable at the apex. Move the PDS to its own subdomain
(pds.{data.domain}, say) to serve the apex site.
</Notice>
)}
{action.error && <Notice variant="error">{action.error}</Notice>}
{data.sites
.filter((site) => !site.installed)
.map((site) => (
<div key={site.name} className="flex flex-col gap-2">
<ListRow
className="px-5 py-4"
media={
<RowIcon>
<GlobeIcon />
</RowIcon>
}
title={
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-[16px]">
{site.name}
</span>
{site.name === data.apexName && (
<Badge variant="scope" className="shrink-0">
apex
</Badge>
)}
{site.publishedApp && (
<Badge variant="scope" className="shrink-0">
published app
</Badge>
)}
{!site.servable && (
<Badge variant="scope" className="shrink-0">
not a valid subdomain
</Badge>
)}
</div>
}
description={
<>
{num(site.fileCount)} file{site.fileCount === 1 ? '' : 's'} ·{' '}
{bytes(site.totalSize)}
{site.deployedAt
? ` · deployed ${timeAgo(site.deployedAt)}`
: ''}
</>
}
actions={
<div className="flex shrink-0 items-center gap-3">
<TextLink
onClick={() =>
setOpenFiles(openFiles === site.name ? null : site.name)
}
>
{openFiles === site.name ? 'Hide files' : 'Files'}
</TextLink>
{site.manifestUri && (
<CopyInstallLink manifestUri={site.manifestUri} />
)}
<TextLink external href={site.url} className="gap-1">
{site.publishedApp
? 'preview'
: site.url.replace(/^https:\/\//, '')}
<ExternalLinkIcon className="size-3.5" />
</TextLink>
<Dialog
title={`Delete ${site.name}?`}
trigger={
<TextLink tone="danger" disabled={action.busy}>
Delete
</TextLink>
}
>
<div className="space-y-3 text-[13px] text-muted-foreground leading-relaxed">
<p>
This removes the site record from your repo and takes{' '}
<span className="font-mono">
{site.url.replace(/^https:\/\//, '')}
</span>{' '}
offline. Its files become unreferenced and this server
cleans them up.
</p>
<p>
A deploy with the same name brings the site back;
unchanged files will upload again.
</p>
</div>
<div className="mt-5 flex justify-end gap-2">
<BaseDialog.Close
render={
<Button variant="ghost" size="sm">
Cancel
</Button>
}
/>
<BaseDialog.Close
render={
<Button
variant="destructive"
size="sm"
onClick={() => remove(site.name)}
>
Delete site
</Button>
}
/>
</div>
</Dialog>
</div>
}
/>
{openFiles === site.name && <SiteFiles site={site} />}
</div>
))}
<InstalledApps domain={data.domain} />
</div>
);
}