diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 56755b5..d000fce 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -7,6 +7,7 @@ function DocumentTitle() { const matches = useMatches() useEffect(() => { + let found = false for (let i = matches.length - 1; i >= 0; i--) { const { handle, params, data } = matches[i] as { handle?: { title?: string | ((p: Record, d: unknown) => string) } @@ -16,9 +17,11 @@ function DocumentTitle() { if (handle?.title) { document.title = typeof handle.title === 'function' ? handle.title(params, data) : handle.title + found = true break } } + if (!found) document.title = 'Underlay' }, [matches]) return null diff --git a/src/routes/[owner]/[collection]/diff.data.ts b/src/routes/[owner]/[collection]/diff.data.ts new file mode 100644 index 0000000..11bd6fd --- /dev/null +++ b/src/routes/[owner]/[collection]/diff.data.ts @@ -0,0 +1,22 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: (params: Record) => + `Diff — ${params.owner}/${params.collection} · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + const prefix = `/api/collections/${params.owner}/${params.collection}` + + const [data, versions] = await Promise.all([ + fetch(new URL(prefix, base), { headers }).then((r) => (r.ok ? r.json() : null)), + fetch(new URL(`${prefix}/versions?limit=100`, base), { headers }).then((r) => + r.ok ? r.json() : [], + ), + ]) + + if (!data) throw new Response('Not Found', { status: 404 }) + return { data, versions } +} diff --git a/src/routes/[owner]/[collection]/diff.tsx b/src/routes/[owner]/[collection]/diff.tsx index beeeb65..730d1d9 100644 --- a/src/routes/[owner]/[collection]/diff.tsx +++ b/src/routes/[owner]/[collection]/diff.tsx @@ -1,8 +1,7 @@ import { useEffect, useState } from 'react' -import { useParams, useSearchParams } from 'react-router' +import { useLoaderData, useParams, useSearchParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { NotFoundError } from '~/components/NotFound' import { useAppContext } from '~/lib/app-context' import { CollectionNav } from '.' @@ -20,59 +19,21 @@ export default function CollectionDiffPage() { const { owner, collection } = useParams() const [searchParams, setSearchParams] = useSearchParams() const { currentUser } = useAppContext() + const { data, versions } = useLoaderData() as { data: any; versions: any[] } + + const isOwner = + currentUser?.slug === owner || currentUser?.orgs?.some((o: any) => o.slug === owner) - const [data, setData] = useState(null) - const [versions, setVersions] = useState([]) - const [isOwner, setIsOwner] = useState(false) const [diff, setDiff] = useState(null) const [diffError, setDiffError] = useState(null) - const [loading, setLoading] = useState(true) const [diffLoading, setDiffLoading] = useState(false) - // Version selectors (semver strings, empty string = none/empty) - const [fromVer, setFromVer] = useState('') - const [toVer, setToVer] = useState('') - - useEffect(() => { - if (!owner || !collection) return - - Promise.all([ - fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : null, - ), - fetch(`/api/collections/${owner}/${collection}/versions?limit=100`, { - credentials: 'include', - }).then((r) => (r.ok ? r.json() : [])), - ]).then(([col, vers]) => { - if (!col) { - setLoading(false) - return - } - setData(col) - setVersions(vers) - - if (currentUser) { - setIsOwner( - currentUser.slug === owner || currentUser.orgs?.some((o: any) => o.slug === owner), - ) - } + const latestSemver = versions.length > 0 ? versions[0].semver : '' + const [fromVer, setFromVer] = useState(searchParams.get('from') ?? '') + const [toVer, setToVer] = useState(searchParams.get('to') || latestSemver) - // Determine from/to from URL - const latestSemver = vers.length > 0 ? vers[0].semver : '' - const urlTo = searchParams.get('to') - const urlFrom = searchParams.get('from') - const target = urlTo || latestSemver - const base = urlFrom ?? '' - setToVer(target) - setFromVer(base) - - setLoading(false) - }) - }, [owner, collection, currentUser]) - - // Fetch diff when from/to change useEffect(() => { - if (!toVer || loading) return + if (!toVer) return setDiffLoading(true) setDiff(null) setDiffError(null) @@ -90,7 +51,7 @@ export default function CollectionDiffPage() { } }) .finally(() => setDiffLoading(false)) - }, [fromVer, toVer, loading, owner, collection]) + }, [fromVer, toVer, owner, collection]) function handleCompare(e: React.FormEvent) { e.preventDefault() @@ -105,15 +66,6 @@ export default function CollectionDiffPage() { setSearchParams(params) } - if (loading) { - return ( - -
Loading…
-
- ) - } - if (!data) throw new NotFoundError() - const targetVersion = versions.find((v: any) => v.semver === toVer) const baseVersion = versions.find((v: any) => v.semver === fromVer) @@ -137,7 +89,7 @@ export default function CollectionDiffPage() { owner={owner!} collection={collection!} isPublic={data.public} - isOwner={isOwner} + isOwner={!!isOwner} active="versions" /> diff --git a/src/routes/[owner]/[collection]/index.data.ts b/src/routes/[owner]/[collection]/index.data.ts index 708de53..147eae4 100644 --- a/src/routes/[owner]/[collection]/index.data.ts +++ b/src/routes/[owner]/[collection]/index.data.ts @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs } from 'react-router' export const handle = { - title: (params: Record) => `${params.owner}/${params.collection} — Underlay`, + title: (params: Record) => `${params.owner}/${params.collection} · Underlay`, } export async function loader({ params, request }: LoaderFunctionArgs) { diff --git a/src/routes/[owner]/[collection]/schemas.data.ts b/src/routes/[owner]/[collection]/schemas.data.ts new file mode 100644 index 0000000..ce275df --- /dev/null +++ b/src/routes/[owner]/[collection]/schemas.data.ts @@ -0,0 +1,20 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: (params: Record) => + `Schemas — ${params.owner}/${params.collection} · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + const prefix = `/api/collections/${params.owner}/${params.collection}` + + const [data, schemas] = await Promise.all([ + fetch(new URL(prefix, base), { headers }).then((r) => (r.ok ? r.json() : null)), + fetch(new URL(`${prefix}/schemas`, base), { headers }).then((r) => (r.ok ? r.json() : null)), + ]) + + if (!data) throw new Response('Not Found', { status: 404 }) + return { data, schemas } +} diff --git a/src/routes/[owner]/[collection]/schemas.tsx b/src/routes/[owner]/[collection]/schemas.tsx index 59db272..1e953e4 100644 --- a/src/routes/[owner]/[collection]/schemas.tsx +++ b/src/routes/[owner]/[collection]/schemas.tsx @@ -1,8 +1,7 @@ import { type FormEvent, useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { Link, useLoaderData, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { NotFoundError } from '~/components/NotFound' import { useAppContext } from '~/lib/app-context' import { CollectionNav } from '.' @@ -10,54 +9,31 @@ import { CollectionNav } from '.' export default function CollectionSchemasPage() { const { owner, collection } = useParams() const { currentUser } = useAppContext() + const { data, schemas: schemasData } = useLoaderData() as { data: any; schemas: any } + + const isOwner = + currentUser?.slug === owner || currentUser?.orgs?.some((o: any) => o.slug === owner) + + const schemas: any[] = schemasData?.schemas ?? [] - const [data, setData] = useState(null) - const [isOwner, setIsOwner] = useState(false) - const [schemas, setSchemas] = useState([]) - const [schemasData, setSchemasData] = useState({}) const [arkRecordTypes, setArkRecordTypes] = useState>({}) - const [loading, setLoading] = useState(true) const [arkSuccess, setArkSuccess] = useState('') const [arkError, setArkError] = useState('') useEffect(() => { - if (!owner || !collection) return - - const ownerFlag = - currentUser && - (currentUser.slug === owner || currentUser.orgs?.some((o: any) => o.slug === owner)) - setIsOwner(!!ownerFlag) - - Promise.all([ - fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : null, - ), - fetch(`/api/collections/${owner}/${collection}/schemas`, { credentials: 'include' }).then( - (r) => (r.ok ? r.json() : { schemas: [], version: null, semver: null }), - ), - ownerFlag - ? fetch(`/api/collections/${owner}/${collection}/ark/record-types`, { - credentials: 'include', - }).then((r) => (r.ok ? r.json() : [])) - : Promise.resolve([]), - ]).then(([col, sd, arkTypes]) => { - if (!col) { - setLoading(false) - return - } - setData(col) - setSchemasData(sd) - setSchemas(sd.schemas ?? []) - - const types: Record = {} - for (const entry of arkTypes) { - types[entry.recordType] = entry.redirectUrlField - } - setArkRecordTypes(types) - - setLoading(false) + if (!isOwner || !owner || !collection) return + fetch(`/api/collections/${owner}/${collection}/ark/record-types`, { + credentials: 'include', }) - }, [owner, collection, currentUser]) + .then((r) => (r.ok ? r.json() : [])) + .then((arkTypes: any[]) => { + const types: Record = {} + for (const entry of arkTypes) { + types[entry.recordType] = entry.redirectUrlField + } + setArkRecordTypes(types) + }) + }, [isOwner, owner, collection]) async function handleUpdateArkType(e: FormEvent, slug: string) { e.preventDefault() @@ -90,15 +66,6 @@ export default function CollectionSchemasPage() { } } - if (loading) { - return ( - -
Loading…
-
- ) - } - if (!data) throw new NotFoundError() - return (
@@ -106,7 +73,7 @@ export default function CollectionSchemasPage() { owner={owner!} collection={collection!} isPublic={data.public} - isOwner={isOwner} + isOwner={!!isOwner} active="schemas" /> @@ -124,7 +91,7 @@ export default function CollectionSchemasPage() {

{schemas.length} type{schemas.length !== 1 ? 's' : ''} - {schemasData.semver && ( + {schemasData?.semver && ( in {schemasData.semver} )}

diff --git a/src/routes/[owner]/[collection]/settings.data.ts b/src/routes/[owner]/[collection]/settings.data.ts new file mode 100644 index 0000000..42d17b0 --- /dev/null +++ b/src/routes/[owner]/[collection]/settings.data.ts @@ -0,0 +1,22 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: (params: Record) => + `Settings — ${params.owner}/${params.collection} · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + const prefix = `/api/collections/${params.owner}/${params.collection}` + + const [data, arkSettings] = await Promise.all([ + fetch(new URL(prefix, base), { headers }).then((r) => (r.ok ? r.json() : null)), + fetch(new URL(`${prefix}/ark`, base), { headers }).then((r) => + r.ok ? r.json() : { enabled: false, customUrl: null, arkUrl: null }, + ), + ]) + + if (!data) throw new Response('Not Found', { status: 404 }) + return { data, arkSettings } +} diff --git a/src/routes/[owner]/[collection]/settings.tsx b/src/routes/[owner]/[collection]/settings.tsx index 3f5632b..bccfa47 100644 --- a/src/routes/[owner]/[collection]/settings.tsx +++ b/src/routes/[owner]/[collection]/settings.tsx @@ -1,8 +1,7 @@ -import { type FormEvent, useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { type FormEvent, useState } from 'react' +import { Link, useLoaderData, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { NotFoundError } from '~/components/NotFound' import { useAppContext } from '~/lib/app-context' import { CollectionNav } from '.' @@ -10,31 +9,28 @@ import { CollectionNav } from '.' export default function CollectionSettingsPage() { const { owner, collection } = useParams() const { currentUser } = useAppContext() + const loaderData = useLoaderData() as { data: any; arkSettings: any } - const [data, setData] = useState(null) - const [arkSettings, setArkSettings] = useState({ - enabled: false, - customUrl: null, - arkUrl: null, - }) - const [loading, setLoading] = useState(true) + const [data, setData] = useState(loaderData.data) + const [arkSettings, setArkSettings] = useState(loaderData.arkSettings) const [success, setSuccess] = useState('') const [error, setError] = useState('') const [submitting, setSubmitting] = useState('') // Form state - const [name, setName] = useState('') - const [slugValue, setSlugValue] = useState('') - const [isPublic, setIsPublic] = useState(false) + const [name, setName] = useState(data.name) + const [slugValue, setSlugValue] = useState(data.slug) + const [isPublic, setIsPublic] = useState(data.public) // Metadata form - const [description, setDescription] = useState('') - const [readme, setReadme] = useState('') - const [license, setLicense] = useState('') + const meta = data.latestVersion?.metadata as Record | null | undefined + const [description, setDescription] = useState((meta?.description as string) ?? '') + const [readme, setReadme] = useState((meta?.readme as string) ?? '') + const [license, setLicense] = useState((meta?.license as string) ?? '') // ARK form - const [arkEnabled, setArkEnabled] = useState(false) - const [arkCustomUrl, setArkCustomUrl] = useState('') + const [arkEnabled, setArkEnabled] = useState(arkSettings.enabled) + const [arkCustomUrl, setArkCustomUrl] = useState(arkSettings.customUrl ?? '') // Delete form const [confirmSlug, setConfirmSlug] = useState('') @@ -42,45 +38,6 @@ export default function CollectionSettingsPage() { // Transfer form const [transferTarget, setTransferTarget] = useState('') - useEffect(() => { - if (!owner || !collection || !currentUser) return - - const isOrgMember = currentUser.orgs?.some((o: any) => o.slug === owner) - if (currentUser.slug !== owner && !isOrgMember) { - window.location.href = `/${owner}/${collection}` - return - } - - Promise.all([ - fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : null, - ), - fetch(`/api/collections/${owner}/${collection}/ark`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : { enabled: false, customUrl: null, arkUrl: null }, - ), - ]).then(([col, ark]) => { - if (!col) { - setLoading(false) - return - } - setData(col) - setName(col.name) - setSlugValue(col.slug) - setIsPublic(col.public) - - const meta = col.latestVersion?.metadata as Record | null | undefined - setDescription((meta?.description as string) ?? '') - setReadme((meta?.readme as string) ?? '') - setLicense((meta?.license as string) ?? '') - - setArkSettings(ark) - setArkEnabled(ark.enabled) - setArkCustomUrl(ark.customUrl ?? '') - - setLoading(false) - }) - }, [owner, collection, currentUser]) - if (!currentUser) { window.location.href = '/login' return null @@ -218,15 +175,6 @@ export default function CollectionSettingsPage() { } } - if (loading) { - return ( - -
Loading…
-
- ) - } - if (!data) throw new NotFoundError() - const arkPath: string | null = arkSettings.arkUrl ? new URL(arkSettings.arkUrl).pathname : null return ( diff --git a/src/routes/[owner]/[collection]/v/[n].data.ts b/src/routes/[owner]/[collection]/v/[n].data.ts new file mode 100644 index 0000000..c2e65d6 --- /dev/null +++ b/src/routes/[owner]/[collection]/v/[n].data.ts @@ -0,0 +1,22 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: (params: Record) => + `Version ${params.n} — ${params.owner}/${params.collection} · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + const prefix = `/api/collections/${params.owner}/${params.collection}` + + const [version, collectionData] = await Promise.all([ + fetch(new URL(`${prefix}/versions/${params.n}`, base), { headers }).then((r) => + r.ok ? r.json() : null, + ), + fetch(new URL(prefix, base), { headers }).then((r) => (r.ok ? r.json() : null)), + ]) + + if (!version) throw new Response('Not Found', { status: 404 }) + return { version, collectionData } +} diff --git a/src/routes/[owner]/[collection]/v/[n].tsx b/src/routes/[owner]/[collection]/v/[n].tsx index d583043..879799e 100644 --- a/src/routes/[owner]/[collection]/v/[n].tsx +++ b/src/routes/[owner]/[collection]/v/[n].tsx @@ -1,8 +1,7 @@ -import { useEffect, useState } from 'react' -import { Link, useParams, useSearchParams } from 'react-router' +import { useEffect, useMemo, useState } from 'react' +import { Link, useLoaderData, useParams, useSearchParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { NotFoundError } from '~/components/NotFound' import { useAppContext } from '~/lib/app-context' import { CollectionNav, formatBytes } from '..' @@ -11,12 +10,22 @@ export default function CollectionVersionPage() { const { owner, collection, n } = useParams() const [searchParams, setSearchParams] = useSearchParams() const { currentUser } = useAppContext() + const { version, collectionData } = useLoaderData() as { version: any; collectionData: any } - const [version, setVersion] = useState(null) - const [collectionData, setCollectionData] = useState(null) - const [isOwner, setIsOwner] = useState(false) + const isOwner = + currentUser?.slug === owner || currentUser?.orgs?.some((o: any) => o.slug === owner) + + const readmeSource = (version.metadata as Record | null | undefined)?.readme as + | string + | null const [readmeHtml, setReadmeHtml] = useState(null) - const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!readmeSource) return + import('marked').then(({ marked }) => { + setReadmeHtml(marked.parse(readmeSource) as string) + }) + }, [readmeSource]) // Tab state const tab = searchParams.get('tab') ?? 'records' @@ -29,41 +38,9 @@ export default function CollectionVersionPage() { // Files state const [files, setFiles] = useState([]) - useEffect(() => { - if (!owner || !collection || !n) return - - Promise.all([ - fetch(`/api/collections/${owner}/${collection}/versions/${n}`, { - credentials: 'include', - }).then((r) => (r.ok ? r.json() : null)), - fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : null, - ), - ]).then(([ver, col]) => { - if (!ver) { - setLoading(false) - return - } - setVersion(ver) - setCollectionData(col) - - const meta = ver.metadata as Record | null | undefined - const readmeSource = (meta?.readme as string) || null - if (readmeSource) { - import('marked').then(({ marked }) => { - setReadmeHtml(marked.parse(readmeSource) as string) - }) - } - - if (currentUser) { - setIsOwner( - currentUser.slug === owner || currentUser.orgs?.some((o: any) => o.slug === owner), - ) - } - - setLoading(false) - }) - }, [owner, collection, n, currentUser]) + const schemasMap = (version.schemas ?? {}) as Record + const allTypes = useMemo(() => Object.keys(schemasMap).sort(), [schemasMap]) + const currentType = selectedType || (allTypes.length > 0 ? allTypes[0] : null) // Fetch records when tab/type/page changes const page = parseInt(searchParams.get('page') ?? '1', 10) @@ -71,11 +48,6 @@ export default function CollectionVersionPage() { useEffect(() => { if (!version || tab !== 'records') return - - const schemasMap = (version.schemas ?? {}) as Record - const allTypes = Object.keys(schemasMap).sort() - const currentType = selectedType || (allTypes.length > 0 ? allTypes[0] : null) - if (!currentType) return const offset = (page - 1) * pageSize @@ -88,7 +60,7 @@ export default function CollectionVersionPage() { setRecords(body.records ?? body) setTotalRecords(body.pagination?.total ?? version.recordCount ?? 0) }) - }, [version, tab, selectedType, page, owner, collection, n]) + }, [version, tab, currentType, page, owner, collection, n]) // Fetch files when files tab selected useEffect(() => { @@ -101,19 +73,6 @@ export default function CollectionVersionPage() { .then(setFiles) }, [version, tab, owner, collection, n]) - if (loading) { - return ( - -
Loading…
-
- ) - } - if (!version) throw new NotFoundError() - - const schemasMap = (version.schemas ?? {}) as Record - const allTypes = Object.keys(schemasMap).sort() - const currentType = selectedType || (allTypes.length > 0 ? allTypes[0] : null) - const currentTypeFields: string[] = currentType ? schemasMap[currentType]?.properties ? Object.keys(schemasMap[currentType].properties) @@ -161,7 +120,7 @@ export default function CollectionVersionPage() { owner={owner!} collection={collection!} isPublic={collectionData?.public} - isOwner={isOwner} + isOwner={!!isOwner} active="versions" versionLabel={version.semver} /> diff --git a/src/routes/[owner]/[collection]/versions.data.ts b/src/routes/[owner]/[collection]/versions.data.ts new file mode 100644 index 0000000..0c0501c --- /dev/null +++ b/src/routes/[owner]/[collection]/versions.data.ts @@ -0,0 +1,22 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: (params: Record) => + `Versions — ${params.owner}/${params.collection} · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + const prefix = `/api/collections/${params.owner}/${params.collection}` + + const [data, versions] = await Promise.all([ + fetch(new URL(prefix, base), { headers }).then((r) => (r.ok ? r.json() : null)), + fetch(new URL(`${prefix}/versions?limit=100`, base), { headers }).then((r) => + r.ok ? r.json() : [], + ), + ]) + + if (!data) throw new Response('Not Found', { status: 404 }) + return { data, versions } +} diff --git a/src/routes/[owner]/[collection]/versions.tsx b/src/routes/[owner]/[collection]/versions.tsx index 6a97c3d..34c586e 100644 --- a/src/routes/[owner]/[collection]/versions.tsx +++ b/src/routes/[owner]/[collection]/versions.tsx @@ -1,8 +1,6 @@ -import { useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { Link, useLoaderData, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { NotFoundError } from '~/components/NotFound' import { useAppContext } from '~/lib/app-context' import { CollectionNav, formatBytes } from '.' @@ -10,48 +8,10 @@ import { CollectionNav, formatBytes } from '.' export default function CollectionVersionsPage() { const { owner, collection } = useParams() const { currentUser } = useAppContext() + const { data, versions } = useLoaderData() as { data: any; versions: any[] } - const [data, setData] = useState(null) - const [versions, setVersions] = useState([]) - const [isOwner, setIsOwner] = useState(false) - const [loading, setLoading] = useState(true) - - useEffect(() => { - if (!owner || !collection) return - - Promise.all([ - fetch(`/api/collections/${owner}/${collection}`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : null, - ), - fetch(`/api/collections/${owner}/${collection}/versions?limit=100`, { - credentials: 'include', - }).then((r) => (r.ok ? r.json() : [])), - ]).then(([col, vers]) => { - if (!col) { - setLoading(false) - return - } - setData(col) - setVersions(vers) - - if (currentUser) { - setIsOwner( - currentUser.slug === owner || currentUser.orgs?.some((o: any) => o.slug === owner), - ) - } - - setLoading(false) - }) - }, [owner, collection, currentUser]) - - if (loading) { - return ( - -
Loading…
-
- ) - } - if (!data) throw new NotFoundError() + const isOwner = + currentUser?.slug === owner || currentUser?.orgs?.some((o: any) => o.slug === owner) return ( @@ -60,7 +20,7 @@ export default function CollectionVersionsPage() { owner={owner!} collection={collection!} isPublic={data.public} - isOwner={isOwner} + isOwner={!!isOwner} active="versions" /> diff --git a/src/routes/[owner]/index.data.ts b/src/routes/[owner]/index.data.ts new file mode 100644 index 0000000..ae95727 --- /dev/null +++ b/src/routes/[owner]/index.data.ts @@ -0,0 +1,25 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: (params: Record) => `${params.owner} · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + + const [account, collections, members] = await Promise.all([ + fetch(new URL(`/api/accounts/${params.owner}`, base), { headers }).then((r) => + r.ok ? r.json() : null, + ), + fetch(new URL(`/api/accounts/${params.owner}/collections`, base), { headers }).then((r) => + r.ok ? r.json() : [], + ), + fetch(new URL(`/api/accounts/${params.owner}/members`, base), { headers }).then((r) => + r.ok ? r.json() : [], + ), + ]) + + if (!account) throw new Response('Not Found', { status: 404 }) + return { account, collections, members } +} diff --git a/src/routes/[owner]/index.tsx b/src/routes/[owner]/index.tsx index 3cd73ac..fb88ac3 100644 --- a/src/routes/[owner]/index.tsx +++ b/src/routes/[owner]/index.tsx @@ -1,60 +1,18 @@ -import { useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { Link, useLoaderData, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { NotFoundError } from '~/components/NotFound' import { useAppContext } from '~/lib/app-context' export default function OwnerPage() { const { owner } = useParams() const { currentUser } = useAppContext() - - const [account, setAccount] = useState(null) - const [collections, setCollections] = useState([]) - const [members, setMembers] = useState([]) - const [isMember, setIsMember] = useState(false) - const [loading, setLoading] = useState(true) - - useEffect(() => { - if (!owner) return - setLoading(true) - - Promise.all([ - fetch(`/api/accounts/${owner}`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : null, - ), - fetch(`/api/accounts/${owner}/collections`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : [], - ), - fetch(`/api/accounts/${owner}/members`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : [], - ), - ]).then(([acct, cols, mems]) => { - if (!acct) { - setLoading(false) - return - } - setAccount(acct) - setCollections(cols) - setMembers(mems) - - if (currentUser) { - setIsMember(currentUser.orgs?.some((o: any) => o.slug === owner) ?? false) - } - - setLoading(false) - }) - }, [owner, currentUser]) - - if (loading) { - return ( - -
Loading…
-
- ) + const { account, collections, members } = useLoaderData() as { + account: any + collections: any[] + members: any[] } - if (!account) throw new NotFoundError() + const isMember = currentUser?.orgs?.some((o: any) => o.slug === owner) ?? false const totalVersions = collections.reduce((sum: number, c: any) => sum + (c.versionCount ?? 0), 0) return ( diff --git a/src/routes/[owner]/settings/index.data.ts b/src/routes/[owner]/settings/index.data.ts new file mode 100644 index 0000000..802509e --- /dev/null +++ b/src/routes/[owner]/settings/index.data.ts @@ -0,0 +1,22 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: (params: Record) => `Settings — ${params.owner} · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + + const [orgData, kfOrgs] = await Promise.all([ + fetch(new URL(`/api/accounts/${params.owner}`, base), { headers }).then((r) => + r.ok ? r.json() : null, + ), + fetch(new URL('/api/accounts/available-kf-orgs', base), { headers }).then((r) => + r.ok ? r.json() : [], + ), + ]) + + if (!orgData) throw new Response('Not Found', { status: 404 }) + return { orgData, kfOrgs: Array.isArray(kfOrgs) ? kfOrgs : [] } +} diff --git a/src/routes/[owner]/settings/index.tsx b/src/routes/[owner]/settings/index.tsx index a977fbb..100bc3f 100644 --- a/src/routes/[owner]/settings/index.tsx +++ b/src/routes/[owner]/settings/index.tsx @@ -1,88 +1,51 @@ -import { type FormEvent, useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { type FormEvent, useState } from 'react' +import { Link, useLoaderData, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { NotFoundError } from '~/components/NotFound' import { useAppContext } from '~/lib/app-context' export default function OwnerSettings() { const { owner } = useParams() const { currentUser } = useAppContext() + const { orgData: initialOrgData, kfOrgs } = useLoaderData() as { + orgData: any + kfOrgs: { id: string; name: string }[] + } + + const org = currentUser?.orgs?.find((o: any) => o.slug === owner) + const isOwner = org?.role === 'owner' + const isAdmin = org?.role === 'admin' || isOwner - const [orgData, setOrgData] = useState(null) - const [isOwner, setIsOwner] = useState(false) - const [isAdmin, setIsAdmin] = useState(false) - const [loading, setLoading] = useState(true) const [success, setSuccess] = useState('') const [error, setError] = useState('') const [submitting, setSubmitting] = useState('') // Profile form - const [displayName, setDisplayName] = useState('') - const [slugValue, setSlugValue] = useState('') - const [bio, setBio] = useState('') - const [website, setWebsite] = useState('') - const [location, setLocation] = useState('') + const [displayName, setDisplayName] = useState(initialOrgData.displayName ?? '') + const [slugValue, setSlugValue] = useState(initialOrgData.slug ?? owner) + const [bio, setBio] = useState(initialOrgData.bio ?? '') + const [website, setWebsite] = useState(initialOrgData.website ?? '') + const [location, setLocation] = useState(initialOrgData.location ?? '') // KF org link - const [kfOrgId, setKfOrgId] = useState('') - const [kfOrgs, setKfOrgs] = useState<{ id: string; name: string }[]>([]) - const [kfOrgsLoading, setKfOrgsLoading] = useState(false) + const [kfOrgId, setKfOrgId] = useState(initialOrgData.kfOrgId ?? '') // ARK form - const [arkNaan, setArkNaan] = useState('') + const [arkNaan, setArkNaan] = useState(initialOrgData.arkNaan ?? '') // Delete form const [confirmSlug, setConfirmSlug] = useState('') - useEffect(() => { - if (!owner || !currentUser) return - - const org = currentUser.orgs?.find((o: any) => o.slug === owner) - if (!org) { - window.location.href = `/${owner}` - return - } - - const ownerRole = org.role === 'owner' - const adminRole = org.role === 'admin' || ownerRole - setIsOwner(ownerRole) - setIsAdmin(adminRole) - - fetch(`/api/accounts/${owner}`, { credentials: 'include' }) - .then((r) => (r.ok ? r.json() : null)) - .then((data) => { - if (!data) { - setLoading(false) - return - } - setOrgData(data) - setDisplayName(data.displayName ?? '') - setSlugValue(data.slug ?? owner) - setBio(data.bio ?? '') - setWebsite(data.website ?? '') - setLocation(data.location ?? '') - setKfOrgId(data.kfOrgId ?? '') - setArkNaan(data.arkNaan ?? '') - setLoading(false) - }) - - // Fetch available KF orgs for the transfer UI - setKfOrgsLoading(true) - fetch('/api/accounts/available-kf-orgs', { credentials: 'include' }) - .then((r) => (r.ok ? r.json() : [])) - .then((orgs) => { - setKfOrgs(Array.isArray(orgs) ? orgs : []) - setKfOrgsLoading(false) - }) - .catch(() => setKfOrgsLoading(false)) - }, [owner, currentUser]) - if (!currentUser) { window.location.href = '/login' return null } + if (!org) { + window.location.href = `/${owner}` + return null + } + function clearMessages() { setSuccess('') setError('') @@ -165,15 +128,6 @@ export default function OwnerSettings() { } } - if (loading) { - return ( - -
Loading…
-
- ) - } - if (!orgData) throw new NotFoundError() - return (
@@ -216,19 +170,19 @@ export default function OwnerSettings() { {isOwner ? (
- {orgData.avatarUrl ? ( + {initialOrgData.avatarUrl ? ( Avatar ) : (
- {orgData.displayName?.charAt(0)?.toUpperCase() ?? '?'} + {initialOrgData.displayName?.charAt(0)?.toUpperCase() ?? '?'}
)}
-

{orgData.displayName}

+

{initialOrgData.displayName}

@{owner}

@@ -328,11 +282,11 @@ export default function OwnerSettings() { ARK Identifiers - {orgData.arkShoulder && ( + {initialOrgData.arkShoulder && (

Assigned shoulder

- {orgData.arkShoulder} + {initialOrgData.arkShoulder}
)} @@ -377,60 +331,56 @@ export default function OwnerSettings() { changes which KF Account is responsible for billing and ownership, but does not affect permissions or membership on the Underlay side.

- {kfOrgsLoading ? ( -

Loading KF Accounts…

- ) : ( - { - e.preventDefault() - clearMessages() - setSubmitting('kforg') - try { - const res = await fetch(`/api/accounts/${owner}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ kfOrgId }), - }) - if (res.ok) { - setSuccess('Ownership transferred.') - } else { - const body = await res.json().catch(() => ({})) - setError(body.error ?? 'Failed to transfer ownership.') - } - } finally { - setSubmitting('') + { + e.preventDefault() + clearMessages() + setSubmitting('kforg') + try { + const res = await fetch(`/api/accounts/${owner}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ kfOrgId }), + }) + if (res.ok) { + setSuccess('Ownership transferred.') + } else { + const body = await res.json().catch(() => ({})) + setError(body.error ?? 'Failed to transfer ownership.') } - }} - className="space-y-3" - > -
- - -
- +
)} diff --git a/src/routes/[owner]/settings/keys.data.ts b/src/routes/[owner]/settings/keys.data.ts new file mode 100644 index 0000000..c47f9f0 --- /dev/null +++ b/src/routes/[owner]/settings/keys.data.ts @@ -0,0 +1,22 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: (params: Record) => `API Keys — ${params.owner} · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + + const [orgData, collections] = await Promise.all([ + fetch(new URL(`/api/accounts/${params.owner}`, base), { headers }).then((r) => + r.ok ? r.json() : null, + ), + fetch(new URL(`/api/accounts/${params.owner}/collections`, base), { headers }).then((r) => + r.ok ? r.json() : [], + ), + ]) + + if (!orgData) throw new Response('Not Found', { status: 404 }) + return { orgData, collections } +} diff --git a/src/routes/[owner]/settings/keys.tsx b/src/routes/[owner]/settings/keys.tsx index 5252b1c..2c51d5d 100644 --- a/src/routes/[owner]/settings/keys.tsx +++ b/src/routes/[owner]/settings/keys.tsx @@ -1,9 +1,8 @@ import { type FormEvent, useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { Link, useLoaderData, useParams } from 'react-router' import { ApiPlayground } from '~/components/ApiPlayground' import BaseLayout from '~/components/BaseLayout' -import { NotFoundError } from '~/components/NotFound' import { useAppContext } from '~/lib/app-context' import { authClient } from '~/lib/auth-client' @@ -38,12 +37,12 @@ function getScope(permissions?: Record): string { export default function OwnerSettingsKeys() { const { owner } = useParams() const { currentUser } = useAppContext() + const { orgData, collections } = useLoaderData() as { orgData: any; collections: any[] } + + const org = currentUser?.orgs?.find((o: any) => o.slug === owner) + const isAdmin = org?.role === 'admin' || org?.role === 'owner' - const [orgData, setOrgData] = useState(null) - const [isAdmin, setIsAdmin] = useState(false) const [keys, setKeys] = useState([]) - const [collections, setCollections] = useState([]) - const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [newKeyResult, setNewKeyResult] = useState<{ key: string; name: string } | null>(null) const [submitting, setSubmitting] = useState(false) @@ -59,35 +58,8 @@ export default function OwnerSettingsKeys() { } useEffect(() => { - if (!owner || !currentUser) return - - const org = currentUser.orgs?.find((o: any) => o.slug === owner) - if (!org) { - window.location.href = `/${owner}` - return - } - - const adminRole = org.role === 'admin' || org.role === 'owner' - setIsAdmin(adminRole) - - Promise.all([ - fetch(`/api/accounts/${owner}`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : null, - ), - loadKeys(), - fetch(`/api/accounts/${owner}/collections`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : [], - ), - ]).then(([org, , cols]) => { - if (!org) { - setLoading(false) - return - } - setOrgData(org) - setCollections(cols as any) - setLoading(false) - }) - }, [owner, currentUser]) + loadKeys() + }, []) if (!currentUser) { window.location.href = '/login' @@ -126,15 +98,6 @@ export default function OwnerSettingsKeys() { setKeys((prev) => prev.filter((k) => k.id !== keyId)) } - if (loading) { - return ( - -
Loading…
-
- ) - } - if (!orgData) throw new NotFoundError() - return (
diff --git a/src/routes/[owner]/settings/members.data.ts b/src/routes/[owner]/settings/members.data.ts new file mode 100644 index 0000000..18693fd --- /dev/null +++ b/src/routes/[owner]/settings/members.data.ts @@ -0,0 +1,14 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: (params: Record) => `Members — ${params.owner} · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + + const res = await fetch(new URL(`/api/accounts/${params.owner}`, base), { headers }) + if (!res.ok) throw new Response('Not Found', { status: 404 }) + return { orgData: await res.json() } +} diff --git a/src/routes/[owner]/settings/members.tsx b/src/routes/[owner]/settings/members.tsx index 315c73a..4e6f5b4 100644 --- a/src/routes/[owner]/settings/members.tsx +++ b/src/routes/[owner]/settings/members.tsx @@ -1,22 +1,22 @@ import { type FormEvent, useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { Link, useLoaderData, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' -import { NotFoundError } from '~/components/NotFound' import { useAppContext } from '~/lib/app-context' import { authClient } from '~/lib/auth-client' export default function OwnerSettingsMembers() { const { owner } = useParams() const { currentUser } = useAppContext() + const { orgData } = useLoaderData() as { orgData: any } + + const org = currentUser?.orgs?.find((o: any) => o.slug === owner) + const orgId = org?.organizationId ?? null + const isOwner = org?.role === 'owner' + const isAdmin = org?.role === 'admin' || isOwner - const [orgData, setOrgData] = useState(null) - const [orgId, setOrgId] = useState(null) - const [isOwner, setIsOwner] = useState(false) - const [isAdmin, setIsAdmin] = useState(false) const [members, setMembers] = useState([]) const [invitations, setInvitations] = useState([]) - const [loading, setLoading] = useState(true) const [success, setSuccess] = useState('') const [error, setError] = useState('') const [submitting, setSubmitting] = useState(false) @@ -39,36 +39,21 @@ export default function OwnerSettingsMembers() { } useEffect(() => { - if (!owner || !currentUser) return - - const org = currentUser.orgs?.find((o: any) => o.slug === owner) - if (!org) { - window.location.href = `/${owner}` - return - } - - const id = org.organizationId - setOrgId(id) - setIsOwner(org.role === 'owner') - setIsAdmin(org.role === 'admin' || org.role === 'owner') - - Promise.all([ - fetch(`/api/accounts/${owner}`, { credentials: 'include' }).then((r) => - r.ok ? r.json() : null, - ), - loadMembers(id), - loadInvitations(id), - ]).then(([orgResult]) => { - if (orgResult) setOrgData(orgResult) - setLoading(false) - }) - }, [owner, currentUser]) + if (!orgId) return + loadMembers(orgId) + loadInvitations(orgId) + }, [orgId]) if (!currentUser) { window.location.href = '/login' return null } + if (!org) { + window.location.href = `/${owner}` + return null + } + function clearMessages() { setSuccess('') setError('') @@ -154,15 +139,6 @@ export default function OwnerSettingsMembers() { } } - if (loading) { - return ( - -
Loading…
-
- ) - } - if (!orgData) throw new NotFoundError() - const pendingInvitations = invitations.filter((i: any) => i.status === 'pending') return ( diff --git a/src/routes/admin/mirror.data.ts b/src/routes/admin/mirror.data.ts index a9c29a8..d946f36 100644 --- a/src/routes/admin/mirror.data.ts +++ b/src/routes/admin/mirror.data.ts @@ -1,4 +1,4 @@ import { requireAuth } from '~/lib/auth-middleware' export const middleware = [requireAuth] -export const handle = { title: 'Mirror Admin — Underlay' } +export const handle = { title: 'Mirror Admin · Underlay' } diff --git a/src/routes/blog/[slug].data.ts b/src/routes/blog/[slug].data.ts new file mode 100644 index 0000000..c36c154 --- /dev/null +++ b/src/routes/blog/[slug].data.ts @@ -0,0 +1,48 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const posts: Record = { + '2024-04-27-underlay-revived': { + title: 'Underlay, Revived', + subtitle: 'The landscape changed. The project can finally be simple.', + date: '2024-04-27', + }, + '2024-04-27-institutional-repositories': { + title: 'Institutional Repositories', + subtitle: 'Why universities need better infrastructure for structured data.', + date: '2024-04-27', + }, + '2026-04-28-atproto-integration': { + title: 'AT Protocol Integration', + subtitle: 'Connecting Underlay to the decentralized social web.', + date: '2026-04-28', + }, + '2026-04-30-schema-evolution': { + title: 'Schema Evolution', + subtitle: 'How Underlay handles schema changes across versions.', + date: '2026-04-30', + }, + '2026-06-08-content-addressed-records': { + title: 'Content-Addressed Records', + subtitle: + 'Applying the insight that already works for schemas and files to the records themselves.', + date: '2026-06-08', + }, + '2026-06-08-permanently-addressable-structured-data': { + title: 'Permanently Addressable Structured Data', + subtitle: 'What Underlay is, why it matters now, and how it works.', + date: '2026-06-08', + }, +} + +export const handle = { + title: (params: Record) => { + const post = params.slug ? posts[params.slug] : undefined + return post ? `${post.title} · Underlay` : 'Blog · Underlay' + }, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const res = await fetch(new URL(`/api/blog/${params.slug}`, base)) + return { content: res.ok ? await res.text() : '' } +} diff --git a/src/routes/blog/[slug].tsx b/src/routes/blog/[slug].tsx index cb19fa4..9bb1491 100644 --- a/src/routes/blog/[slug].tsx +++ b/src/routes/blog/[slug].tsx @@ -1,58 +1,15 @@ -import { useEffect, useState } from 'react' -import { useParams } from 'react-router' +import { useLoaderData, useParams } from 'react-router' import BlogLayout from '~/components/BlogLayout' -// Blog post metadata -const posts: Record = { - '2024-04-27-underlay-revived': { - title: 'Underlay, Revived', - subtitle: 'The landscape changed. The project can finally be simple.', - date: '2024-04-27', - }, - '2024-04-27-institutional-repositories': { - title: 'Institutional Repositories', - subtitle: 'Why universities need better infrastructure for structured data.', - date: '2024-04-27', - }, - '2026-04-28-atproto-integration': { - title: 'AT Protocol Integration', - subtitle: 'Connecting Underlay to the decentralized social web.', - date: '2026-04-28', - }, - '2026-04-30-schema-evolution': { - title: 'Schema Evolution', - subtitle: 'How Underlay handles schema changes across versions.', - date: '2026-04-30', - }, - '2026-06-08-content-addressed-records': { - title: 'Content-Addressed Records', - subtitle: - 'Applying the insight that already works for schemas and files to the records themselves.', - date: '2026-06-08', - }, - '2026-06-08-permanently-addressable-structured-data': { - title: 'Permanently Addressable Structured Data', - subtitle: 'What Underlay is, why it matters now, and how it works.', - date: '2026-06-08', - }, -} +import { posts } from './[slug].data' export default function BlogPost() { const { slug } = useParams() - const [content, setContent] = useState(null) + const { content } = useLoaderData() as { content: string } const meta = slug ? posts[slug] : undefined - useEffect(() => { - if (!slug) return - // Fetch the rendered markdown from an API endpoint or static file - fetch(`/api/blog/${slug}`) - .then((res) => (res.ok ? res.text() : '')) - .then(setContent) - .catch(() => setContent('')) - }, [slug]) - if (!meta) { return ( @@ -63,10 +20,10 @@ export default function BlogPost() { return ( - {content === null ? ( -

Loading...

- ) : ( + {content ? (
+ ) : ( +

Post content unavailable.

)} ) diff --git a/src/routes/blog/index.data.ts b/src/routes/blog/index.data.ts new file mode 100644 index 0000000..0d977d3 --- /dev/null +++ b/src/routes/blog/index.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Blog · Underlay' } diff --git a/src/routes/dashboard.data.ts b/src/routes/dashboard.data.ts index 7abc5c5..07431f5 100644 --- a/src/routes/dashboard.data.ts +++ b/src/routes/dashboard.data.ts @@ -1,4 +1,4 @@ import { requireAuth } from '~/lib/auth-middleware' export const middleware = [requireAuth] -export const handle = { title: 'Dashboard — Underlay' } +export const handle = { title: 'Dashboard · Underlay' } diff --git a/src/routes/dev.data.ts b/src/routes/dev.data.ts new file mode 100644 index 0000000..4c65215 --- /dev/null +++ b/src/routes/dev.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Dev · Underlay' } diff --git a/src/routes/dev/landing.data.ts b/src/routes/dev/landing.data.ts new file mode 100644 index 0000000..0c6862d --- /dev/null +++ b/src/routes/dev/landing.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Landing Dev · Underlay' } diff --git a/src/routes/docs/api/accounts.data.ts b/src/routes/docs/api/accounts.data.ts new file mode 100644 index 0000000..440b39f --- /dev/null +++ b/src/routes/docs/api/accounts.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Accounts API · Underlay' } diff --git a/src/routes/docs/api/collections.data.ts b/src/routes/docs/api/collections.data.ts new file mode 100644 index 0000000..f0ecc32 --- /dev/null +++ b/src/routes/docs/api/collections.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Collections API · Underlay' } diff --git a/src/routes/docs/api/files.data.ts b/src/routes/docs/api/files.data.ts new file mode 100644 index 0000000..eb92662 --- /dev/null +++ b/src/routes/docs/api/files.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Files API · Underlay' } diff --git a/src/routes/docs/api/index.data.ts b/src/routes/docs/api/index.data.ts new file mode 100644 index 0000000..79c33dd --- /dev/null +++ b/src/routes/docs/api/index.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'API Reference · Underlay' } diff --git a/src/routes/docs/api/versions.data.ts b/src/routes/docs/api/versions.data.ts new file mode 100644 index 0000000..e2e87e2 --- /dev/null +++ b/src/routes/docs/api/versions.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Versions API · Underlay' } diff --git a/src/routes/docs/concepts.data.ts b/src/routes/docs/concepts.data.ts new file mode 100644 index 0000000..625f4cd --- /dev/null +++ b/src/routes/docs/concepts.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Concepts · Underlay' } diff --git a/src/routes/docs/index.data.ts b/src/routes/docs/index.data.ts new file mode 100644 index 0000000..7caa91d --- /dev/null +++ b/src/routes/docs/index.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Docs · Underlay' } diff --git a/src/routes/docs/integration.data.ts b/src/routes/docs/integration.data.ts new file mode 100644 index 0000000..d707834 --- /dev/null +++ b/src/routes/docs/integration.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Integration · Underlay' } diff --git a/src/routes/docs/quickstart.data.ts b/src/routes/docs/quickstart.data.ts new file mode 100644 index 0000000..1d6dc59 --- /dev/null +++ b/src/routes/docs/quickstart.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Quickstart · Underlay' } diff --git a/src/routes/docs/self-host.data.ts b/src/routes/docs/self-host.data.ts new file mode 100644 index 0000000..15bef6f --- /dev/null +++ b/src/routes/docs/self-host.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Self-Hosting · Underlay' } diff --git a/src/routes/explore.data.ts b/src/routes/explore.data.ts new file mode 100644 index 0000000..ccfab79 --- /dev/null +++ b/src/routes/explore.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Explore · Underlay' } diff --git a/src/routes/forgot-password.data.ts b/src/routes/forgot-password.data.ts new file mode 100644 index 0000000..9c4113a --- /dev/null +++ b/src/routes/forgot-password.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Forgot Password · Underlay' } diff --git a/src/routes/index.data.ts b/src/routes/index.data.ts new file mode 100644 index 0000000..083a7a9 --- /dev/null +++ b/src/routes/index.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Underlay' } diff --git a/src/routes/invitations/accept.data.ts b/src/routes/invitations/accept.data.ts new file mode 100644 index 0000000..28bb03c --- /dev/null +++ b/src/routes/invitations/accept.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Accept Invitation · Underlay' } diff --git a/src/routes/landing-playground.data.ts b/src/routes/landing-playground.data.ts new file mode 100644 index 0000000..7759794 --- /dev/null +++ b/src/routes/landing-playground.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Landing Playground · Underlay' } diff --git a/src/routes/login.data.ts b/src/routes/login.data.ts new file mode 100644 index 0000000..cf3f6bd --- /dev/null +++ b/src/routes/login.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Log In · Underlay' } diff --git a/src/routes/logout.data.ts b/src/routes/logout.data.ts new file mode 100644 index 0000000..e523af3 --- /dev/null +++ b/src/routes/logout.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Log Out · Underlay' } diff --git a/src/routes/protocol.data.ts b/src/routes/protocol.data.ts new file mode 100644 index 0000000..02d9005 --- /dev/null +++ b/src/routes/protocol.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Protocol · Underlay' } diff --git a/src/routes/query.data.ts b/src/routes/query.data.ts new file mode 100644 index 0000000..5d7ea5c --- /dev/null +++ b/src/routes/query.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Query · Underlay' } diff --git a/src/routes/records/[hash].data.ts b/src/routes/records/[hash].data.ts new file mode 100644 index 0000000..9e03c1f --- /dev/null +++ b/src/routes/records/[hash].data.ts @@ -0,0 +1,13 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: () => `Record · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + const res = await fetch(new URL(`/api/records/${params.hash}/provenance`, base), { headers }) + if (!res.ok) throw new Response('Not Found', { status: 404 }) + return res.json() +} diff --git a/src/routes/records/[hash].tsx b/src/routes/records/[hash].tsx index 9a0b52e..754ac9b 100644 --- a/src/routes/records/[hash].tsx +++ b/src/routes/records/[hash].tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { Link, useLoaderData, useParams } from 'react-router' import BaseLayout from '~/components/BaseLayout' @@ -25,42 +24,7 @@ interface RecordData { export default function RecordDetailPage() { const params = useParams() const hash = params.hash! - const [record, setRecord] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - useEffect(() => { - fetch(`/api/records/${hash}/provenance`, { credentials: 'same-origin' }) - .then(async (res) => { - if (!res.ok) { - setError('Record not found.') - return - } - setRecord(await res.json()) - }) - .catch(() => setError('Failed to load record.')) - .finally(() => setLoading(false)) - }, [hash]) - - if (loading) { - return ( - -
-

Loading…

-
-
- ) - } - - if (error || !record) { - return ( - -
-

{error || 'Record not found.'}

-
-
- ) - } + const record = useLoaderData() as RecordData const fields = Object.entries(record.data) diff --git a/src/routes/reset-password.data.ts b/src/routes/reset-password.data.ts new file mode 100644 index 0000000..7c6ad65 --- /dev/null +++ b/src/routes/reset-password.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Reset Password · Underlay' } diff --git a/src/routes/schemas/[id].data.ts b/src/routes/schemas/[id].data.ts new file mode 100644 index 0000000..2845e28 --- /dev/null +++ b/src/routes/schemas/[id].data.ts @@ -0,0 +1,13 @@ +import type { LoaderFunctionArgs } from 'react-router' + +export const handle = { + title: () => `Schema · Underlay`, +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const base = new URL(request.url).origin + const headers = { Cookie: request.headers.get('Cookie') ?? '' } + const res = await fetch(new URL(`/api/schemas/${params.id}`, base), { headers }) + if (!res.ok) throw new Response('Not Found', { status: 404 }) + return res.json() +} diff --git a/src/routes/schemas/[id].tsx b/src/routes/schemas/[id].tsx index 7b2d9bb..516ae16 100644 --- a/src/routes/schemas/[id].tsx +++ b/src/routes/schemas/[id].tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { Link, useLoaderData } from 'react-router' import BaseLayout from '~/components/BaseLayout' import SchemaLabelManager from '~/components/SchemaLabelManager' @@ -14,45 +13,7 @@ interface SchemaData { } export default function SchemaDetailPage() { - const params = useParams() - const schemaId = params.id - const [schema, setSchema] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - useEffect(() => { - if (!schemaId) return - fetch(`/api/schemas/${schemaId}`, { credentials: 'same-origin' }) - .then(async (res) => { - if (!res.ok) { - setError('Schema not found.') - return - } - setSchema(await res.json()) - }) - .catch(() => setError('Failed to load schema.')) - .finally(() => setLoading(false)) - }, [schemaId]) - - if (loading) { - return ( - -
-

Loading…

-
-
- ) - } - - if (error || !schema) { - return ( - -
-

{error || 'Schema not found.'}

-
-
- ) - } + const schema = useLoaderData() as SchemaData const properties = (schema.schema as any)?.properties ?? {} const fields = Object.entries(properties) diff --git a/src/routes/schemas/index.data.ts b/src/routes/schemas/index.data.ts new file mode 100644 index 0000000..17b7d14 --- /dev/null +++ b/src/routes/schemas/index.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Schemas · Underlay' } diff --git a/src/routes/settings/avatar.data.ts b/src/routes/settings/avatar.data.ts index 26d3ecd..74479ad 100644 --- a/src/routes/settings/avatar.data.ts +++ b/src/routes/settings/avatar.data.ts @@ -1,4 +1,4 @@ import { requireAuth } from '~/lib/auth-middleware' export const middleware = [requireAuth] -export const handle = { title: 'Avatar — Underlay' } +export const handle = { title: 'Avatar · Underlay' } diff --git a/src/routes/settings/index.data.ts b/src/routes/settings/index.data.ts index b6df305..2fc17ad 100644 --- a/src/routes/settings/index.data.ts +++ b/src/routes/settings/index.data.ts @@ -1,4 +1,4 @@ import { requireAuth } from '~/lib/auth-middleware' export const middleware = [requireAuth] -export const handle = { title: 'Settings — Underlay' } +export const handle = { title: 'Settings · Underlay' } diff --git a/src/routes/settings/keys.data.ts b/src/routes/settings/keys.data.ts index 9ae172f..4c75a71 100644 --- a/src/routes/settings/keys.data.ts +++ b/src/routes/settings/keys.data.ts @@ -1,4 +1,4 @@ import { requireAuth } from '~/lib/auth-middleware' export const middleware = [requireAuth] -export const handle = { title: 'API Keys — Underlay' } +export const handle = { title: 'API Keys · Underlay' } diff --git a/src/routes/settings/sessions.data.ts b/src/routes/settings/sessions.data.ts index 3755aa7..715effa 100644 --- a/src/routes/settings/sessions.data.ts +++ b/src/routes/settings/sessions.data.ts @@ -1,4 +1,4 @@ import { requireAuth } from '~/lib/auth-middleware' export const middleware = [requireAuth] -export const handle = { title: 'Sessions — Underlay' } +export const handle = { title: 'Sessions · Underlay' } diff --git a/src/routes/signup.data.ts b/src/routes/signup.data.ts new file mode 100644 index 0000000..47e39f0 --- /dev/null +++ b/src/routes/signup.data.ts @@ -0,0 +1 @@ +export const handle = { title: 'Sign Up · Underlay' }