import { useQuery } from '@tanstack/react-query'; import { useEffect } from 'react'; import { Notice } from '#/components/atoms/notice.jsx'; import { PublishedCheck } from '#/components/molecules/published-check.jsx'; import { AppSkeleton } from '#/components/organisms/app-skeleton.jsx'; import { Shell } from '#/components/organisms/shell.jsx'; import { get } from '#/lib/api.js'; import { nsidDomain } from '#/lib/format.js'; import { queryKeys } from '#/lib/query.js'; import { useRouter } from '#/lib/router.js'; 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'; import { HistoryPage } from '#/pages/history.jsx'; import { HomePage } from '#/pages/home.jsx'; import { IdentityPage } from '#/pages/identity.jsx'; import { PackagesPage } from '#/pages/packages.jsx'; import { RecordsPage } from '#/pages/records.jsx'; import { RegistryPage } from '#/pages/registry.jsx'; import { SecurityPage } from '#/pages/security.jsx'; import { SettingsPage } from '#/pages/settings.jsx'; import { SitesPage } from '#/pages/sites.jsx'; const APPS_PATH = '/account/apps'; 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'; const REGISTRY_PATH = '/account/registry'; const PACKAGES_PATH = '/account/packages'; /** The account sections, in rail order. Counts come from the overview. * Sites appears only when the server composes the feature in, under a Labs * heading at the end of the rail. */ function sectionsFor(data) { return [ { path: '/account', title: 'Home' }, { path: APPS_PATH, title: 'Your apps', count: data.stats.apps }, ...(data.features.files ? [{ path: FILES_PATH, title: 'Files', count: data.stats.blobs }] : []), { path: '/account/history', title: 'History' }, { path: '/account/backups', title: 'Backups' }, { path: '/account/security', title: 'Sign-in & security' }, { path: '/account/identity', title: 'Username & host' }, { path: '/account/settings', title: 'Settings' }, ...(data.features.sites || data.features.git || data.features.drive || data.features.oci || data.features.npm ? [{ heading: 'Labs' }] : []), ...(data.features.sites ? [{ path: SITES_PATH, title: 'Sites' }] : []), ...(data.features.git ? [ { path: GIT_PATH, title: 'Repositories', count: data.stats.gitRepos, }, ] : []), ...(data.features.drive ? [ { path: DRIVE_PATH, title: 'Drive', count: data.stats.driveFiles, }, ] : []), ...(data.features.oci ? [ { path: REGISTRY_PATH, title: 'Registry', count: data.stats.ociRepos, }, ] : []), ...(data.features.npm ? [ { path: PACKAGES_PATH, title: 'Packages', count: data.stats.npmPackages, }, ] : []), ]; } const PAGES = { '/account': { title: 'Your Atmosphere Account', sub: 'One account for every atproto app. Everything you make is stored here.', }, [APPS_PATH]: { title: 'Your apps', sub: 'Apps that hold access or have stored data. Open one to see its records and manage access.', }, [FILES_PATH]: { title: 'Files', sub: 'Images and other files your apps have stored in your account.', }, [SITES_PATH]: { title: 'Sites', sub: 'Static sites stored in your repo and served on your own domain.', }, [GIT_PATH]: { 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.', }, [REGISTRY_PATH]: { title: 'Registry', sub: 'A container registry in your repo. Push with docker, pull from anywhere.', }, [PACKAGES_PATH]: { title: 'Packages', sub: 'An npm registry in your repo. Publish with npm, install from anywhere.', }, '/account/history': { title: 'History', sub: 'Changes recorded by this server, newest first.', }, '/account/backups': { title: 'Backups', sub: 'Scheduled copies of your account, kept somewhere else.', }, '/account/security': { title: 'Sign-in & security', sub: 'How you sign in, and who has access.', }, '/account/identity': { title: 'Username & host', sub: 'Your name on the network, where your account lives, and how to take it elsewhere.', }, '/account/settings': { title: 'Settings', sub: 'How this page looks and behaves.', }, }; // Clear the account cookie server-side, then hard-navigate to /account, where a // signed-out request is served the sign-in screen. Used by the sign-out button // and when a request reveals the current session has lapsed. function clearSessionAndReload() { return fetch('/account/sign-out', { method: 'POST', redirect: 'manual' }) .catch(() => {}) .finally(() => { window.location.href = '/account'; }); } export function App() { const { path, params, navigate, back } = useRouter(); const { data, error } = useQuery({ queryKey: queryKeys.overview, queryFn: () => get('/overview'), }); const signOut = clearSessionAndReload; // A 401 means the cookie no longer maps to a live session — it expired, or in // local dev the account was reseeded under a new DID. Clear the stale cookie // and reload onto the sign-in screen, rather than leaving the console to error // on every request. const sessionExpired = error?.status === 401; useEffect(() => { if (sessionExpired) clearSessionAndReload(); }, [sessionExpired]); // Keep the palette in step with the stored appearance choice, whether it // arrived with the overview or was changed from another device. const theme = data?.theme; useEffect(() => { if (theme) applyTheme(theme); }, [theme]); if (error) { if (sessionExpired) return ; return (
{error.message}
); } if (!data) return ; // Destinations as URLs, so navigational UI can be real links (open in a new // tab, copyable) that still route in place via `go` on a plain click. const go = (next) => navigate(next); const appHref = (domain) => `${APPS_PATH}/${encodeURIComponent(domain)}`; const collectionHref = (collection, space) => `${RECORDS_PATH}?collection=${encodeURIComponent(collection)}${ space ? `&space=${encodeURIComponent(space)}` : '' }`; const recordHref = (collection, rkey, seq) => `${RECORDS_PATH}?collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}${ seq != null ? `&seq=${encodeURIComponent(seq)}` : '' }`; // The Files view's filters live in the URL, so a filtered view is a place: // linkable, sharable, restored by the back button. const filesHref = (filters) => { const query = new URLSearchParams(); if (filters.kind) query.set('kind', filters.kind); if (filters.app) query.set('app', filters.app); if (filters.unused) query.set('unused', '1'); if (filters.sort === 'largest') query.set('sort', 'largest'); const qs = query.toString(); return qs ? `${FILES_PATH}?${qs}` : FILES_PATH; }; const sections = sectionsFor(data); const appDomain = path.startsWith(`${APPS_PATH}/`) ? decodeURIComponent(path.slice(APPS_PATH.length + 1)) : null; // A repository's own page sits under Repositories in the rail, the same // way an app's page sits under Your apps. const gitRepoName = path.startsWith(`${GIT_PATH}/`) ? decodeURIComponent(path.slice(GIT_PATH.length + 1)) : null; // The apps directory, an app's own page, and a collection's records all sit // under Your apps in the rail. const underApps = path === APPS_PATH || appDomain || path === RECORDS_PATH; const current = underApps ? APPS_PATH : gitRepoName ? GIT_PATH : PAGES[path] ? path : ''; let page; let body; if (path === RECORDS_PATH) { const collection = params.get('collection') || ''; const space = params.get('space') || undefined; page = { title: collection || 'Records', titleBadge: collection ? ( ) : undefined, sub: space || undefined, }; body = ( back(`${APPS_PATH}/${encodeURIComponent(nsidDomain(collection))}`) } /> ); } else if (appDomain) { page = { title: appDomain }; body = ( go(APPS_PATH)} /> ); } else if (path === APPS_PATH) { page = PAGES[APPS_PATH]; body = ; } else if (path === FILES_PATH) { page = PAGES[path]; body = ( ); } else if (path === SITES_PATH) { page = PAGES[SITES_PATH]; body = ; } else if (path === GIT_PATH) { page = PAGES[GIT_PATH]; body = ; } else if (path === REGISTRY_PATH) { page = PAGES[REGISTRY_PATH]; body = ; } else if (path === PACKAGES_PATH) { page = PAGES[PACKAGES_PATH]; body = ; } else if (path === DRIVE_PATH) { page = PAGES[DRIVE_PATH]; body = ( ); } else if (gitRepoName) { const repoSpace = params.get('space') || undefined; page = { title: gitRepoName, sub: repoSpace ? 'A private git repository shared through a space.' : 'A git repository stored in your account.', back: { href: GIT_PATH, label: 'Repositories' }, }; body = ( ); } else if (path === '/account/history') { page = PAGES[path]; body = ; } else if (path === '/account/backups') { page = PAGES[path]; body = ; } else if (path === '/account/security') { page = PAGES[path]; body = ; } else if (path === '/account/identity') { page = PAGES[path]; body = ; } else if (path === '/account/settings') { page = PAGES[path]; body = ; } else { page = PAGES['/account']; body = ( ); } return ( {body} ); }