diff --git a/packages/account-ui/src/app.jsx b/packages/account-ui/src/app.jsx index 27abb2e..ce4de64 100644 --- a/packages/account-ui/src/app.jsx +++ b/packages/account-ui/src/app.jsx @@ -12,6 +12,7 @@ import { applyTheme } from '#/lib/theme.js'; import { AppPage } from '#/pages/app-page.jsx'; import { AppsPage } from '#/pages/apps.jsx'; import { BackupsPage } from '#/pages/backups.jsx'; +import { DrivePage } from '#/pages/drive.jsx'; import { FilesPage } from '#/pages/files.jsx'; import { GitPage } from '#/pages/git.jsx'; import { GitRepoPage } from '#/pages/git-repo-page.jsx'; @@ -28,6 +29,7 @@ const RECORDS_PATH = '/account/repo/records'; const FILES_PATH = '/account/files'; const SITES_PATH = '/account/sites'; const GIT_PATH = '/account/git'; +const DRIVE_PATH = '/account/drive'; /** The account sections, in rail order. Counts come from the overview. * Sites appears only when the server composes the feature in, under a Labs @@ -44,7 +46,9 @@ function sectionsFor(data) { { path: '/account/security', title: 'Sign-in & security' }, { path: '/account/identity', title: 'Username & host' }, { path: '/account/settings', title: 'Settings' }, - ...(data.features.sites || data.features.git ? [{ heading: 'Labs' }] : []), + ...(data.features.sites || data.features.git || data.features.drive + ? [{ heading: 'Labs' }] + : []), ...(data.features.sites ? [{ path: SITES_PATH, title: 'Sites' }] : []), ...(data.features.git ? [ @@ -55,6 +59,15 @@ function sectionsFor(data) { }, ] : []), + ...(data.features.drive + ? [ + { + path: DRIVE_PATH, + title: 'Drive', + count: data.stats.driveFiles, + }, + ] + : []), ]; } @@ -79,6 +92,10 @@ const PAGES = { title: 'Repositories', sub: 'Git repositories stored in your repo. Push with the atproto remote helper, clone from anywhere.', }, + [DRIVE_PATH]: { + title: 'Drive', + sub: 'Files you keep in your own account, in folders you choose.', + }, '/account/history': { title: 'History', sub: 'Changes recorded by this server, newest first.', @@ -252,6 +269,16 @@ export function App() { } else if (path === GIT_PATH) { page = PAGES[GIT_PATH]; body = ; + } else if (path === DRIVE_PATH) { + page = PAGES[DRIVE_PATH]; + body = ( + + ); } else if (gitRepoName) { const repoSpace = params.get('space') || undefined; page = { diff --git a/packages/account-ui/src/lib/api.js b/packages/account-ui/src/lib/api.js index fb26c56..1de364e 100644 --- a/packages/account-ui/src/lib/api.js +++ b/packages/account-ui/src/lib/api.js @@ -36,3 +36,26 @@ export const get = (path) => request(path); /** @param {string} path @param {unknown} [body] */ export const post = (path, body) => request(path, { method: 'POST', body: JSON.stringify(body ?? {}) }); + +/** + * Send one file as the request body. The server streams it into blob storage, + * so the file is never re-encoded as JSON or a multipart form. + * @param {string} path + * @param {File} file + * @returns {Promise} + */ +export async function upload(path, file) { + const response = await fetch(`/account/api${path}`, { + method: 'POST', + headers: { 'Content-Type': file.type || 'application/octet-stream' }, + body: file, + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new ApiError( + body.error || `Upload failed (${response.status})`, + response.status, + ); + } + return body; +} diff --git a/packages/account-ui/src/lib/query.js b/packages/account-ui/src/lib/query.js index 4825bda..ddd64a6 100644 --- a/packages/account-ui/src/lib/query.js +++ b/packages/account-ui/src/lib/query.js @@ -25,6 +25,10 @@ export const queryKeys = { sites: ['sites'], siteFiles: (/** @type {string} */ name) => ['site-files', name], gitRepos: ['git-repos'], + driveFiles: (/** @type {{folder: string, space: string}} */ params) => [ + 'drive-files', + params, + ], gitTree: (/** @type {Record} */ params) => [ 'git-tree', params, diff --git a/packages/account-ui/src/pages/drive.jsx b/packages/account-ui/src/pages/drive.jsx new file mode 100644 index 0000000..6e753dd --- /dev/null +++ b/packages/account-ui/src/pages/drive.jsx @@ -0,0 +1,465 @@ +import { Dialog as BaseDialog } from '@base-ui/react/dialog'; +import { useQuery } from '@tanstack/react-query'; +import { + FileIcon, + FolderIcon, + FolderPlusIcon, + HardDriveIcon, + LockIcon, + 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()}`; +} + +/** The last segment of a space AT-URI, which is the name its owner gave it. */ +function spaceLabel(uri) { + return uri.split('/').pop() || uri; +} + +/** 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 ( + + onNavigate(folderHref('', space))}> + All files + + {ancestry.map((entry, index) => { + const last = index === ancestry.length - 1; + return ( + + / + {last ? ( + {entry.name} + ) : ( + onNavigate(folderHref(entry.rkey, space))} + > + {entry.name} + + )} + + ); + })} + + ); +} + +/** 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 ( + + New folder + + } + > + + setName(event.target.value)} + placeholder="photos" + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[13px]" + /> + + + + Cancel + + } + /> + onCreate(name.trim(), folder, space)} + > + Make folder + + } + /> + + + ); +} + +/** 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 ( + Rename} + > + + setName(event.target.value)} + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-[13px]" + /> + + + + Cancel + + } + /> + onRename(entry.rkey, name.trim(), space)} + > + Rename + + } + /> + + + ); +} + +/** + * @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.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); + + const sendFiles = (files) => + action.run(async () => { + try { + for (const file of files) { + setSending(file.name); + 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 {error.message}; + if (!data) return null; + if (!data.enabled) { + return ( + } + 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 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 + { + 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 && ( + + Drive in + onNavigate(folderHref('', ''))} + > + Your repo + + {data.spaces.map((entry) => ( + onNavigate(folderHref('', entry.uri))} + > + {spaceLabel(entry.uri)} + + ))} + + )} + + + + {canUpload && ( + + + { + const files = [...event.target.files]; + event.target.value = ''; + if (files.length) sendFiles(files); + }} + /> + fileInput.current?.click()} + > + + {action.busy ? 'Uploading…' : 'Upload files'} + + + )} + + + {action.error && {action.error}} + {sending && ( + + Uploading {sending}… + + )} + {dragging && ( + + Drop to upload into{' '} + + {data.path || 'your drive'} + + + )} + + {empty ? ( + } + 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) => ( + + + + } + title={{child.name}} + description={ + <> + {num(child.files)} file{child.files === 1 ? '' : 's'} ·{' '} + {bytes(child.size)} + > + } + href={folderHref(child.rkey, space)} + onNavigate={onNavigate} + actions={ + + + {child.files === 0 && ( + removeFolder(child.rkey)} + > + Delete + + )} + + } + /> + ))} + + {data.files.map((file) => ( + + + + } + title={ + + {file.name} + + } + description={ + <> + {bytes(file.size)} · {file.mimeType} + {file.partCount > 1 ? ` · ${num(file.partCount)} parts` : ''} + {file.updatedAt || file.createdAt + ? ` · ${timeAgo(file.updatedAt || file.createdAt)}` + : ''} + > + } + actions={ + + + Download + + + Delete + + } + > + + + This removes the file's record from your repo. Its + stored bytes become unreferenced and this server cleans + them up. + + There is no undo. + + + + Cancel + + } + /> + remove(file.rkey)} + > + Delete file + + } + /> + + + + } + /> + ))} + > + )} + + {data.totalFiles > 0 && ( + + {num(data.totalFiles)} file{data.totalFiles === 1 ? '' : 's'} in your + drive, {bytes(data.totalSize)} in all. + + )} + + ); +}
+ Uploading {sending}… +
+ This removes the file's record from your repo. Its + stored bytes become unreferenced and this server cleans + them up. +
There is no undo.
+ {num(data.totalFiles)} file{data.totalFiles === 1 ? '' : 's'} in your + drive, {bytes(data.totalSize)} in all. +