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 (
{error.message}
); } if (!data) { return (
Loading files…
); } return (
{data.files.map((file) => (
/{file.path} {file.contentType} {bytes(file.size)}
))}
); } /** 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 (
{action.error && {action.error}}
Subdomain 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]" />
{install.settings.map((field) => (
{field.label} {field.type === 'boolean' && ( setDraft({ ...draft, [field.key]: event.target.checked }) } /> )} {field.type === 'string' && ( setDraft({ ...draft, [field.key]: event.target.value }) } className="rounded-lg border border-border bg-background px-2 py-1 text-[13px]" /> )} {field.type === 'select' && ( )}
))}
); } /** 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} */ ({})); 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 ( Install an app} >
{action.error && {action.error}} {done ? (

Installed. It serves as {done} on your site domain, and appears under Installed apps.

) : !preview ? ( <>

Paste the app's manifest address, an{' '} at:// URI naming a{' '} dev.pdsjs.app.manifest record.

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]" />
) : ( <>
{preview.iconUrl ? ( ) : ( )}
{preview.name}
by {preview.author.handle} · {num(preview.files.count)} file {preview.files.count === 1 ? '' : 's'} ·{' '} {bytes(preview.files.totalSize)}
{preview.description && (

{preview.description}

)} {preview.external && ( This app talks to services beyond your own PDS. )} {preview.pinDrifted && ( The developer changed this app after publishing the manifest. It cannot be installed until they publish an update. )}
Serves at setSiteName(event.target.value)} className="w-28 rounded-lg border border-border bg-background px-2 py-1 text-right" /> .{'{'}your domain{'}'}
{preview.settings.length > 0 && (
{preview.settings.map((field) => (
{field.label} {field.type === 'boolean' && ( setConfig({ ...config, [field.key]: event.target.checked, }) } /> )} {field.type === 'string' && ( setConfig({ ...config, [field.key]: event.target.value, }) } className="rounded-lg border border-border bg-background px-2 py-1" /> )} {field.type === 'select' && ( )}
))}
)}
)}
); } /** 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 (

Installed apps

{action.error && {action.error}} {data.installs.map((install) => (
) : ( ) } title={ {install.name || install.site} } description={ <> Installed from{' '} {install.manifest?.uri?.replace('at://', '')} } actions={
{updates.data?.updates?.[install.uri]?.changed && ( update(install.manifest?.uri)} > Update available )} setOpen(open === install.uri ? null : install.uri) } > {open === install.uri ? 'Hide settings' : 'Settings'} {install.site}.{domain}
} /> {open === install.uri && }
))}
); } /** The address an installer pastes: the manifest at-uri, one click away. */ function CopyInstallLink({ manifestUri }) { const [copied, setCopied] = useState(false); return ( { await navigator.clipboard.writeText(manifestUri); setCopied(true); setTimeout(() => setCopied(false), 2000); }} > {copied ? 'Copied' : 'Copy install link'} ); } 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 {error.message}; } if (!data) return null; const remove = (name) => action.run(async () => { await post('/sites/delete', { name }); }); if (!data.sites.filter((site) => !site.installed).length) { return (
} title="No sites deployed" description={ <> A site is a folder of files stored in your repo and served at{' '} name.{data.domain} (the " {data.apexName}" site serves at {data.domain} itself). Deploy one with{' '} pdsjs-site deploy ./dist --site {data.apexName} --pds https://your-pds --handle your.handle } />
); } return (
{data.apexShadowed && ( 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. )} {action.error && {action.error}} {data.sites .filter((site) => !site.installed) .map((site) => (
} title={
{site.name} {site.name === data.apexName && ( apex )} {site.publishedApp && ( published app )} {!site.servable && ( not a valid subdomain )}
} description={ <> {num(site.fileCount)} file{site.fileCount === 1 ? '' : 's'} ·{' '} {bytes(site.totalSize)} {site.deployedAt ? ` · deployed ${timeAgo(site.deployedAt)}` : ''} } actions={
setOpenFiles(openFiles === site.name ? null : site.name) } > {openFiles === site.name ? 'Hide files' : 'Files'} {site.manifestUri && ( )} {site.publishedApp ? 'preview' : site.url.replace(/^https:\/\//, '')} Delete } >

This removes the site record from your repo and takes{' '} {site.url.replace(/^https:\/\//, '')} {' '} offline. Its files become unreferenced and this server cleans them up.

A deploy with the same name brings the site back; unchanged files will upload again.

Cancel } /> remove(site.name)} > Delete site } />
} /> {openFiles === site.name && }
))}
); }