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 {
FileIcon,
FolderIcon,
FolderPlusIcon,
HardDriveIcon,
LockIcon,
Trash2Icon,
UploadIcon,
} from 'lucide-react';
import { useRef, useState } from 'react';
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, upload } from '#/lib/api.js';
import { bytes, num, timeAgo } from '#/lib/format.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';
/** The drive page URL for one folder, in the public repo or in a space. */
export function folderHref(folder, space) {
const query = new URLSearchParams();
if (folder) query.set('folder', folder);
if (space) query.set('space', space);
const qs = query.toString();
return qs ? `/account/drive?${qs}` : '/account/drive';
}
/** Where the browser fetches one file's bytes. */
function downloadHref(file, space) {
const query = new URLSearchParams({ rkey: file.rkey });
if (space) query.set('space', space);
return `/account/api/drive/file?${query.toString()}`;
}
/** A read-only value with a button that puts it on the clipboard. */
function CopyField({ label, value, rows }) {
const [copied, setCopied] = useState(false);
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-medium text-[12.5px] text-foreground">
{label}
</span>
<Button
variant="ghost"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<textarea
readOnly
rows={rows ?? 1}
value={value}
onFocus={(event) => event.target.select()}
className="w-full resize-none rounded-lg border bg-muted/40 px-2.5 py-1.5 font-mono text-[12px] text-muted-foreground"
/>
</div>
);
}
/**
* The blob behind a single-blob public file, in the two forms another app
* takes: the public URL its bytes serve from, and the blob ref a record
* embeds. Split files and space files have no public single blob, so the
* dialog only offers on files with a blobCid outside a space.
*/
function LinkFileDialog({ file, did }) {
const origin = typeof window === 'undefined' ? '' : window.location.origin;
const url = `${origin}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(file.blobCid)}`;
const ref = JSON.stringify(
{
$type: 'blob',
ref: { $link: file.blobCid },
mimeType: file.mimeType,
size: file.size,
},
null,
2,
);
return (
<Dialog title={`Link ${file.name}`} trigger={<TextLink>Link</TextLink>}>
<div className="space-y-4">
<p className="text-[13px] text-muted-foreground leading-relaxed">
This file is one blob in your account. Any record can reference it. A
post embed, a Spark video, or a video tag can use these directly.
</p>
<CopyField label="Public URL" value={url} rows={2} />
<CopyField label="Blob ref" value={ref} rows={7} />
</div>
</Dialog>
);
}
/** The path back to the top level, one link per folder along the way. */
function Breadcrumb({ ancestry, space, onNavigate }) {
if (!ancestry?.length) return null;
return (
<div className="flex flex-wrap items-center gap-1 text-[13px] text-muted-foreground">
<TextLink onClick={() => onNavigate(folderHref('', space))}>
All files
</TextLink>
{ancestry.map((entry, index) => {
const last = index === ancestry.length - 1;
return (
<span key={entry.rkey} className="flex items-center gap-1">
<span aria-hidden="true">/</span>
{last ? (
<span className="font-medium text-foreground">{entry.name}</span>
) : (
<TextLink
onClick={() => onNavigate(folderHref(entry.rkey, space))}
>
{entry.name}
</TextLink>
)}
</span>
);
})}
</div>
);
}
/**
* Make a drive that lives in a permissioned space. Its files are private and
* shared with whoever joins the space, and it is a space of the drive's own
* type: another app's space is that app's data model.
*/
function NewDriveDialog({ onCreate, busy }) {
const [name, setName] = useState('');
return (
<Dialog
title="New private drive"
trigger={
<Button variant="ghost" size="sm" disabled={busy}>
<LockIcon className="size-3.5" /> New private drive
</Button>
}
>
<div className="space-y-3 text-[13px]">
<p className="text-muted-foreground leading-relaxed">
A private drive keeps its files out of your public repo. Whoever you
add to it can read them.
</p>
<input
type="text"
aria-label="Drive name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="photos"
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[13px]"
/>
<p className="text-faint text-[12px]">
Lowercase letters, numbers and dashes.
</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
size="sm"
disabled={!name.trim()}
onClick={() => onCreate(name.trim())}
>
Make drive
</Button>
}
/>
</div>
</Dialog>
);
}
/** Make a folder here. It is a record of its own, so an empty one stays. */
function NewFolderDialog({ folder, space, onCreate, busy }) {
const [name, setName] = useState('');
return (
<Dialog
title="New folder"
trigger={
<Button variant="ghost" size="sm" disabled={busy}>
<FolderPlusIcon className="size-4" /> New folder
</Button>
}
>
<div className="space-y-3 text-[13px]">
<input
type="text"
aria-label="Folder name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="photos"
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[13px]"
/>
</div>
<div className="mt-5 flex justify-end gap-2">
<BaseDialog.Close
render={
<Button variant="ghost" size="sm">
Cancel
</Button>
}
/>
<BaseDialog.Close
render={
<Button
size="sm"
disabled={!name.trim()}
onClick={() => onCreate(name.trim(), folder, space)}
>
Make folder
</Button>
}
/>
</div>
</Dialog>
);
}
/** Rename one folder. Every file keeps its own record, so nothing moves. */
function RenameFolderDialog({ entry, space, onRename, busy }) {
const [name, setName] = useState(entry.name);
return (
<Dialog
title={`Rename ${entry.name}`}
trigger={<TextLink disabled={busy}>Rename</TextLink>}
>
<div className="space-y-3 text-[13px]">
<input
type="text"
aria-label="Folder name"
value={name}
onChange={(event) => setName(event.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[13px]"
/>
</div>
<div className="mt-5 flex justify-end gap-2">
<BaseDialog.Close
render={
<Button variant="ghost" size="sm">
Cancel
</Button>
}
/>
<BaseDialog.Close
render={
<Button
size="sm"
disabled={!name.trim()}
onClick={() => onRename(entry.rkey, name.trim(), space)}
>
Rename
</Button>
}
/>
</div>
</Dialog>
);
}
/**
* What deleting put aside. The bytes are still stored, so this says what the
* trash costs and offers to give it back or let it go.
*/
function Trash({ space, busy, onRestore, onPurge }) {
const { data } = useQuery({
queryKey: queryKeys.driveTrash(space || ''),
queryFn: () => {
const query = space ? `?space=${encodeURIComponent(space)}` : '';
return get(`/drive/trash${query}`);
},
});
const [open, setOpen] = useState(false);
if (!data?.enabled || !data.items?.length) return null;
return (
<div className="mt-2 flex flex-col gap-2">
<div className="flex items-center justify-between px-1">
<TextLink onClick={() => setOpen(!open)}>
<Trash2Icon className="size-3.5" />
{open ? 'Hide' : 'Show'} recently deleted ({num(data.items.length)})
</TextLink>
<span className="text-[12px] text-muted-foreground">
{bytes(data.totalSize)} still stored
</span>
</div>
{open &&
data.items.map((item) => (
<ListRow
key={item.rkey}
variant="plain"
media={
<RowIcon>{item.isFolder ? <FolderIcon /> : <FileIcon />}</RowIcon>
}
title={<span className="text-[14px]">{item.name}</span>}
description={
<>
{item.isFolder ? 'Folder' : bytes(item.size)} · deleted{' '}
{timeAgo(item.deletedAt)}
</>
}
actions={
<div className="flex shrink-0 items-center gap-3">
<TextLink disabled={busy} onClick={() => onRestore(item.rkey)}>
Put back
</TextLink>
<Dialog
title={`Delete ${item.name} for good?`}
trigger={
<TextLink tone="danger" disabled={busy}>
Delete for good
</TextLink>
}
>
<div className="space-y-3 text-[13px] text-muted-foreground leading-relaxed">
<p>
This frees the stored bytes. Nothing here can bring them
back afterwards.
</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={() => onPurge(item.rkey)}
>
Delete for good
</Button>
}
/>
</div>
</Dialog>
</div>
}
/>
))}
</div>
);
}
/**
* @param {{data: Object, folder: string, onNavigate: (href: string) => void}} props
*/
export function DrivePage({ data: account, folder, space, onNavigate }) {
const key = { folder, space: space || '' };
const { data, error } = useQuery({
queryKey: queryKeys.driveFiles(key),
queryFn: () => {
const query = new URLSearchParams({ folder });
if (space) query.set('space', space);
return get(`/drive/files?${query.toString()}`);
},
});
const action = useApiMutation([
queryKeys.driveFiles(key),
queryKeys.driveTrash(space || ''),
queryKeys.overview,
]);
const fileInput = useRef(/** @type {HTMLInputElement|null} */ (null));
const [dragging, setDragging] = useState(false);
const [sending, setSending] = useState(/** @type {string|null} */ (null));
const canUpload = Boolean(account.features.driveUpload);
// A file larger than one request body may carry goes up in parts through
// an upload session, and still lands as one blob.
const sendInParts = async (file, partSize) => {
const { id } = await post('/drive/upload/start', {
name: file.name,
mimeType: file.type || undefined,
folder: folder || undefined,
space: space || undefined,
});
try {
for (let at = 0; at < file.size; at += partSize) {
setSending(`${file.name} (${Math.round((at / file.size) * 100)}%)`);
await upload(
`/drive/upload/part?id=${id}`,
file.slice(at, at + partSize),
);
}
setSending(file.name);
await post('/drive/upload/finish', { id });
} catch (e) {
post('/drive/upload/abort', { id }).catch(() => {});
throw e;
}
};
const sendFiles = (files) =>
action.run(async () => {
try {
for (const file of files) {
setSending(file.name);
const partSize = data?.uploadPartSize;
if (partSize && file.size > partSize) {
await sendInParts(file, partSize);
continue;
}
const query = new URLSearchParams({ name: file.name });
if (folder) query.set('folder', folder);
if (space) query.set('space', space);
await upload(`/drive/upload?${query.toString()}`, file);
}
} finally {
setSending(null);
}
});
if (error) return <Notice variant="error">{error.message}</Notice>;
if (!data) return null;
if (!data.enabled) {
return (
<EmptyState
icon={<HardDriveIcon />}
title="Drive is off"
description="This server does not have the drive feature composed in."
/>
);
}
const remove = (rkey) =>
action.run(async () => {
await post('/drive/delete', { rkey, space: space || undefined });
});
const makeFolder = (name, parent, inSpace) =>
action.run(async () => {
await post('/drive/folder', {
name,
parent: parent || undefined,
space: inSpace || undefined,
});
});
const renameFolder = (rkey, name, inSpace) =>
action.run(async () => {
await post('/drive/folder/rename', {
rkey,
name,
space: inSpace || undefined,
});
});
const removeFolder = (rkey) =>
action.run(async () => {
await post('/drive/folder/delete', { rkey, space: space || undefined });
});
const restoreItem = (rkey) =>
action.run(async () => {
await post('/drive/restore', { rkey, space: space || undefined });
});
const purgeItem = (rkey) =>
action.run(async () => {
await post('/drive/purge', { rkey, space: space || undefined });
});
const makeDrive = (name) =>
action.run(async () => {
const made = await post('/drive/space', { name });
onNavigate(folderHref('', made.uri));
});
const empty = data.folders.length === 0 && data.files.length === 0;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: the drop target is the file list, and the button beside it does the same job for the keyboard
<div
className="flex flex-col gap-3"
onDragOver={(event) => {
if (!canUpload) return;
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
if (!canUpload) return;
event.preventDefault();
setDragging(false);
const files = [...event.dataTransfer.files];
if (files.length) sendFiles(files);
}}
>
{(data.spaces?.length > 0 || data.canMakeSpace) && (
<div className="flex flex-wrap items-center gap-2 text-[13px]">
<span className="text-muted-foreground">Drive in</span>
<Button
variant={space ? 'ghost' : 'secondary'}
size="sm"
onClick={() => onNavigate(folderHref('', ''))}
>
Your repo
</Button>
{data.spaces.map((entry) => (
<Button
key={entry.uri}
variant={space === entry.uri ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onNavigate(folderHref('', entry.uri))}
>
<LockIcon className="size-3.5" /> {entry.name}
</Button>
))}
{data.canMakeSpace && (
<NewDriveDialog onCreate={makeDrive} busy={action.busy} />
)}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2">
<Breadcrumb
ancestry={data.ancestry}
space={space}
onNavigate={onNavigate}
/>
{canUpload && (
<div className="ml-auto flex items-center gap-2">
<NewFolderDialog
folder={data.folder}
space={space}
busy={action.busy}
onCreate={makeFolder}
/>
<input
ref={fileInput}
type="file"
multiple
className="hidden"
onChange={(event) => {
const files = [...event.target.files];
event.target.value = '';
if (files.length) sendFiles(files);
}}
/>
<Button
size="sm"
disabled={action.busy}
onClick={() => fileInput.current?.click()}
>
<UploadIcon className="size-4" />
{action.busy ? 'Uploading…' : 'Upload files'}
</Button>
</div>
)}
</div>
{action.error && <Notice variant="error">{action.error}</Notice>}
{sending && (
<p className="px-1 text-[12.5px] text-muted-foreground">
Uploading {sending}…
</p>
)}
{dragging && (
<div className="rounded-2xl border border-key border-dashed px-5 py-8 text-center text-[13px] text-muted-foreground">
Drop to upload into{' '}
<span className="font-medium text-foreground">
{data.path || 'your drive'}
</span>
</div>
)}
{empty ? (
<EmptyState
icon={<HardDriveIcon />}
title={data.folder ? 'This folder is empty' : 'No files yet'}
description={
canUpload
? 'Drop files here, or use Upload files. Each one is stored in your own repo, with its bytes in your account’s blob storage.'
: 'Files stored as dev.pdsjs.drive.file records appear here, with their bytes kept in your account’s blob storage.'
}
/>
) : (
<>
{data.folders.map((child) => (
<ListRow
key={child.name}
className="px-5 py-4"
media={
<RowIcon>
<FolderIcon />
</RowIcon>
}
title={<span className="text-[16px]">{child.name}</span>}
description={
<>
{num(child.files)} file{child.files === 1 ? '' : 's'} ·{' '}
{bytes(child.size)}
</>
}
href={folderHref(child.rkey, space)}
onNavigate={onNavigate}
actions={
<div className="flex shrink-0 items-center gap-3">
<RenameFolderDialog
entry={child}
space={space}
busy={action.busy}
onRename={renameFolder}
/>
<Dialog
title={`Delete ${child.name}?`}
trigger={
<TextLink tone="danger" disabled={action.busy}>
Delete
</TextLink>
}
>
<div className="space-y-3 text-[13px] text-muted-foreground leading-relaxed">
<p>
{child.files > 0
? `This puts the folder and the ${child.files} file${child.files === 1 ? '' : 's'} in it into recently deleted.`
: 'This puts the folder into recently deleted.'}
</p>
<p>You can put it back from there.</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={() => removeFolder(child.rkey)}
>
Delete folder
</Button>
}
/>
</div>
</Dialog>
</div>
}
/>
))}
{data.files.map((file) => (
<ListRow
key={file.rkey}
className="px-5 py-4"
media={
<RowIcon>
<FileIcon />
</RowIcon>
}
title={
<span className="min-w-0 truncate text-[16px]">
{file.name}
</span>
}
description={
<>
{bytes(file.size)} · {file.mimeType}
{file.partCount > 1 ? ` · ${num(file.partCount)} parts` : ''}
{file.updatedAt || file.createdAt
? ` · ${timeAgo(file.updatedAt || file.createdAt)}`
: ''}
</>
}
actions={
<div className="flex shrink-0 items-center gap-3">
{!space && file.blobCid && (
<LinkFileDialog file={file} did={account.did} />
)}
<TextLink external href={downloadHref(file, space)}>
Download
</TextLink>
<Dialog
title={`Delete ${file.name}?`}
trigger={
<TextLink tone="danger" disabled={action.busy}>
Delete
</TextLink>
}
>
<div className="space-y-3 text-[13px] text-muted-foreground leading-relaxed">
<p>
This puts the file into recently deleted. Its bytes stay
stored until you empty it from there.
</p>
<p>You can put it back.</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(file.rkey)}
>
Delete file
</Button>
}
/>
</div>
</Dialog>
</div>
}
/>
))}
</>
)}
<Trash
space={space}
busy={action.busy}
onRestore={restoreItem}
onPurge={purgeItem}
/>
{data.totalFiles > 0 && (
<p className="px-1 text-[12.5px] text-muted-foreground">
{num(data.totalFiles)} file{data.totalFiles === 1 ? '' : 's'} in your
drive, {bytes(data.totalSize)} in all.
</p>
)}
</div>
);
}