@drhomet/mithaq 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/dist/asas/actor.d.ts +96 -0
- package/dist/asas/actor.js +114 -0
- package/dist/asas/build.d.ts +231 -0
- package/dist/asas/build.js +787 -0
- package/dist/asas/cli/mithaq.d.ts +2 -0
- package/dist/asas/cli/mithaq.js +145 -0
- package/dist/asas/cli/upgrade.d.ts +20 -0
- package/dist/asas/cli/upgrade.js +147 -0
- package/dist/asas/http/fetch.d.ts +23 -0
- package/dist/asas/http/fetch.js +115 -0
- package/dist/asas/http/orpc.d.ts +43 -0
- package/dist/asas/http/orpc.js +135 -0
- package/dist/asas/runner/bulk.d.ts +20 -0
- package/dist/asas/runner/bulk.js +191 -0
- package/dist/asas/runner/errors.d.ts +60 -0
- package/dist/asas/runner/errors.js +59 -0
- package/dist/asas/runner/read.d.ts +68 -0
- package/dist/asas/runner/read.js +579 -0
- package/dist/asas/runner/runtime.d.ts +21 -0
- package/dist/asas/runner/runtime.js +8 -0
- package/dist/asas/runner/steps.d.ts +400 -0
- package/dist/asas/runner/steps.js +271 -0
- package/dist/asas/runner/tenant.d.ts +54 -0
- package/dist/asas/runner/tenant.js +213 -0
- package/dist/asas/runner/write.d.ts +444 -0
- package/dist/asas/runner/write.js +791 -0
- package/dist/auth/better-auth/access.d.ts +15 -0
- package/dist/auth/better-auth/access.js +71 -0
- package/dist/auth/better-auth/generate.d.ts +29 -0
- package/dist/auth/better-auth/generate.js +310 -0
- package/dist/auth/better-auth/index.d.ts +92 -0
- package/dist/auth/better-auth/index.js +81 -0
- package/dist/auth/better-auth/tenant.d.ts +18 -0
- package/dist/auth/better-auth/tenant.js +26 -0
- package/dist/auth/local/index.d.ts +285 -0
- package/dist/auth/local/index.js +736 -0
- package/dist/base/decimal.d.ts +9 -0
- package/dist/base/decimal.js +16 -0
- package/dist/base/fields.d.ts +123 -0
- package/dist/base/fields.js +51 -0
- package/dist/base/index.d.ts +46 -0
- package/dist/base/index.js +5 -0
- package/dist/base/kinds.d.ts +54 -0
- package/dist/base/kinds.js +78 -0
- package/dist/base/rules.d.ts +73 -0
- package/dist/base/rules.js +80 -0
- package/dist/drizzle/index.d.ts +438 -0
- package/dist/drizzle/index.js +832 -0
- package/dist/drizzle/kinds.d.ts +27 -0
- package/dist/drizzle/kinds.js +41 -0
- package/dist/drizzle/migrate.d.ts +133 -0
- package/dist/drizzle/migrate.js +1285 -0
- package/dist/drizzle/proxy/index.d.ts +1 -0
- package/dist/drizzle/proxy/index.js +1 -0
- package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
- package/dist/drizzle/proxy/node-postgres.js +1 -0
- package/dist/drizzle/proxy/pg-core.d.ts +1 -0
- package/dist/drizzle/proxy/pg-core.js +1 -0
- package/dist/drizzle/rules.d.ts +336 -0
- package/dist/drizzle/rules.js +44 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +36 -0
- package/dist/mithaq/define.d.ts +288 -0
- package/dist/mithaq/define.js +43 -0
- package/dist/mithaq/generate-messages.d.ts +38 -0
- package/dist/mithaq/generate-messages.js +234 -0
- package/dist/mithaq/language.d.ts +335 -0
- package/dist/mithaq/language.js +76 -0
- package/dist/mithaq/lock.d.ts +71 -0
- package/dist/mithaq/lock.js +329 -0
- package/dist/mithaq/messages.d.ts +55 -0
- package/dist/mithaq/messages.js +106 -0
- package/dist/mithaq/refusal.d.ts +6 -0
- package/dist/mithaq/refusal.js +6 -0
- package/dist/mithaq/resolve.d.ts +10 -0
- package/dist/mithaq/resolve.js +954 -0
- package/dist/mithaq/rows.d.ts +87 -0
- package/dist/mithaq/rows.js +174 -0
- package/dist/mithaq/rukn.d.ts +72 -0
- package/dist/mithaq/rukn.js +15 -0
- package/dist/mithaq/standard-schema.d.ts +38 -0
- package/dist/mithaq/standard-schema.js +2 -0
- package/dist/mithaq/types.d.ts +358 -0
- package/dist/mithaq/types.js +15 -0
- package/dist/mithaq/user.d.ts +19 -0
- package/dist/mithaq/user.js +16 -0
- package/dist/mithaq/where.d.ts +32 -0
- package/dist/mithaq/where.js +87 -0
- package/dist/rbac/index.d.ts +105 -0
- package/dist/rbac/index.js +182 -0
- package/dist/rbac/steps.d.ts +22 -0
- package/dist/rbac/steps.js +161 -0
- package/dist/rich-text/index.d.ts +27 -0
- package/dist/rich-text/index.js +58 -0
- package/dist/rich-text/rich-text.d.ts +159 -0
- package/dist/rich-text/rich-text.js +372 -0
- package/dist/screen/client.d.ts +153 -0
- package/dist/screen/client.js +68 -0
- package/dist/screen/generate.d.ts +14 -0
- package/dist/screen/generate.js +226 -0
- package/dist/screen/index.d.ts +479 -0
- package/dist/screen/index.js +645 -0
- package/dist/screen/react.d.ts +95 -0
- package/dist/screen/react.js +58 -0
- package/dist/screen/studio.d.ts +24 -0
- package/dist/screen/studio.js +68 -0
- package/dist/service/index.d.ts +63 -0
- package/dist/service/index.js +15 -0
- package/dist/storage/allowed-type.d.ts +5 -0
- package/dist/storage/allowed-type.js +7 -0
- package/dist/storage/index.d.ts +43 -0
- package/dist/storage/index.js +44 -0
- package/dist/storage/storage.d.ts +384 -0
- package/dist/storage/storage.js +405 -0
- package/dist/versions/index.d.ts +37 -0
- package/dist/versions/index.js +8 -0
- package/dist/versions/versions.d.ts +104 -0
- package/dist/versions/versions.js +160 -0
- package/dist/zod/index.d.ts +184 -0
- package/dist/zod/index.js +121 -0
- package/dist/zod/kinds.d.ts +34 -0
- package/dist/zod/kinds.js +49 -0
- package/dist/zod/rules.d.ts +117 -0
- package/dist/zod/rules.js +67 -0
- package/package.json +63 -4
- package/r/mithaq-cells.json +20 -0
- package/r/mithaq-controls.json +37 -0
- package/r/mithaq-directional-icon.json +15 -0
- package/r/mithaq-expected-updated-at.json +15 -0
- package/r/mithaq-field-shell.json +18 -0
- package/r/mithaq-filter-operators.json +15 -0
- package/r/mithaq-form.json +33 -0
- package/r/mithaq-list-columns.json +23 -0
- package/r/mithaq-list-filters.json +24 -0
- package/r/mithaq-list-pagination.json +24 -0
- package/r/mithaq-list-query.json +18 -0
- package/r/mithaq-list-selection.json +19 -0
- package/r/mithaq-list.json +33 -0
- package/r/mithaq-nav.json +24 -0
- package/r/mithaq-nodes.json +34 -0
- package/r/mithaq-relationship-cell.json +20 -0
- package/r/mithaq-relationship-control.json +20 -0
- package/r/mithaq-rich-text.json +40 -0
- package/r/mithaq-screens-host.json +96 -0
- package/r/mithaq-storage.json +44 -0
- package/r/mithaq-upload.json +29 -0
- package/r/registry.json +550 -0
- package/README.md +0 -3
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-screens-host",
|
|
4
|
+
"title": "Mithaq screens host",
|
|
5
|
+
"description": "What a TanStack Start app draws every screen with: the server functions that resolve the actor, the access report and the screen, the frame and the page each generated route file hands its screen's glob to, the list read through TanStack Query off its qs URL, the form's landing after a save and its guard on leaving with a change, the fetch plug's client, the /unauthorized route, the public landing page at /, the language switch its header and the app's auth pages draw, and the frame, boxes and password toggle those auth pages share. Nothing in it names a screen; `mithaq generate screens` writes each screen's glob and route files beside it. What it imports from the app, which no item writes: `localeOf`, `locales`, `refusalKey` and the type `Locale` from `src/copy.ts`; `wanted` from `src/redirect.ts`; `useCopy` from `src/routes/__root.tsx`, whose route context names the app's own copy key as `app`; the project as the package `asas`; `@/components/mithaq/form`, which the form item writes. The app's `createRouter` spreads `flatSearch` from `src/screen-list.tsx`, so the router reads and writes the list's URL pairs as strings.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tanstack/react-query",
|
|
8
|
+
"@tanstack/react-router",
|
|
9
|
+
"@tanstack/react-start"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"button",
|
|
13
|
+
"card",
|
|
14
|
+
"input",
|
|
15
|
+
"input-group",
|
|
16
|
+
"@mithaq/mithaq-field-shell",
|
|
17
|
+
"@mithaq/mithaq-nodes",
|
|
18
|
+
"@mithaq/mithaq-list",
|
|
19
|
+
"@mithaq/mithaq-nav"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
{
|
|
23
|
+
"path": "registry/host/screens.tsx",
|
|
24
|
+
"content": "import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query'\nimport { notFound, redirect, useMatches, useRouter } from '@tanstack/react-router'\nimport { createIsomorphicFn, createServerFn, createServerOnlyFn } from '@tanstack/react-start'\nimport { Suspense, lazy, useEffect, useState, type ComponentType, type ReactElement, type ReactNode } from 'react'\nimport { holds, navOf, referencesIn, type AccessReport, type ClientKayan, type LoaderTools, type Row, type ScreenClient } from 'mithaq/admin'\nimport { ScreenContext, useScreen, type ScreenState } from 'mithaq/admin/react'\nimport { actingAs, arm, authArm, caused, clientOf, readsOf, takesKey } from './api.ts'\nimport { clampedHref, routedHref } from './links.ts'\nimport { mergedHeaders } from './request-headers.ts'\nimport { documentHead, localeOf, locales, translator, type Locale } from './copy.ts'\nimport { FieldValue, type FormClient, type FormProps } from './components/mithaq/form.tsx'\nimport type { Page } from './components/mithaq/list.tsx'\nimport { QueryRefused, findOf, listRaw, prefixOf, readQuery, rulesOf, scopedTo, withList, type RawQuery } from './components/mithaq/list-query.ts'\nimport { createsIn, newTitleOf, titleOf } from './components/mithaq/controls.tsx'\nimport { NavLinks, NodeButton, OwnButton, RecordFrame, formIn, holdFocus, frameOf, listIn, nodesOf, pageButtonOf, pageOf, readsAsValues, refOf, relatedIn, rowTitle, scopeOf, type Draw, type Refs, type TreePage } from './components/mithaq/nodes.tsx'\nimport { idsOf } from './components/mithaq/relationship-control.tsx'\nimport { ScreenForm } from './screen-form.tsx'\nimport { ScreenList, askedFor, readsDrafts, rowReadsDrafts, rowsKey } from './screen-list.tsx'\nimport { screenNamed } from './screen-names.ts'\nimport { slide } from './slide.ts'\nimport { useCopy } from './routes/__root.tsx'\n\n/**\n * The built asas, on the server alone. The module cache serves one build for\n * every call, since `asas` stays out of the server bundle; a changed `asas` takes a restart.\n */\nexport const getAsas = createServerOnlyFn(async () => (await import('asas')).asas)\n\ntype InProcess = Record<string, { operations: Record<string, (input: object, options: object) => Promise<unknown>> }>\n\n/**\n * Each kayan's operations in process. In the platform app a read of a kayan asks `tenant: 'all'`,\n * which the runner grants platform staff there alone, so staff read every organization's rows; a\n * global is one organization's row and asks for none, and a write never asks.\n */\nconst inProcess = async (asas: Awaited<ReturnType<typeof getAsas>>): Promise<InProcess> => {\n const kayan = asas.kayan as unknown as InProcess\n if (!(await import('asas')).onPlatform()) return kayan\n const reads = new Set(asas.operations.filter((one) => one.effect === 'read' && !one.single).map((one) => `${one.kayan}/${one.name}`))\n return Object.fromEntries(Object.entries(kayan).map(([name, one]) => [name, {\n operations: Object.fromEntries(Object.entries(one.operations).map(([operation, run]) =>\n [operation, reads.has(`${name}/${operation}`) ? (input: object, options: object) => run(input, { ...options, tenant: 'all' }) : run])),\n }]))\n}\n\n/** One screen's generated glob: each file its references name, keyed by the path a reference prints, loaded only when called. */\nexport type Files = Readonly<Record<string, () => Promise<unknown>>>\n\ntype Drawn = ComponentType<Record<string, unknown>>\nconst loaded = new Map<string, Drawn>()\nconst drawn = new Map<string, Drawn>()\n\nconst load = async (files: Files, reference: string) => {\n const at = reference.indexOf('#')\n const exported = ((await files[reference.slice(0, at)]?.()) as Record<string, Drawn> | undefined)?.[reference.slice(at + 1)]\n if (exported) loaded.set(reference, exported)\n return exported\n}\n\n// One `lazy` per reference. A loaded file is handed over as a thenable that\n// resolves at once, which React reads without suspending; an unloaded one\n// (hydration before any loader ran) suspends.\nconst drawnOf = (files: Files, reference: string) => {\n let found = drawn.get(reference)\n if (!found) {\n found = lazy(() => {\n const ready = loaded.get(reference)\n return ready\n ? ({ then: (resolve: (module: { default: Drawn }) => void) => resolve({ default: ready }) } as unknown as Promise<{ default: Drawn }>)\n : load(files, reference).then((exported) => ({ default: exported ?? (() => null) }))\n })\n drawn.set(reference, found)\n }\n return found\n}\n\nconst refsOf = (files: Files, screen: ScreenClient): Refs =>\n Object.fromEntries(\n referencesIn(screen)\n .filter((reference) => Object.hasOwn(files, reference.slice(0, reference.indexOf('#'))))\n .map((reference) => [reference, drawnOf(files, reference)]),\n ) as Refs\n\nconst preload = (files: Files, ...parts: unknown[]) => Promise.all(referencesIn(parts).map((reference) => loaded.get(reference) ?? load(files, reference)))\n\n/** This build's screens by name: what a request's screen name is checked against before anything is read by it. */\nconst screensOf = (targets: unknown) =>\n Object.fromEntries(Object.entries(targets as Record<string, { capability?: string } | undefined>).filter(([, target]) => target?.capability === 'screen'))\n\nconst segments = (path: string) => path.split('/').filter(Boolean)\n\n/**\n * The page a path asks for, a kayan's or the screen's own, longest first and a\n * static segment before a parameter, so `/courses/new` is the create page, not\n * an edit of a row called `new`.\n */\nexport const matchPage = (screen: ScreenClient, path: string) => {\n const asked = segments(path)\n const pages = [\n ...Object.entries(screen.pages).map(([name, page]) => ({ kayan: undefined, name, page, parts: segments(page.path) })),\n ...Object.values(screen.kayan).flatMap((kayan) => Object.entries(kayan.pages).map(([name, page]) => ({ kayan: kayan.name as string | undefined, name, page, parts: segments(page.path) }))),\n ]\n .sort((a, b) => b.parts.length - a.parts.length || a.parts.filter((one) => one.startsWith(':')).length - b.parts.filter((one) => one.startsWith(':')).length)\n for (const one of pages) {\n if (one.parts.length !== asked.length) continue\n const params: Record<string, string> = {}\n if (one.parts.every((part, at) => (part.startsWith(':') ? ((params[part.slice(1)] = asked[at]!), true) : part === asked[at]))) {\n return { kayan: one.kayan, page: one.name, params }\n }\n }\n return undefined\n}\n\ntype Actor = {\n readonly id?: string\n readonly name?: string | null\n readonly email?: string | null\n readonly impersonatedBy?: unknown\n readonly activeOrganizationId?: string | null\n readonly organizations?: readonly { readonly id: string; readonly name: string; readonly slug?: string }[]\n}\n\n// `'unauthorized'` is answered before the projection is read, so a refused screen sends no kayan, field, row or page;\n// what is returned is serialized into the document, so projection and report are cut to what the actor may read.\nconst resolveScreen = async (asked: unknown) => {\n const [{ getRequest, setResponseHeader }, asas] = await Promise.all([import('@tanstack/react-start/server'), getAsas()])\n const name = screenNamed(screensOf(asas.targets), asked)\n const request = getRequest()\n const ask = (path: string) => asas.handler(new Request(new URL(path, request.url), { headers: request.headers }))\n await slide({ strategies: asas.auth.strategies, headers: request.headers, setResponseHeader })\n // Off the strategy loop, not the provider's session route: in process, so\n // no request counts against its rate limit.\n const actor = (await asas.auth.actor(request.headers)) as Actor | null\n if (!actor?.email) return undefined\n const report = (await (await ask('/api/access')).json()) as AccessReport\n if (!report.screens?.includes(name)) return 'unauthorized' as const\n const full = (asas.targets as unknown as Record<string, { client?: ScreenClient } | undefined>)[name]!.client!\n const kayan = Object.fromEntries(\n Object.entries(full.kayan)\n .filter(([one]) => report.collections?.[one]?.read === true)\n .map(([one, value]) => [one, { ...value, fields: value.fields.filter((field) => report.collections?.[one]?.fields?.[field.name]?.read === true) }]),\n )\n const { onPlatform, platformEntry, PLATFORM_SCREEN } = await import('asas')\n const platform = platformEntry(actor)\n // The other screens of this app the actor may enter. The platform's own name\n // is left out of a tenant app's list, so the one link to it is `platformEntry`'s alone.\n const served = screensOf(asas.targets) as Record<string, { client: ScreenClient }>\n const siblings = (report.screens ?? [])\n .filter((one) => one !== name && Object.hasOwn(served, one) && (one === PLATFORM_SCREEN) === onPlatform())\n .map((one) => ({ name: one, path: served[one]!.client.path }))\n const others = [...siblings, ...(platform ? [{ name: PLATFORM_SCREEN, path: platform }] : [])]\n return {\n asas,\n request,\n name,\n report,\n // Whole, so a page of a kayan the cut leaves out is told from a path naming no page.\n full,\n user: {\n ...(actor.id ? { id: actor.id } : {}),\n name: actor.name ?? actor.email,\n email: actor.email,\n // Only the fact; the id of the person behind the session stays on the server.\n ...(actor.impersonatedBy ? { impersonating: true } : {}),\n ...(others.length ? { screens: others } : {}),\n ...(actor.organizations && {\n organizations: actor.organizations.map(({ id, name, slug }) => ({ id, name, slug })),\n activeOrganizationId: actor.activeOrganizationId ?? null,\n }),\n },\n access: {\n // So `holds` answers a `screen.<name>` key in the browser as on the server.\n ...(report.screens === undefined ? {} : { screens: report.screens }),\n collections: Object.fromEntries(Object.keys(kayan).map((one) => [one, report.collections![one]])),\n // For a key neither a screen's nor an operation's, such as `user.ban`.\n ...(report.permissions === undefined ? {} : { permissions: report.permissions }),\n } satisfies AccessReport,\n // The nav is cut from the whole report, before the report is cut to the screen's kayan.\n screen: { ...full, kayan, nav: navOf(full, report) } as ScreenClient,\n }\n}\n\n/** The layout route's answer: who, what they may do, and the screen. */\nexport const loadScreen = createServerFn({ method: 'GET', strict: { output: false } })\n // Checked against this build's screens in `resolveScreen`.\n .validator((screen: unknown) => screen)\n .handler(async ({ data }) => {\n const found = await resolveScreen(data)\n return found === 'unauthorized' ? found : found ? { name: found.name, user: found.user, access: found.access, screen: found.screen } : null\n })\n\n/**\n * What `/unauthorized` offers a signed-in actor: every screen the report lists for it, `null` without a session.\n * `from` is only matched against this build's screens and pages, so nothing a caller writes is said back.\n */\nexport const loadEnterable = createServerFn({ method: 'GET' })\n .validator((asked?: { from?: unknown; locale?: unknown }) => ({\n from: typeof asked?.from === 'string' ? asked.from : undefined,\n locale: localeOf(typeof asked?.locale === 'string' ? asked.locale : undefined),\n }))\n .handler(async ({ data }): Promise<{\n screens: { name: string; path: string }[]\n refused?: { page: string; screen: { name: string; path: string } }\n email?: string\n organizations?: { id: string; name: string; slug?: string }[]\n others?: { id: string; name: string; slug?: string }[]\n impersonating?: string\n } | null> => {\n const [{ getRequest }, asas, { onPlatform, PLATFORM_SCREEN }] = await Promise.all([import('@tanstack/react-start/server'), getAsas(), import('asas')])\n const request = getRequest()\n const actor = (await asas.auth.actor(request.headers)) as Actor | null\n if (!actor?.email) return null\n const report = (await (await asas.handler(new Request(new URL('/api/access', request.url), { headers: request.headers }))).json()) as AccessReport\n const screens = screensOf(asas.targets) as Record<string, { client: ScreenClient }>\n const entered = (report.screens ?? [])\n .filter((name) => Object.hasOwn(screens, name) && (name === PLATFORM_SCREEN) === onPlatform())\n .map((name) => ({ name, path: screens[name]!.client.path }))\n const impersonating = actor.impersonatedBy ? { impersonating: actor.name ?? actor.email } : {}\n if (entered.length) {\n const { from, locale } = data\n const home = from === undefined ? undefined : entered.find((one) => from === one.path || from.startsWith(`${one.path}/`))\n const match = home && matchPage(screens[home.name]!.client, from!.slice(home.path.length))\n if (!home || !match) return { screens: entered, ...impersonating }\n const page = nameOfPage({ screen: screens[home.name]!.client, name: home.name, match, copy: { t: translator(locale), locale } })\n return { screens: entered, refused: { page, screen: home }, ...impersonating }\n }\n const memberships = actor.organizations ?? []\n const active = memberships.find((one) => one.id === actor.activeOrganizationId)\n return {\n screens: entered,\n email: actor.email,\n organizations: (active ? [active] : memberships).map(({ id, name, slug }) => ({ id, name, slug })),\n others: onPlatform() ? [] : memberships.filter((one) => one !== active).map(({ id, name, slug }) => ({ id, name, slug })),\n ...impersonating,\n }\n })\n\nexport { askedFor }\n\nconst holdsForm = (page: TreePage) => formIn(nodesOf(page)) !== undefined\n\nconst relatedOf = (screen: ScreenClient, kayan: ClientKayan) => Object.values(screen.kayan).filter((one) => kayan.fields.some((field) => field.relationTo === one.name))\n\nconst scopeTo = (node: { where?: string }, id: unknown) => ({ [node.where!]: { equals: id } })\n\n/** One page's answer: which page the path asked for, the row a form draws, the rows a list draws, and what this actor may write. */\nexport const loadPage = createServerFn({ method: 'GET', strict: { output: false } })\n .validator((asked: { screen: unknown; path: string; query: Record<string, unknown> }) => ({\n screen: asked.screen,\n path: String(asked.path),\n query: askedFor(asked.query),\n }))\n .handler(async ({ data }) => {\n const found = await resolveScreen(data.screen)\n if (!found) return 'signed out' as const\n if (found === 'unauthorized') return found\n const { asas, screen } = found\n const match = matchPage(screen, data.path)\n // A page of a kayan the actor may not read is refused like a page whose key\n // it does not hold, so one role meets one denial page.\n if (!match) return matchPage(found.full, data.path) ? ('unauthorized' as const) : null\n // A page of the screen's own reads nothing, but one naming a key is served\n // only to an actor the report says holds it; its nav entry is only chrome.\n if (match.kayan === undefined) {\n const permission = screen.pages[match.page]?.permission\n if (permission !== undefined && !holds(found.report, permission)) return 'unauthorized' as const\n return { name: found.name, user: found.user, access: found.access, screen, kayan: undefined, page: match.page, value: null, first: undefined, related: {}, refused: [], creates: false, operations: {}, writable: {}, drafts: undefined }\n }\n const kayan = screen.kayan[match.kayan]\n if (!kayan) return null\n const page = kayan.pages[match.page]!\n const tree = 'page' in page ? page : undefined\n const actor = (await asas.auth.actor(found.request.headers)) ?? undefined\n const operations = (await inProcess(asas)) as unknown as Record<string, { operations: Record<string, (input: object, options: { actor?: unknown; draft?: boolean }) => Promise<unknown>> } | undefined>\n const globals = asas.global as unknown as typeof operations\n const readRow = async (id: string, draft: boolean) =>\n (await operations[kayan.name]!.operations.findByID!({ id }, { actor, ...(draft && { draft }) }).catch((thrown: unknown) => {\n if (!caused(thrown)) throw thrown\n return null\n })) as Row | null\n const report = found.access.collections?.[kayan.name]\n // A create page with no field to write is refused like a page whose key is\n // not held, not drawn as an empty form.\n if (match.page === 'create' && !createsIn({ kayan, report })) return 'unauthorized' as const\n // A kayan that keeps drafts, for an actor holding `findVersions` (the key a\n // draft read and a draft save ask): the actor edits the latest draft.\n // Without it, or where it leaves this row out, the actor reads the row and saves there.\n const drafting = await rowReadsDrafts({\n kayan,\n operations: report?.operations,\n id: kayan.single ? undefined : match.params.id,\n findVersions: (input) => operations[kayan.name]!.operations.findVersions!(input, { actor }).catch((thrown: unknown) => {\n if (!caused(thrown)) throw thrown\n return null\n }) as Promise<{ docs: readonly unknown[] } | null>,\n })\n let value: Row | null = null\n if (kayan.single ? !tree || holdsForm(tree) : match.params.id !== undefined) {\n if (kayan.single) value = (await globals[kayan.name]!.operations.findOne!({}, { actor })) as Row | null\n else if (match.params.id) {\n value = await readRow(match.params.id, drafting)\n if (!value) return null\n }\n }\n // A create page reads Draft from the start, since no published copy exists.\n // A plain read finds only a published row, which tells draft from changed.\n let drafts: FormProps['drafts']\n if (drafting && tree && holdsForm(tree)) {\n const published = value ? await readRow(String(value.id), false) : null\n drafts = {\n status: !published ? 'draft' : value!._status === 'draft' ? 'changed' : 'published',\n ...(kayan.drafts!.autosave && { autosave: kayan.drafts!.autosave.interval }),\n }\n }\n // The list this page draws, or the one it opens over. A URL the list does not\n // offer is refused before `find` is asked anything, and the page draws the refusal.\n const list = kayan.pages.list && 'page' in kayan.pages.list ? kayan.pages.list : undefined\n const listPage = tree && listIn(nodesOf(tree)) ? tree : tree?.mode ? list : undefined\n let first: { input: Record<string, unknown>; rows: Page } | undefined\n const node = listPage && listIn(nodesOf(listPage))\n if (node) {\n try {\n const input = findOf({\n kayan,\n query: readQuery(data.query, rulesOf({ kayan, node, maxLimit: screen.maxLimit, context: { client: relatedOf(screen, kayan), locales } })),\n node,\n locales,\n client: relatedOf(screen, kayan),\n })\n first = { input, rows: (await operations[kayan.name]!.operations.find!(input, { actor, ...(drafting && { draft: true }) })) as Page }\n } catch (thrown) {\n if (!(thrown instanceof QueryRefused)) throw thrown\n }\n }\n // Each list of another kayan the page draws over its row. A kayan the screen\n // does not cross (one the actor may not read) is read nothing, and so is a URL the list does not offer.\n const related: Record<string, { input: Record<string, unknown>; rows: Page }> = {}\n // Lists whose read the server refused, which the page says it cannot show rather than drawing nothing.\n const refused: string[] = []\n for (const one of tree && value ? relatedIn(nodesOf(tree), scopeOf({ values: {}, record: value, access: found.access, kayan })) : []) {\n const target = screen.kayan[one.of!]\n if (!target) continue\n try {\n const input = scopedTo(findOf({\n kayan: target,\n query: readQuery(listRaw(data.query, prefixOf(one)), rulesOf({ kayan: target, node: one, maxLimit: screen.maxLimit, context: { client: relatedOf(screen, target), locales } })),\n node: one,\n locales,\n client: relatedOf(screen, target),\n }), scopeTo(one, value!.id))\n const draft = readsDrafts(target, found.access.collections?.[target.name]?.operations)\n related[prefixOf(one)] = { input, rows: (await operations[target.name]!.operations.find!(input, { actor, ...(draft && { draft }) })) as Page }\n } catch (thrown) {\n if (!(thrown instanceof QueryRefused) && !caused(thrown)) throw thrown\n if ((thrown as { status?: unknown }).status === 403) refused.push(prefixOf(one))\n }\n }\n const own = (name: string) => (asas.operations as readonly { kayan: string; name: string; input: Parameters<typeof takesKey>[0] }[]).find((one) => one.kayan === kayan.name && one.name === name)\n const update = own('update')\n // The row a relationship title points at, read as this actor; one the actor\n // may not read names nothing. Only its id and title field go into the page's\n // data, which is serialized into the document.\n const titleField = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.kind === 'relationship')\n const titledKayan = titleField ? screen.kayan[titleField.relationTo ?? ''] : undefined\n const titledId = value && titledKayan ? idsOf(value[titleField!.name])[0] : undefined\n const titledRow = titledId === undefined ? undefined : ((await operations[titledKayan!.name]?.operations.findByID?.({ id: titledId }, { actor }).catch((thrown: unknown) => {\n if (!caused(thrown)) throw thrown\n return null\n })) as Row | null | undefined)\n const titledField = titledKayan?.useAsTitle\n const titledBy = titledRow && titledField ? ({ id: titledRow.id, [titledField]: titledRow[titledField] } as Row) : undefined\n return {\n name: found.name,\n user: found.user,\n access: found.access,\n screen,\n kayan: kayan.name,\n page: match.page,\n value,\n first,\n related,\n refused,\n creates: createsIn({ kayan, report }),\n operations: report?.operations ?? {},\n writable: report?.fields ?? {},\n drafts,\n ...(titledBy && { titledBy }),\n // Whether the kayan's update takes the `expectedUpdatedAt` step's key, which the form then sends.\n ...(update && takesKey(update.input, 'expectedUpdatedAt') && { expectedUpdatedAt: true }),\n // And which of delete and restore do, for the Discards and the Versions tab's restore.\n expectedUpdatedAtOn: ['delete', 'restoreVersion'].filter((name) => { const one = own(name); return !!one && takesKey(one.input, 'expectedUpdatedAt') }),\n }\n })\n\ntype ScreenData = Exclude<Awaited<ReturnType<typeof loadScreen>>, 'unauthorized' | null>\ntype PageData = Exclude<Awaited<ReturnType<typeof loadPage>>, 'signed out' | 'unauthorized' | null>\n\nconst pathOf = (href: string) => href.split(/[?#]/)[0]!\n\n/**\n * A screen's frame route's loader: resolves the session, access report and screen, and preloads the providers' files.\n * It is not `beforeLoad` because route context must be serializable and the projection's `rules` are `unknown`.\n */\nexport const frameLoader = async ({ screen, files, href }: { screen: string; files: Files; href: string }) => {\n const found = await loadScreen({ data: screen })\n if (!found) throw redirect({ to: '/auth/sign-in', search: { redirect: href } })\n if (found === 'unauthorized') throw redirect({ to: '/unauthorized', search: { from: pathOf(href) } })\n await preload(files, found.screen.providers)\n return found\n}\n\n// Server: in process as this request's actor, so the first HTML has what a loader reads. Browser: the page's `fetch`.\n// Only the browser's client is held to the served read's cap; in process a `limit` of 0 is allowed.\nconst toolsOf = createIsomorphicFn()\n .server(async ({ access, maxLimit }: Pick<LoaderTools, 'access' | 'maxLimit'>): Promise<LoaderTools> => {\n const [{ getRequest }, asas] = await Promise.all([import('@tanstack/react-start/server'), getAsas()])\n const request = getRequest()\n const actor = (await asas.auth.actor(request.headers)) ?? undefined\n const kayan = (await inProcess(asas)) as unknown as Parameters<typeof actingAs>[0]\n return {\n client: actingAs(kayan, actor),\n request: (path, init) => asas.handler(new Request(new URL(path, request.url), { ...init, headers: mergedHeaders(request.headers, init?.headers) })),\n access,\n maxLimit,\n }\n })\n .client(async ({ access, maxLimit }: Pick<LoaderTools, 'access' | 'maxLimit'>): Promise<LoaderTools> => ({ client: clientOf(arm), request: fetch, access, maxLimit }))\n\n/**\n * A screen page route's loader: the page `path` names and only the files it draws. A page naming a `permission`\n * is served only to an actor holding it; a page whose file exports a `loader` gets its return as `loaderData`.\n */\nexport const pageLoader = async ({\n screen,\n files,\n path,\n query,\n href,\n}: {\n screen: string\n files: Files\n path: string\n query: Record<string, unknown>\n href: string\n}) => {\n const found = await loadPage({ data: { screen, path, query } })\n if (found === 'signed out') throw redirect({ to: '/auth/sign-in', search: { redirect: href } })\n if (found === 'unauthorized') throw redirect({ to: '/unauthorized', search: { from: pathOf(href) } })\n if (!found) throw notFound()\n // A list asked for a page past its last is sent to its last, replacing the\n // entry the URL made, so back does not land on it again.\n const clamped = found.first && clampedHref({ href, page: found.first.rows.page, totalPages: found.first.rows.totalPages })\n if (clamped) throw redirect({ href: clamped, replace: true })\n await preload(files, ...partsOf(found))\n if (found.kayan === undefined) {\n const component = found.screen.pages[found.page]?.component\n const module = component ? ((await files[component.slice(0, component.indexOf('#'))]?.()) as { loader?: unknown } | undefined) : undefined\n if (typeof module?.loader === 'function') {\n return { ...found, loaderData: (await (module.loader as (tools: LoaderTools) => Promise<unknown>)(await toolsOf({ access: found.access, maxLimit: found.screen.maxLimit }))) as unknown }\n }\n }\n return found\n}\n\nconst nameOfPage = ({ screen, name, match, row, titledBy, copy }: {\n screen: ScreenClient\n name: string\n match: { kayan?: string; page: string }\n row?: Row | null\n titledBy?: Row | null\n copy: { t: (key: string, fallback?: string) => string | undefined; locale: Locale }\n}): string => {\n if (match.kayan === undefined) return screen.nav.find((entry) => !entry.kayan && entry.page === match.page)?.label?.[copy.locale] ?? copy.t(`screens.${name}`, name) ?? name\n const kayan = screen.kayan[match.kayan]!\n const page = kayan.pages[match.page]\n const many = !!page && 'page' in page && listIn(nodesOf(page)) !== undefined\n if (match.page === 'create') return newTitleOf({ kayan, copy })\n const field = kayan.fields.find((one) => one.name === kayan.useAsTitle)\n const target = field?.kind === 'relationship' ? screen.kayan[field.relationTo ?? ''] : undefined\n const titled = many ? undefined : target ? titledBy && rowTitle({ kayan: target, row: titledBy, locale: copy.locale }) : row && rowTitle({ kayan, row, locale: copy.locale })\n return titled || titleOf({ kayan, copy, many })\n}\n\n/** A page's route `head`: the tab names the page, then the app. A route that drew its not-found has no data and says the page was not found. */\nexport const pageHead = (data: PageData | undefined, context: { locale: Locale; app: string }) =>\n documentHead(context, (t) =>\n data ? nameOfPage({ screen: data.screen, name: data.name, match: data, row: data.value, titledBy: 'titledBy' in data ? data.titledBy : undefined, copy: { t, locale: context.locale } }) : t('admin.notFoundTitle', 'Page not found'))\n\nconst partsOf = (found: PageData): unknown[] => {\n if (found.kayan === undefined) return [found.screen.pages[found.page]]\n const kayan = found.screen.kayan[found.kayan]!\n const page = kayan.pages[found.page]!\n return [page, 'page' in page && page.mode ? kayan.pages.list : undefined, Object.values(found.screen.kayan).map((one) => one.fields)]\n}\n\nconst useScreenState = (data: ScreenData): ScreenState => {\n const { dir, locale, t } = useCopy()\n return { t, locale, dir, screen: data.screen, access: data.access, user: data.user, client: clientOf(arm) }\n}\n\n/**\n * The bar that says the session is an impersonation, with the way out. Stopping\n * is a sign-out: the staff member's own session lives in the platform app under\n * that app's cookie, and the sign-out answers where they go next.\n */\nexport function ImpersonationBar({ name }: { name: string }): ReactElement {\n const { t } = useCopy()\n return (\n <div role=\"alert\" data-impersonating=\"\" className=\"flex flex-wrap items-center justify-between gap-2 bg-amber-500 px-4 py-2 text-sm font-medium text-black\">\n <span>\n {t('admin.impersonating', 'You are impersonating')} <strong>{name}</strong>.\n </span>\n <button\n type=\"button\"\n className=\"rounded-md border border-black/40 px-3 py-1 font-semibold\"\n // `replace`, so Back does not return to the page signed out of.\n onClick={() => void authArm('sign-out').then((answered) => window.location.replace(String(answered.body?.url ?? '/auth/sign-in')))}\n >\n {t('admin.stopImpersonating', 'Stop impersonating')}\n </button>\n </div>\n )\n}\n\nfunction ScreenBar({ user }: { user: ScreenData['user'] }): ReactElement | null {\n const { t } = useCopy()\n const [failed, setFailed] = useState(false)\n useEffect(() => {\n const show = (event: Event) => setFailed((event as CustomEvent<boolean>).detail)\n window.addEventListener('mithaq:after-commit', show)\n return () => window.removeEventListener('mithaq:after-commit', show)\n }, [])\n return (\n <>\n {'impersonating' in user && user.impersonating ? <ImpersonationBar name={user.name} /> : null}\n {failed ? (\n <p role=\"status\" className=\"rounded-md border border-amber-500/50 px-3 py-2 text-sm\">\n {t('admin.afterCommitFailed', 'Saved, but a follow-up action failed, so something this save should have caused, such as an email or a notification, may be missing. Please contact an administrator.')}\n </p>\n ) : null}\n </>\n )\n}\n\n/** The screen's own frame: its providers, outermost first, around the frame's bar and the page. */\nexport function ScreenFrame({ files, data, children }: { files: Files; data: ScreenData; children: ReactNode }): ReactElement {\n const refs = refsOf(files, data.screen)\n // One per frame: made per request on the server, so no row one actor read\n // reaches another's render; held for the frame's life in the browser.\n const [queries] = useState(() => new QueryClient())\n // A hydrating page has had no loader run in the browser, so every file the\n // frame and page draw is asked for at once, off the answer the page's loader\n // sent, not one round trip per file as React reaches it.\n const page = useMatches({ select: (matches) => matches.at(-1)?.loaderData as PageData | undefined })\n useState(() => typeof window !== 'undefined' && void preload(files, data.screen.providers, ...(page?.screen ? partsOf(page) : [])))\n // A link inside the screen is followed by the router, so a tap reads the\n // next page's data instead of loading a whole document.\n const router = useRouter()\n const base = data.screen.path\n useEffect(() => {\n const follow = (event: MouseEvent) => {\n const href = routedHref({ event, origin: window.location.origin, base })\n if (href === undefined) return\n event.preventDefault()\n void router.navigate({ href })\n }\n document.addEventListener('click', follow)\n return () => document.removeEventListener('click', follow)\n }, [router, base])\n // Tab stays inside an open dialog, alert dialog or sheet, as in a native modal.\n useEffect(() => holdFocus(document.body), [])\n // A component that changed what the page reads, with no kayan operation to\n // say so, asks for a re-read of the lists and the page's loader without a\n // reload, so the scroll stays.\n useEffect(() => {\n const again = () => void Promise.all([queries.invalidateQueries(), router.invalidate()])\n window.addEventListener('mithaq:refresh', again)\n return () => window.removeEventListener('mithaq:refresh', again)\n }, [queries, router])\n // Suspends only while a hydrating page's files load.\n return (\n <QueryClientProvider client={queries}>\n <ScreenContext value={useScreenState(data)}>\n <Suspense fallback={null}>\n {data.screen.providers.reduceRight<ReactElement>(\n (inner, path) => {\n const Provider = refOf({ screen: { refs }, place: 'provider', path })!\n return <Provider>{inner}</Provider>\n },\n <>\n <ScreenBar user={data.user} />\n {children}\n </>,\n )}\n </Suspense>\n </ScreenContext>\n </QueryClientProvider>\n )\n}\n\ntype PageViewProps = {\n files: Files\n data: PageData & { loaderData?: unknown }\n query: RawQuery\n onQuery: (search: Record<string, string>) => void\n}\n\n/** One page, inside what the host provides for it: the screen, and on a kayan's page that kayan and the row, which `useKayan()` and `useRecord()` read. */\nexport function ScreenPageView(props: PageViewProps): ReactElement {\n const { data } = props\n const state = useScreenState(data)\n // So a shell marks its home link on the home page alone.\n const own = data.screen.pages[data.page]?.path ?? '/'\n return (\n <ScreenContext value={data.kayan === undefined ? { ...state, path: own === '/' ? data.screen.path : `${data.screen.path}${own}` } : { ...state, kayan: data.screen.kayan[data.kayan], record: data.value }}>\n <PageBody {...props} />\n </ScreenContext>\n )\n}\n\n/**\n * A screen page route's not-found, drawn under the screen's frame in the shell\n * its pages use, so the nav stays and the way back is the screen's home. The\n * shell is the first page tree's, which is every page's in this template.\n */\nexport function ScreenNotFound({ files, screen: name }: { files: Files; screen: string }): ReactElement {\n const { t } = useCopy()\n const screen = useScreen()\n const refs = refsOf(files, screen)\n // A page that opens over its list is drawn inside the list's shell, so it names none to draw here.\n const framed = Object.values(screen.kayan).flatMap((kayan) => Object.values(kayan.pages).flatMap((page) => ('page' in page && !page.mode ? [{ kayan, page }] : [])))[0]\n const back = (t('auth.backTo', 'Back to {screen}') ?? 'Back to {screen}').replace('{screen}', () => t(`screens.${name}`, name) ?? name)\n const regions = {\n header: <h1 className=\"text-2xl font-bold tracking-tight\">{t('admin.notFoundTitle', 'Page not found')}</h1>,\n nav: <nav aria-label={t('admin.navigation', 'Navigation')}><NavLinks /></nav>,\n main: (\n <div className=\"flex flex-col items-start gap-4\">\n <p className=\"text-muted-foreground text-sm\">{t('admin.notFound', 'That page does not exist.')}</p>\n <a className=\"text-foreground text-sm underline underline-offset-4\" href={screen.path}>{back}</a>\n </div>\n ),\n }\n if (!framed) return <main className=\"p-6\">{regions.header}{regions.main}</main>\n return <>{frameOf({ screen: { refs, ...framed }, t, path: framed.page.page.shell, regions })}</>\n}\n\nfunction PageBody({ files, data, query, onQuery }: PageViewProps): ReactElement | null {\n const refs = refsOf(files, data.screen)\n const router = useRouter()\n const queries = useQueryClient()\n const { locale, t } = useCopy()\n if (data.kayan === undefined) {\n const Page = refOf({ screen: { refs }, place: 'page', path: data.screen.pages[data.page]?.component })\n return Page ? <Page loaderData={data.loaderData} /> : null\n }\n const kayan = data.screen.kayan[data.kayan]!\n const declared = kayan.pages[data.page]!\n if ('component' in declared) {\n const Whole = refOf({ screen: { refs }, place: 'component', path: declared.component })\n return Whole ? <Whole page={declared} /> : null\n }\n const page = declared\n const base = data.screen.path\n const hrefOf = (target: ClientKayan, pageName: string | undefined, id?: string) => {\n const path = pageName === undefined ? undefined : target.pages[pageName]?.path\n return path ? `${base}${id ? path.replace(':id', encodeURIComponent(id)) : path}` : undefined\n }\n // A row opens its kayan's edit page, else the one page whose path draws a\n // row (how the platform opens a record it only reads).\n const rowPage = (target: ClientKayan) => (target.pages.edit ? 'edit' : Object.keys(target.pages).find((one) => target.pages[one]!.path.includes(':id')))\n const related = relatedOf(data.screen, kayan)\n const go = (href: string | undefined) => void (href ? router.navigate({ href }) : router.invalidate())\n const written = <Written,>(write: Promise<Written>, name = kayan.name) =>\n write.then((out) => {\n void queries.invalidateQueries({ queryKey: rowsKey(name) })\n return out\n })\n\n // A list of another kayan on this page: the rows whose `where` holds the row\n // the page draws. The screen crosses only the kayan the actor may read, so one\n // it may not is refused here, as is one whose read the server refused.\n const relatedList = (target: TreePage, node: Parameters<NonNullable<Draw['list']>>[0]['node'], index: number, own?: ReactElement) => {\n const listed = data.screen.kayan[node.of!]\n // Both look the same to the viewer: rows it cannot see.\n if (!listed || data.refused.includes(prefixOf(node))) {\n return (\n <p key={index} role=\"alert\" data-mithaq-refused-list={node.of} className=\"rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive\">\n {t('admin.cannotSee', 'You can\\'t see this')}\n </p>\n )\n }\n const id = data.value?.id\n if (id === undefined) return null\n const prefix = prefixOf(node)\n const report = data.access.collections?.[listed.name]\n const targets = relatedOf(data.screen, listed)\n return (\n <ScreenList\n key={index}\n props={{\n kayan: listed,\n node,\n client: targets,\n api: readsOf([listed, ...targets]),\n creates: createsIn({ kayan: listed, report }),\n operations: report?.operations ?? {},\n writes: (field) => report?.fields?.[field]?.update === true,\n access: data.access,\n t,\n locale,\n locales,\n href: (row) => hrefOf(listed, rowPage(listed), row) ?? '#',\n newHref: '#',\n screen: { refs, kayan: listed, page: target },\n edit: {\n client: {\n create: (input) => written(arm[listed.name]!.create!(input), listed.name),\n update: (input) => written(arm[listed.name]!.update!(input), listed.name),\n delete: (input) => written(arm[listed.name]!.delete!(input), listed.name),\n },\n writes: (field, on) => report?.fields?.[field]?.[on] === true,\n prefill: { [node.where!]: id },\n },\n }}\n raw={listRaw(query, prefix)}\n onQuery={(search) => onQuery(withList({ raw: query, prefix, written: search }))}\n first={data.related[prefix]}\n scope={scopeTo(node, id)}\n find={(input, options) => arm[listed.name]!.find!(input, options) as Promise<Page>}\n run={(operation, input) => arm[listed.name]![operation]!(input)}\n own={own}\n />\n )\n }\n\n const draw = (target: TreePage): Draw => {\n return {\n kayan,\n copy: { t, locale },\n issues: {},\n // A page drawn here holds no form, so a `when` reads the row and the report alone.\n scope: scopeOf({ values: {}, record: data.value, access: data.access, kayan }),\n // A leaf is the row's value drawn by its cell, read and never edited.\n control: (leaf) => {\n const field = kayan.fields.find((one) => one.name === (typeof leaf === 'string' ? leaf : leaf.field))\n return field ? (\n <FieldValue\n key={field.name}\n kayan={kayan}\n field={field}\n leaf={typeof leaf === 'string' ? undefined : leaf}\n row={data.value}\n copy={{ t, locale }}\n api={readsOf(related)}\n kayans={related}\n screen={{ refs, kayan, page: target }}\n />\n ) : null\n },\n component: (path, index) => {\n const Component = refOf({ screen: { refs }, place: 'component', path })\n return Component ? <Component key={index} page={target} /> : null\n },\n layout: ({ path, regions, index }) => frameOf({ screen: { refs, kayan, page: target }, t, path, regions, key: index }),\n // A list node naming a component is drawn by it, which reads through\n // `useList()` what the registry's list is handed.\n list: ({ node, index, component }) => {\n const Own = component ? refOf({ screen: { refs }, place: 'component', path: component }) : undefined\n if (node.of) return relatedList(target, node, index, Own ? <Own page={target} /> : undefined)\n return (\n <ScreenList\n key={index}\n props={{\n kayan,\n client: related,\n api: readsOf([kayan, ...related]),\n creates: data.creates && !!kayan.pages.create,\n operations: data.operations,\n writes: (field) => data.writable[field]?.update === true,\n access: data.access,\n t,\n locale,\n locales,\n href: (id) => hrefOf(kayan, rowPage(kayan), id) ?? '#',\n newHref: hrefOf(kayan, 'create') ?? '#',\n screen: { refs, kayan, page: target },\n }}\n raw={query}\n onQuery={onQuery}\n first={data.first}\n find={(input, options) => arm[kayan.name]!.find!(input, options) as Promise<Page>}\n run={(operation, input) => arm[kayan.name]![operation]!(input)}\n own={Own ? <Own page={target} /> : undefined}\n />\n )\n },\n nav: () => <NavLinks />,\n button: (node, index) => {\n const button = pageButtonOf({ kayan, node, allowed: data.operations })\n if (!button) return null\n const call = async (input: Row) => {\n await written(arm[kayan.name]![button.name]!({ ...input, ...(data.value?.id !== undefined && { id: data.value.id }) }))\n await router.invalidate()\n }\n // A project's button component is handed a run that asks the confirm\n // first; the registry's own button asks it before calling.\n const Own = node.component ? refOf({ screen: { refs }, place: 'button', path: node.component }) : undefined\n return Own ? (\n <OwnButton key={index} Own={Own} operation={button} busy={false} copy={{ t, locale }} call={call} />\n ) : (\n <NodeButton key={index} node={node} copy={{ t, locale }} busy={false} run={() => void call({})} />\n )\n },\n }\n }\n const drawn = (target: TreePage) => pageOf({ refs, kayan, page: target }, draw(target))\n if (readsAsValues({ page, value: data.value, operations: data.operations })) return <>{drawn(page)}</>\n\n const listHref = hrefOf(kayan, 'list')\n const client: FormClient = kayan.single\n ? {\n create: ({ id: _id, ...input }) => arm[kayan.name]!.update!(input),\n update: ({ id: _id, ...input }) => arm[kayan.name]!.update!(input),\n delete: () => Promise.reject(new Error(`\"${kayan.name}\" is one row and declares no delete.`)),\n }\n : {\n create: (input, options) => written(arm[kayan.name]!.create!(input, options)),\n update: (input, options) => written(arm[kayan.name]!.update!(input, options)),\n delete: (input) => written(arm[kayan.name]!.delete!(input)),\n run: (operation, input) => written(arm[kayan.name]![operation]!(input)),\n findByID: (input) => arm[kayan.name]!.findByID!(input),\n }\n const form = (\n <ScreenForm\n kayan={kayan}\n screen={{ refs, kayan, page }}\n client={client}\n value={data.value}\n t={t}\n locale={locale}\n locales={locales}\n writes={(field, on) => data.writable[field]?.[on] === true}\n kayans={related}\n api={readsOf(related)}\n operations={data.operations}\n related={draw(page).list}\n edit={(id) => hrefOf(kayan, 'edit', id)}\n list={listHref}\n drafts={data.drafts}\n expectedUpdatedAt={data.expectedUpdatedAt}\n expectedUpdatedAtOn={data.expectedUpdatedAtOn}\n />\n )\n const list = kayan.pages.list\n if (page.mode && list && 'page' in list) {\n return (\n <>\n {drawn(list)}\n {/* Named for a screen reader by the page's title, a create as new. */}\n <RecordFrame mode={page.mode} title={(data.value ? titleOf : newTitleOf)({ kayan, copy: { t, locale } })} closeLabel={t('admin.close', 'Close') ?? 'Close'} onClose={() => go(listHref)}>\n {form}\n </RecordFrame>\n </>\n )\n }\n return form\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 d96a3dd603b5aead8e3d82e8792fa40d5f4dac1bfbee2e7064ab2be888b595a6\n",
|
|
25
|
+
"type": "registry:file",
|
|
26
|
+
"target": "~/src/screens.tsx"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"path": "registry/host/screen-form.tsx",
|
|
30
|
+
"content": "import { useBlocker, useRouter } from '@tanstack/react-router'\nimport { useState, type ReactElement } from 'react'\nimport { Form, type FormProps } from './components/mithaq/form.tsx'\n\n/**\n * A kayan's form on a screen's page. A create moves to the new row's edit page, a delete to the list.\n * While the form holds a change the router blocks navigation and the form asks its discard question.\n */\nexport function ScreenForm({ edit, list, ...props }: Omit<FormProps, 'onDone' | 'onDirty' | 'onCancel' | 'blocked'> & {\n /** The href of a row's edit page, or nothing where the kayan has none. */\n edit: (id: string) => string | undefined\n /** The href of the kayan's list, or nothing where it has none. */\n list?: string\n}): ReactElement {\n const router = useRouter()\n const [dirty, setDirty] = useState(false)\n const blocker = useBlocker({ shouldBlockFn: () => dirty, withResolver: true, enableBeforeUnload: false })\n // `ignoreBlocker`: the form reports clean only on the render after the save.\n const go = (href: string | undefined) => void (href ? router.navigate({ href, ignoreBlocker: true }) : router.invalidate())\n const landing = (row: unknown, effect?: string) => {\n const id = (row as { id?: unknown } | null | undefined)?.id\n return effect === 'delete' ? list : effect === 'create' && id !== undefined ? edit(String(id)) : undefined\n }\n return (\n <Form\n {...props}\n onDirty={setDirty}\n onDone={(row, effect) => go(landing(row, effect))}\n onCancel={list ? () => go(list) : undefined}\n blocked={blocker.status === 'blocked' ? blocker : undefined}\n />\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 7284b5e97770fd0c395fafa0b7459cab4ceb458e4bc982dc8233894126116170\n",
|
|
31
|
+
"type": "registry:file",
|
|
32
|
+
"target": "~/src/screen-form.tsx"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"path": "registry/host/screen-list.tsx",
|
|
36
|
+
"content": "import { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useState, type ReactElement } from 'react'\nimport { ListContext, useScreen } from 'mithaq/admin/react'\nimport { Button } from '@/components/ui/button'\nimport { List, type ListProps, type Page } from './components/mithaq/list.tsx'\nimport { QueryRefused, findOf, readQuery, readRaw, rulesOf, scopedTo, writeQuery, type ListQuery, type RawQuery } from './components/mithaq/list-query.ts'\nimport { counted } from './components/mithaq/controls.tsx'\nimport { listOf } from './components/mithaq/nodes.tsx'\n\n/**\n * The list's search as the router carries it: the URL's pairs as strings, each\n * `where[…]` pair a key of its own. The router's default would JSON-quote a\n * page number, so an app hands these two to `createRouter`.\n */\nexport const flatSearch = {\n parseSearch: (search: string) => Object.fromEntries(new URLSearchParams(search)),\n stringifySearch: (search: Record<string, unknown>) => {\n const written = new URLSearchParams(Object.entries(search).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]]))).toString()\n return written ? `?${written}` : ''\n },\n}\n\n/** A page route's search: the list's own keys, each the string the URL carries, read against the list once the loader has the kayan. */\nexport const askedFor = (search: Record<string, unknown>): RawQuery => readRaw(search)\n\n/** Every page of one kayan's list the browser holds, which a write to that kayan invalidates. */\nexport const rowsKey = (kayan: string) => ['mithaq', kayan] as const\n\n/**\n * Whether a list of this kayan reads with `draft: true`: the kayan keeps drafts\n * and this actor holds `findVersions`. Then never-published rows are listed,\n * each shown as its latest draft, and the total is counted the same way.\n */\nexport const readsDrafts = (kayan: Pick<ListProps['kayan'], 'drafts'>, operations?: Readonly<Record<string, boolean>>) =>\n !!kayan.drafts && operations?.findVersions === true\n\n/**\n * Whether this actor edits one row's drafts: it holds `findVersions`, and this row is within any limit on it.\n * A row left out gets a plain Save and no autosave, since the server answers a draft save on it 404.\n */\nexport const rowReadsDrafts = async ({ kayan, operations, id, findVersions }: {\n kayan: Pick<ListProps['kayan'], 'drafts'>\n operations?: Readonly<Record<string, boolean>>\n id?: string\n findVersions: (input: { where: { parent: { equals: string } }; limit: 1 }) => Promise<{ docs: readonly unknown[] } | null>\n}) => readsDrafts(kayan, operations) && (id === undefined || !!(await findVersions({ where: { parent: { equals: id } }, limit: 1 }))?.docs.length)\n\n/** A query the URL asks for, or the sentence it was refused with, which asks nothing of the server. */\nexport const queryOf = (props: Pick<ListProps, 'kayan' | 'client' | 'locales' | 'screen' | 'node'> & { maxLimit: number }, raw: RawQuery): { query: ListQuery } | { refused: string } => {\n try {\n return { query: readQuery(raw, rulesOf({ kayan: props.kayan, node: props.node ?? listOf(props.screen), maxLimit: props.maxLimit, context: { client: props.client, locales: props.locales } })) }\n } catch (thrown) {\n if (thrown instanceof QueryRefused) return { refused: thrown.message }\n throw thrown\n }\n}\n\n/**\n * One screen's list, over the URL and TanStack Query. The URL is the list's one\n * state; the server-rendered first page seeds the cache. A URL the list does\n * not offer is refused before any request.\n */\nexport function ScreenList({\n find,\n first,\n onQuery,\n own,\n props,\n raw,\n run,\n scope,\n}: {\n props: Omit<ListProps, 'page' | 'query' | 'onQuery' | 'run'>\n /** The URL's search, as the route validated it. */\n raw: RawQuery\n onQuery: (search: Record<string, string>) => void\n /** The page the loader read, and the input it read it with, which seeds the cache where it is the page this URL asks for. */\n first?: { input: unknown; rows: Page }\n find: (input: Record<string, unknown>, options?: { draft?: boolean }) => Promise<Page>\n run: (operation: string, input: Record<string, unknown>) => Promise<unknown>\n /** What a list node's own component draws in place of the registry's list, reading the same props through `useList()`. */\n own?: ReactElement\n /** The rows a list of another kayan is held to: its relationship holding the page's row. */\n scope?: Readonly<Record<string, unknown>>\n}): ReactElement | null {\n const queries = useQueryClient()\n // Rows the last bulk left alone: never-published drafts, which the list shows and a bulk does not write.\n const [left, setLeft] = useState(0)\n const asked = queryOf({ ...props, maxLimit: useScreen().maxLimit }, raw)\n const query = 'query' in asked ? asked.query : {}\n const found = findOf({ kayan: props.kayan, query, node: props.node ?? listOf(props.screen), locales: props.locales, client: props.client })\n const input = scope ? scopedTo(found, scope) : found\n const seeded = first && JSON.stringify(first.input) === JSON.stringify(input) ? first.rows : undefined\n const { data } = useQuery({\n queryKey: [...rowsKey(props.kayan.name), 'find', input],\n queryFn: () => find(input, readsDrafts(props.kayan, props.operations) ? { draft: true } : undefined),\n enabled: 'query' in asked,\n initialData: seeded,\n placeholderData: (previous) => previous ?? first?.rows,\n // Keeps the page the server just rendered from being read again as it\n // hydrates. An organization or actor switch that does not reload the\n // document must clear this cache, or the next actor sees the last one's pages.\n staleTime: 30_000,\n })\n if ('refused' in asked) {\n return (\n <div role=\"alert\" data-mithaq-refused={asked.refused} className=\"flex flex-col items-start gap-2 rounded-md border border-destructive/50 px-3 py-2 text-sm\">\n {props.t('admin.queryRefused', 'This link asks the list for something it does not offer, so nothing was read.')}\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => onQuery({})}>\n {props.t('admin.clearFilters', 'Clear filters')}\n </Button>\n </div>\n )\n }\n if (!data) return null\n const list: ListProps = {\n ...props,\n page: data,\n query,\n onQuery: (next) => onQuery(writeQuery(next)),\n run: async (operation, input) => {\n const written = await run(operation, input)\n setLeft((written as { errors?: readonly unknown[] } | null)?.errors?.length ?? 0)\n await queries.invalidateQueries({ queryKey: rowsKey(props.kayan.name) })\n return written\n },\n }\n return (\n <ListContext value={list}>\n {left ? (\n <p role=\"status\" data-mithaq-left={left} className=\"rounded-md border px-3 py-2 text-sm\">\n {counted({ t: props.t, locale: props.locale, key: 'admin.bulkLeft', count: left, fallback: '{count} of the ticked rows were left as they are, because they have never been published.' })}\n </p>\n ) : null}\n {own ?? <List {...list} />}\n </ListContext>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 3c37825f9f645fd3f9c95a3d1ec554e863e85767e8329a1b2e16a40e5f5ea381\n",
|
|
37
|
+
"type": "registry:file",
|
|
38
|
+
"target": "~/src/screen-list.tsx"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"path": "registry/host/api.ts",
|
|
42
|
+
"content": "import { afterCommitHeader, type ClientKayan, type FetchClient } from 'mithaq/admin'\n\n/** What a write may ask beside its input on a kayan that keeps drafts: a draft save, and an autosave. The fetch plug reads them off the query string only. */\nexport type DraftOptions = { readonly draft?: boolean; readonly autosave?: boolean }\n\nconst queryOf = ({ draft, autosave }: DraftOptions = {}) => (draft ? `?draft=true${autosave ? '&autosave=true' : ''}` : '')\n\nconst call = async ({ kayan, operation, input, ...options }: { kayan: string; operation: string; input: Record<string, unknown> } & DraftOptions) => {\n const response = await fetch(`/api/${encodeURIComponent(kayan)}/${encodeURIComponent(operation)}${queryOf(options)}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(input),\n })\n const answer = await response.json().catch(() => null)\n if (!response.ok) {\n const error = new Error(typeof answer?.message === 'string' ? answer.message : `HTTP ${response.status}`)\n Object.assign(error, { status: response.status, code: answer?.code, data: answer?.data })\n throw error\n }\n // A failed `afterCommit` raises the screens' banner and a committed write\n // clears it. Only the server knows an operation's effect, so the plug sets\n // the header on writes and never on reads.\n const said = response.headers.get(afterCommitHeader)\n if (typeof window !== 'undefined' && said) {\n window.dispatchEvent(new CustomEvent('mithaq:after-commit', { detail: said === 'failed' }))\n }\n return answer\n}\n\n// Restated, not imported from the `.tsx` file that declares them: a `.ts` file\n// must not need the JSX compiler.\ntype FoundPage = { docs: readonly Record<string, unknown>[]; hasNextPage: boolean; nextPage: number | null }\ntype FindApi = Readonly<\n Record<\n string,\n | {\n find: (input: Record<string, unknown>) => Promise<FoundPage>\n create?: (input: Record<string, unknown>) => Promise<unknown>\n delete?: (input: Record<string, unknown>) => Promise<unknown>\n update?: (input: Record<string, unknown>) => Promise<unknown>\n }\n | undefined\n >\n>\n\n/**\n * Every operation of every kayan, keyed by name strings. `arm[kayan][operation](input)`\n * posts to `POST /api/<kayan>/<operation>`; the proxy never answers `undefined`,\n * and a name no kayan has is the server's not-found.\n */\nexport type Arm = Readonly<Record<string, Readonly<Record<string, (input: Record<string, unknown>, options?: DraftOptions) => Promise<unknown>>>>>\nexport const arm = new Proxy(Object.create(null) as Arm, {\n get: (_target, kayan: string) => new Proxy(Object.create(null) as Record<string, (input: Record<string, unknown>, options?: DraftOptions) => Promise<unknown>>, {\n get: (_operations, operation: string) => (input: Record<string, unknown>, options?: DraftOptions) => call({ kayan, operation, input, ...options }),\n }),\n})\n\n/**\n * Whether an operation's input names a key: probes the schema with a value no\n * browser-written schema accepts and looks for an issue at that key. A schema\n * that answers asynchronously, or throws, reads as naming nothing.\n */\nexport const takesKey = (input: { '~standard': { validate: (value: unknown) => unknown } }, key: string) => {\n let answer: { issues?: readonly { path?: readonly unknown[] }[] }\n try {\n answer = input['~standard'].validate({ [key]: Symbol(key) }) as typeof answer\n } catch {\n return false\n }\n if (answer instanceof Promise) {\n answer.catch(() => {})\n return false\n }\n return answer.issues?.some(({ path }) => {\n const segment = path?.[0]\n return String(typeof segment === 'object' && segment ? (segment as { key: unknown }).key : segment) === key\n }) ?? false\n}\n\n/** What a screen's `client` is over an arm; the draft words ride along with the input. */\nexport const clientOf = (through: Arm): FetchClient => ({ kayan, operation, input, draft, autosave }) => through[kayan]![operation]!(input, { draft, autosave })\n\n/**\n * What the server's `client` is: each operation in process, as `actor`. Only the\n * two draft words are read off the caller's options and the actor goes last, so\n * a loader cannot run as someone else.\n */\nexport const actingAs = (\n through: Readonly<Record<string, { operations: Record<string, (input: object, options: { actor?: unknown } & DraftOptions) => Promise<unknown>> }>>,\n actor: unknown,\n): FetchClient => ({ kayan, operation, input, draft, autosave }) => through[kayan]!.operations[operation]!(input, { draft, autosave, actor })\n\n/**\n * What a relationship control and cell read a second kayan through. Only the\n * kayans the screen's loader already narrowed to what this actor may read are\n * in the record, so a picker cannot page any other.\n */\nexport const readsOf = (kayans: readonly ClientKayan[]): FindApi =>\n Object.fromEntries(\n kayans.map((kayan) => [\n kayan.name,\n {\n find: (input: Record<string, unknown>) => arm[kayan.name]!.find!(input) as Promise<FoundPage>,\n },\n ]),\n )\n\ntype FieldReads = {\n readonly collections?: Readonly<\n Record<string, { readonly fields?: Readonly<Record<string, Readonly<Record<string, boolean>> | undefined>> } | undefined>\n >\n}\n\n/**\n * One kayan of the projection with every field this actor may not read taken\n * off it. A loader's result is serialized into the document, so an unreadable\n * field's name, label or rule would otherwise reach the viewer.\n */\nexport const readable = (one: ClientKayan, report: FieldReads): ClientKayan => ({\n ...one,\n fields: one.fields.filter((field) => report.collections?.[one.name]?.fields?.[field.name]?.read === true),\n})\n\n/**\n * Whether a thrown refusal is the caller's. Those carry an HTTP status; a store\n * that is down or a hook bug carries none and must be rethrown, not reported\n * as a missing row.\n */\nexport const caused = (thrown: unknown): boolean =>\n typeof (thrown as { status?: unknown } | null | undefined)?.status === 'number'\n\n/**\n * One POST at the auth arm (Better Auth's router under `/api/auth/user`) with the session cookie.\n * Returns the answer instead of throwing: a refusal is a sentence for the viewer; no reply is status 0.\n */\nexport const authArm = async (path: string, body: unknown = {}) => {\n const response = await fetch(`/api/auth/user/${path}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n }).catch(() => null)\n if (!response) return { ok: false, status: 0, message: undefined, body: null }\n const answered = (await response.json().catch(() => null)) as ({ message?: string } & Record<string, unknown>) | null\n // The status too, because a rate limit's refusal carries no code to read.\n return { ok: response.ok, status: response.status, message: answered?.message, body: answered }\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 94db2fc49ad4391c0d1c2183cb41001dd08d62017c76af86cc5f611488c22c68\n",
|
|
43
|
+
"type": "registry:file",
|
|
44
|
+
"target": "~/src/api.ts"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"path": "registry/host/slide.ts",
|
|
48
|
+
"content": "/**\n * The provider's own session read, which refreshes a session past its `updateAge` and forwards the cookie.\n * Run it before the actor is resolved: a read that refreshes the row first leaves this one nothing due.\n */\nexport const slide = async ({\n strategies,\n headers,\n setResponseHeader,\n}: {\n strategies: readonly unknown[]\n headers: Headers\n setResponseHeader: (name: 'set-cookie', value: string[]) => void\n}) => {\n const cookies: string[] = []\n for (const strategy of strategies) {\n const read = (strategy as { server?: { api?: { getSession?: (args: object) => Promise<{ headers: Headers }> } } }).server?.api?.getSession\n if (read) cookies.push(...(await read({ headers, returnHeaders: true })).headers.getSetCookie())\n }\n // Set once with every strategy's cookies: a second set replaces the first.\n if (cookies.length) setResponseHeader('set-cookie', cookies)\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 bc0221e1088103f8638e0333b84aafb6b326e3991a187ced198bce2d090ecbe9\n",
|
|
49
|
+
"type": "registry:file",
|
|
50
|
+
"target": "~/src/slide.ts"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"path": "registry/host/screen-names.ts",
|
|
54
|
+
"content": "/**\n * The screen a request names, refused unless it is one of `known`'s own keys.\n * The input is whatever a request sent and it indexes `asas.targets`, so\n * `__proto__` and any other name no screen answers is refused before that read.\n */\nexport const screenNamed = <Known extends object>(known: Known, name: unknown): keyof Known & string => {\n if (typeof name !== 'string' || !Object.hasOwn(known, name)) throw new Error(`\"${String(name)}\" is no screen of this app.`)\n return name as keyof Known & string\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 878b7180f2aa169546feb42bcdfcb6002841d08e46788c94ddd6edfc4e3502ca\n",
|
|
55
|
+
"type": "registry:file",
|
|
56
|
+
"target": "~/src/screen-names.ts"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "registry/host/unauthorized.tsx",
|
|
60
|
+
"content": "import { Fragment, useState, type ReactNode } from 'react'\nimport { createFileRoute, redirect } from '@tanstack/react-router'\nimport { Button } from '@/components/ui/button'\nimport { authArm } from '../api.ts'\nimport { AuthFrame, useRefusal } from '../auth-frame.tsx'\nimport { documentHead } from '../copy.ts'\nimport { ImpersonationBar, loadEnterable } from '../screens.tsx'\nimport { useCopy } from './__root.tsx'\n\nexport const Route = createFileRoute('/unauthorized')({\n // The path a screen refused; the server only matches it against the build's pages.\n validateSearch: (search: Record<string, unknown>): { from?: string } => (typeof search.from === 'string' ? { from: search.from } : {}),\n loaderDeps: ({ search }) => ({ from: search.from }),\n loader: async ({ deps, context }) => {\n const entered = await loadEnterable({ data: { from: deps.from, locale: context.locale } })\n // Carries this page's own path so a visitor whose session expired lands back here after signing in.\n if (!entered) throw redirect({ to: '/auth/sign-in', search: { redirect: '/unauthorized' } })\n return entered\n },\n // After the loader, so its data's type is inferred before this reads it.\n head: ({ match, loaderData }) => documentHead(match.context, (t) => (loaderData?.screens.length === 0 ? t('auth.noAccess') : t('auth.unauthorized'))),\n component: Unauthorized,\n})\n\nfunction Unauthorized() {\n const { t } = useCopy()\n const { screens, refused, email, impersonating, organizations = [], others = [] } = Route.useLoaderData()\n const refusal = useRefusal()\n const [switching, setSwitching] = useState<string>()\n const [failed, setFailed] = useState<string>()\n // The slug sits beside the name where another membership shares the name.\n const memberships = [...organizations, ...others]\n const named = (one: { id: string; name: string; slug?: string }): ReactNode =>\n one.slug && memberships.some((other) => other.id !== one.id && other.name === one.name) ? (\n <>\n {one.name} <span dir=\"ltr\" data-slug=\"\" className=\"text-muted-foreground text-xs font-normal\">{one.slug}</span>\n </>\n ) : (\n one.name\n )\n // Each name is isolated so an email or Latin name in an Arabic sentence keeps\n // its order, and is one inline box that wraps whole, so a long address never\n // runs past the card on a phone.\n const whole = 'inline-block max-w-full [overflow-wrap:anywhere]'\n const said = (text: string | undefined) =>\n text!.split(/(\\{email\\}|\\{organization\\}|\\{page\\})/).map((part, at) =>\n part === '{email}' ? <bdi key={at} className={whole}>{email}</bdi>\n : part === '{organization}' ? <bdi key={at}>{organizations.map((one, index) => <Fragment key={one.id}>{index ? ', ' : ''}<span className={whole}>{named(one)}</span></Fragment>)}</bdi>\n : part === '{page}' ? <bdi key={at}>{refused?.page}</bdi>\n : part)\n const link = 'text-foreground underline underline-offset-4'\n const screenName = (name: string) => t(`screens.${name}`, name) ?? name\n return (\n <AuthFrame\n title={screens.length ? t('auth.unauthorized', 'Not for your role') : t('auth.noAccess', 'Your account has no access yet')}\n description={\n <span data-no-access={screens.length ? undefined : ''}>\n {refused\n ? said(t('auth.unauthorizedPage', 'Your role does not open that page: {page}.'))\n : screens.length\n ? t('auth.unauthorizedDescription', 'Your role does not open that screen. These are the screens it opens:')\n : organizations.length\n ? said(t('auth.noAccessDescription', '{email} is a member of {organization} and holds no role that opens a screen here. Ask an administrator of {organization} to give you one.'))\n : said(t('auth.noOrganization', '{email} belongs to no organization yet. Ask an administrator to invite you.'))}\n </span>\n }\n >\n <div className=\"grid gap-4\">\n {/* Stopping impersonation is the way out in place of Sign out. */}\n {impersonating ? <ImpersonationBar name={impersonating} /> : null}\n {refused ? (\n <a className={link} href={refused.screen.path}>\n {(t('auth.backTo', 'Back to {screen}') ?? 'Back to {screen}').replace('{screen}', () => screenName(refused.screen.name))}\n </a>\n ) : screens.length ? (\n <ul className=\"grid gap-2\">\n {screens.map((screen) => (\n <li key={screen.name}>\n {/* An anchor: the path is the screen's own data, not a route the router knows. */}\n <a className={link} href={screen.path}>\n {screenName(screen.name)}\n </a>\n </li>\n ))}\n </ul>\n ) : null}\n {/* So a switch into an organization where the actor holds no role is not a dead end. */}\n {others.map((one) => (\n <Button\n key={one.id}\n type=\"button\"\n variant=\"secondary\"\n data-switch-organization={one.id}\n // Busy while it runs, so a second press sends nothing.\n disabled={switching !== undefined}\n aria-busy={switching === one.id || undefined}\n onClick={() => {\n setSwitching(one.id)\n setFailed(undefined)\n void authArm('organization/set-active', { organizationId: one.id }).then((answer) => {\n if (answer.ok) return window.location.assign('/auth/sign-in')\n setSwitching(undefined)\n setFailed(refusal(answer, t('auth.requestFailed', 'That request did not work.')))\n })\n }}\n >\n {/* One box, so the button's gap does not open beside the name. */}\n <span>{(t('auth.switchTo', 'Switch to {organization}') ?? 'Switch to {organization}').split(/(\\{organization\\})/).map((part, at) => (part === '{organization}' ? <bdi key={at}>{named(one)}</bdi> : part))}</span>\n </Button>\n ))}\n {failed ? <p role=\"alert\" className=\"text-destructive text-sm\">{failed}</p> : null}\n {/* `replace`, so Back does not return to the page signed out of. */}\n {impersonating ? null : (\n <Button type=\"button\" variant=\"outline\" className=\"max-sm:h-11\" onClick={() => void authArm('sign-out').then(() => window.location.replace('/auth/sign-in'))}>\n {t('admin.signOut', 'Sign out')}\n </Button>\n )}\n </div>\n </AuthFrame>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 3c5c304b3892c56759bf780f155ef3aa154ce3cc954e3d240b3537cdd97da735\n",
|
|
61
|
+
"type": "registry:file",
|
|
62
|
+
"target": "~/src/routes/unauthorized.tsx"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"path": "registry/host/index.tsx",
|
|
66
|
+
"content": "import { Link, createFileRoute } from '@tanstack/react-router'\nimport { BookOpen, LayoutDashboard, Languages, ShieldCheck, Users } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport { useHomeLabel } from '../auth-frame.tsx'\nimport { LocaleSwitch } from '../locale-switch.tsx'\nimport { loadEnterable } from '../screens.tsx'\nimport { useCopy } from './__root.tsx'\n\n// A host file because it reads `loadEnterable`, which only a host file may.\nexport const Route = createFileRoute('/')({\n loader: () => loadEnterable(),\n component: Landing,\n})\n\nfunction EntryActions({ screens, size }: { screens: readonly { name: string; path: string }[] | null; size: 'sm' | 'lg' }) {\n const { t } = useCopy()\n const label = useHomeLabel()\n if (screens === null) {\n return (\n <>\n <Link to=\"/auth/sign-up\">\n <Button size={size}>{t('marketing.getStarted', 'Get started')}</Button>\n </Link>\n <Link to=\"/auth/sign-in\">\n <Button size={size} variant=\"outline\">\n {t('auth.signIn', 'Sign in')}\n </Button>\n </Link>\n </>\n )\n }\n // A plain anchor: the path is the screen's own data, not a route the router knows.\n const home: { path: string; name?: string } = screens[0] ?? { path: '/unauthorized' }\n return (\n <a href={home.path}>\n <Button size={size}>{label(home, 'marketing.openScreen')}</Button>\n </a>\n )\n}\n\nfunction Feature({ icon: Icon, title, description }: { icon: typeof BookOpen; title: string; description: string }) {\n return (\n <Card>\n <CardHeader>\n <Icon className=\"mb-2 h-6 w-6 text-primary\" />\n <CardTitle className=\"text-base font-semibold\">{title}</CardTitle>\n </CardHeader>\n <CardContent className=\"text-sm text-muted-foreground\">{description}</CardContent>\n </Card>\n )\n}\n\nfunction Step({ number, title, description }: { number: number; title: string; description: string }) {\n return (\n <div className=\"flex flex-col gap-2\">\n <div className=\"flex h-9 w-9 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-semibold\">\n {number}\n </div>\n <h3 className=\"font-semibold\">{title}</h3>\n <p className=\"text-sm text-muted-foreground\">{description}</p>\n </div>\n )\n}\n\nfunction Landing() {\n const { t, locale } = useCopy()\n const screens = Route.useLoaderData()?.screens ?? null\n // `t` answers `string | undefined` for a key no locale carries; the fallback only satisfies that type.\n const tt = (key: string, fallback: string) => t(key, fallback) ?? fallback\n const features = [\n { icon: BookOpen, title: tt('marketing.featureCoursesTitle', 'Courses and sessions'), description: tt('marketing.featureCoursesDescription', '') },\n { icon: Users, title: tt('marketing.featureEnrollmentsTitle', 'Enrollments'), description: tt('marketing.featureEnrollmentsDescription', '') },\n { icon: ShieldCheck, title: tt('marketing.featureRolesTitle', 'Roles and permissions'), description: tt('marketing.featureRolesDescription', '') },\n { icon: Languages, title: tt('marketing.featureBilingualTitle', 'Arabic and English, either direction'), description: tt('marketing.featureBilingualDescription', '') },\n { icon: LayoutDashboard, title: tt('marketing.featurePortalTitle', 'A dashboard and a customer portal'), description: tt('marketing.featurePortalDescription', '') },\n ]\n const steps = [\n { title: tt('marketing.howStep1Title', 'Set up your organization'), description: tt('marketing.howStep1Description', '') },\n { title: tt('marketing.howStep2Title', 'Add courses and roles'), description: tt('marketing.howStep2Description', '') },\n { title: tt('marketing.howStep3Title', 'Open the portal to your customers'), description: tt('marketing.howStep3Description', '') },\n ]\n return (\n <div className=\"flex min-h-screen flex-col\">\n <header className=\"sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60\">\n <div className=\"mx-auto flex h-16 max-w-6xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6\">\n <span className=\"text-lg font-semibold\">{t('marketing.brand', 'Asas')}</span>\n <div className=\"flex items-center gap-1 sm:gap-2\">\n <LocaleSwitch locale={locale} />\n <EntryActions screens={screens} size=\"sm\" />\n </div>\n </div>\n </header>\n\n <main className=\"flex-1\">\n <section className=\"mx-auto flex max-w-4xl flex-col items-center gap-6 px-4 py-20 text-center sm:px-6 sm:py-28\">\n <h1 className=\"text-4xl font-bold tracking-tight sm:text-5xl\">{t('marketing.heroTitle', 'Run courses and enrollments without the busywork')}</h1>\n <p className=\"max-w-2xl text-lg text-muted-foreground\">{t('marketing.heroSubtitle', '')}</p>\n <div className=\"flex flex-col gap-3 sm:flex-row\">\n <EntryActions screens={screens} size=\"lg\" />\n </div>\n </section>\n\n <section className=\"mx-auto max-w-6xl px-4 py-16 sm:px-6\">\n <div className=\"mx-auto mb-12 max-w-2xl text-center\">\n <h2 className=\"text-3xl font-bold tracking-tight\">{t('marketing.featuresTitle', 'Everything a training organization needs')}</h2>\n <p className=\"mt-3 text-muted-foreground\">{t('marketing.featuresSubtitle', '')}</p>\n </div>\n <div className=\"grid gap-6 sm:grid-cols-2 lg:grid-cols-3\">\n {features.map((feature) => (\n <Feature key={feature.title} {...feature} />\n ))}\n </div>\n </section>\n\n <section className=\"border-t bg-muted/30\">\n <div className=\"mx-auto max-w-6xl px-4 py-16 sm:px-6\">\n <h2 className=\"mb-10 text-center text-3xl font-bold tracking-tight\">{t('marketing.howTitle', 'How it works')}</h2>\n <div className=\"grid gap-8 sm:grid-cols-3\">\n {steps.map((step, at) => (\n <Step key={step.title} number={at + 1} {...step} />\n ))}\n </div>\n </div>\n </section>\n\n <section className=\"mx-auto flex max-w-3xl flex-col items-center gap-6 px-4 py-20 text-center sm:px-6\">\n <h2 className=\"text-3xl font-bold tracking-tight\">{t('marketing.ctaTitle', 'Start running your courses on Asas')}</h2>\n <p className=\"text-muted-foreground\">{t('marketing.ctaSubtitle', '')}</p>\n <EntryActions screens={screens} size=\"lg\" />\n </section>\n </main>\n\n <footer className=\"border-t py-8\">\n <div className=\"mx-auto max-w-6xl px-4 text-center text-sm text-muted-foreground sm:px-6\">\n {t('marketing.footer', 'Asas — courses and enrollments for training organizations.')}\n </div>\n </footer>\n </div>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 32a4513d4cf4a984b7845056965d48edff53542ce962b466af67a87983832a6d\n",
|
|
67
|
+
"type": "registry:file",
|
|
68
|
+
"target": "~/src/routes/index.tsx"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "registry/host/locale-switch.tsx",
|
|
72
|
+
"content": "import { Button } from '@/components/ui/button'\nimport { localeEntries } from './components/mithaq/nav.tsx'\nimport { locales, switchLocale, type Locale } from './copy.ts'\n\n/** The language switch for pages without a form: `switchLocale` reloads without asking, so a page with a form must use the user menu, which asks first. */\nexport function LocaleSwitch({ locale }: { locale: Locale }) {\n return (\n <div className=\"flex items-center gap-1\">\n {localeEntries(locales, locale).map((entry) => (\n <Button\n key={entry.locale}\n type=\"button\"\n variant={entry.active ? 'secondary' : 'ghost'}\n size=\"xs\"\n aria-current={entry.active}\n aria-label={entry.label}\n onClick={() => switchLocale({ to: entry.locale })}\n >\n <span className=\"hidden sm:inline\">{entry.label}</span>\n <span className=\"sm:hidden\">{entry.locale.toUpperCase()}</span>\n </Button>\n ))}\n </div>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 43c6d309a5bae4f6af3cdcf4c741106b3a16129ac77d142f1e4254594f5b8a54\n",
|
|
73
|
+
"type": "registry:file",
|
|
74
|
+
"target": "~/src/locale-switch.tsx"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"path": "registry/host/auth-frame.tsx",
|
|
78
|
+
"content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { redirect, useRouteContext } from '@tanstack/react-router'\nimport { createServerFn } from '@tanstack/react-start'\nimport { Eye, EyeOff } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Input } from '@/components/ui/input'\nimport { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'\nimport { authArm } from './api.ts'\nimport { FieldShell, describedBy } from './components/mithaq/field-shell.tsx'\nimport { refusalKey } from './copy.ts'\nimport { LocaleSwitch } from './locale-switch.tsx'\nimport { wanted } from './redirect.ts'\nimport { useCopy } from './routes/__root.tsx'\n\n/** The one search key the auth pages read, off the raw search. */\nexport const redirectSearch = (search: Record<string, unknown>): { redirect?: string } =>\n typeof search.redirect === 'string' ? { redirect: search.redirect } : {}\n\n/** Whether a read of the actor names someone. A read that throws, or an actor with no email, names no one. */\nexport const signedInAs = async (actor: Promise<unknown>) => Boolean(((await actor.catch(() => null)) as { email?: unknown } | null)?.email)\n\n/**\n * Whether this request carries a session, where signing in lands for it, and the provider's password bounds.\n * No auth page sends a referrer, since the reset page's address carries its token.\n */\nexport const authGate = createServerFn({ method: 'GET' }).handler(async () => {\n // Imported inside the server half so no auth page ships the screen host to the browser.\n const [{ getRequest, setResponseHeader }, { asas }, { loadEnterable }] = await Promise.all([import('@tanstack/react-start/server'), import('asas'), import('./screens.tsx')])\n setResponseHeader('referrer-policy', 'no-referrer')\n type Provider = { name: string; server: { $context: Promise<{ password: { config: { minPasswordLength: number; maxPasswordLength: number } } }> } }\n const provider = (asas.auth.strategies as unknown as Provider[]).find((one) => one.name === 'user')!\n const [signedIn, { password }] = await Promise.all([signedInAs(asas.auth.actor(getRequest().headers)), provider.server.$context])\n const first = signedIn ? (await loadEnterable())?.screens[0] : undefined\n const home: { path: string; name?: string } = first ?? { path: '/unauthorized' }\n return { signedIn, home, password: { min: password.config.minPasswordLength, max: password.config.maxPasswordLength } }\n})\n\n/**\n * Sign-in's and sign-up's `beforeLoad`. A signed-in person is sent where a\n * successful sign-in sends them; everyone else gets the password bounds in the\n * route context.\n */\nexport const gate = async ({ search }: { search: { redirect?: string } }) => {\n const { signedIn, home, password } = await authGate()\n if (signedIn) throw redirect({ href: wanted({ asked: search.redirect, home: home.path }) })\n return { password }\n}\n\n/** What a link to `home` says: the screen it opens, or, where it opens none, the action of seeing the account's status. */\nexport const useHomeLabel = () => {\n const { t } = useCopy()\n return (home: { name?: string }, sentence: 'auth.backTo' | 'marketing.openScreen') =>\n home.name === undefined\n ? t('auth.accountStatus', 'See your account status')\n : t(sentence, sentence === 'auth.backTo' ? 'Back to {screen}' : 'Open {screen}')!.replace('{screen}', () => t(`screens.${home.name}`, home.name)!)\n}\n\n/** What the forgot and reset pages draw for someone already signed in: the way on, and a sign-out. */\nexport function AlreadySignedIn({ redirect: asked, home }: { redirect?: string; home: { path: string; name?: string } }) {\n const { t } = useCopy()\n const label = useHomeLabel()\n const to = wanted({ asked, home: home.path })\n return (\n <AuthFrame title={t('auth.alreadySignedIn', 'You are already signed in')}>\n <div className=\"grid gap-4\">\n {/* An anchor: the path is the visitor's own, not a route the router knows. */}\n <a className=\"text-primary text-center text-sm underline underline-offset-4\" href={to}>\n {to === home.path ? label(home, 'auth.backTo') : t('auth.continue', 'Continue')}\n </a>\n <Button type=\"button\" variant=\"outline\" onClick={() => void authArm('sign-out').then(() => window.location.reload())}>\n {t('admin.signOut', 'Sign out')}\n </Button>\n </div>\n </AuthFrame>\n )\n}\n\n/** The sentence for a refusal of the provider: this app's own words for the status or code, then `fallback`. Never the provider's `message`, which is English in any locale. */\nexport const useRefusal = () => {\n const { t } = useCopy()\n return (answered: { status: number; body: Record<string, unknown> | null }, fallback: string | undefined) => {\n const key = refusalKey(answered.status, answered.body?.code)\n return (key && t(key)) || fallback || ''\n }\n}\n\n/** Whether the browser's own `type=\"email\"` refuses this text. */\nexport const notAnEmail = (value: string) => Object.assign(document.createElement('input'), { type: 'email', value }).validity.typeMismatch\n\n/** The frame every auth page is drawn in: the app's name linking home, the language switch, and the page's card. */\nexport function AuthFrame({ title, description, children }: { title: string | undefined; description?: ReactNode; children: ReactNode }) {\n const { t, locale } = useCopy()\n const { app } = useRouteContext({ from: '__root__' })\n return (\n <div className=\"flex min-h-screen flex-col\">\n <header className=\"mx-auto flex h-16 w-full max-w-6xl items-center justify-between gap-2 px-4 sm:px-6\">\n {/* An anchor: `/` is not a router route in an app whose start entry answers its home. */}\n <a className=\"text-lg font-semibold\" href=\"/\">\n {t(app, 'Asas')}\n </a>\n <LocaleSwitch locale={locale} />\n </header>\n <main className=\"flex flex-1 items-center justify-center px-4 pb-16\">\n <Card className=\"w-full max-w-sm\">\n <CardHeader>\n <CardTitle><h1>{title}</h1></CardTitle>\n {description ? <CardDescription>{description}</CardDescription> : null}\n </CardHeader>\n {/* Descriptions follow the text direction; the shared primitive aligns them left. */}\n <CardContent className=\"[&_[data-slot=field-description]]:text-start\">{children}</CardContent>\n </Card>\n </main>\n </div>\n )\n}\n\nfunction PasswordInput(props: ComponentProps<'input'>) {\n const { t } = useCopy()\n const [shown, setShown] = useState(false)\n return (\n // Focus moving to the box's own toggle has not left the box.\n <InputGroup onBlur={(event) => event.currentTarget.contains(event.relatedTarget) && event.stopPropagation()}>\n <InputGroupInput {...props} dir=\"ltr\" type={shown ? 'text' : 'password'} />\n <InputGroupAddon align=\"inline-end\">\n <InputGroupButton aria-label={t('auth.showPassword', 'Show password')} aria-pressed={shown} size=\"icon-xs\" onClick={() => setShown((was) => !was)}>\n {shown ? <EyeOff /> : <Eye />}\n </InputGroupButton>\n </InputGroupAddon>\n </InputGroup>\n )\n}\n\n/** One labelled box, which says what is wrong with it once it has been left. An email or password reads left to right in any locale. */\nexport function AuthBox({\n id,\n label,\n error,\n description,\n onLeave,\n ...props\n}: { id: string; label: string | undefined; error?: string; description?: string; onLeave: () => void } & ComponentProps<'input'>) {\n const control = { 'aria-describedby': describedBy({ id, description, error }), 'aria-invalid': error ? true : undefined, id, name: id, required: true, ...props }\n return (\n <FieldShell id={id} label={label ?? id} description={description} error={error} onBlur={onLeave}>\n {props.type === 'password' ? <PasswordInput {...control} /> : <Input dir={props.type === 'email' ? 'ltr' : 'auto'} {...control} />}\n </FieldShell>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 ab822afe361dfc5f82c504570fd5d0ea13ec4572e60adb679a3f9532ccf67803\n",
|
|
79
|
+
"type": "registry:file",
|
|
80
|
+
"target": "~/src/auth-frame.tsx"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"path": "registry/host/links.ts",
|
|
84
|
+
"content": "/**\n * The path a click on a plain `<a href>` is routed to inside the screen, or\n * `undefined` for a click the browser keeps: a modified or handled click, another\n * target, a download, another origin, or a path outside the screen's `base`.\n */\nexport const routedHref = ({\n event,\n origin,\n base,\n}: {\n event: Pick<MouseEvent, 'button' | 'metaKey' | 'altKey' | 'ctrlKey' | 'shiftKey' | 'defaultPrevented' | 'target'>\n origin: string\n base: string\n}): string | undefined => {\n if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return undefined\n const anchor = (event.target as Element | null)?.closest?.('a[href]')\n const target = anchor?.getAttribute('target')\n if (!anchor || (target && target !== '_self') || anchor.hasAttribute('download')) return undefined\n // The browser's own resolution against the current page: against the origin\n // alone, `href=\"edit\"` on `/dashboard/courses/` would read as `/edit`.\n const url = new URL((anchor as HTMLAnchorElement).href)\n if (url.origin !== origin || (url.pathname !== base && !url.pathname.startsWith(`${base}/`))) return undefined\n return `${url.pathname}${url.search}${url.hash}`\n}\n\n/**\n * Where a list asked for a page past its last is sent instead: the same URL at\n * the last page, or with no page where the last is the first. `undefined` for a\n * page in range.\n */\nexport const clampedHref = ({ href, page, totalPages }: { href: string; page: number; totalPages: number }): string | undefined => {\n if (page <= Math.max(1, totalPages)) return undefined\n const url = new URL(href, 'http://local')\n if (totalPages > 1) url.searchParams.set('page', String(totalPages))\n else url.searchParams.delete('page')\n return `${url.pathname}${url.search}${url.hash}`\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 4754cbac64eeac528e7e037173ad56ffa8d07df3e526e1191bbe3be400db97a5\n",
|
|
85
|
+
"type": "registry:file",
|
|
86
|
+
"target": "~/src/links.ts"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"path": "registry/host/request-headers.ts",
|
|
90
|
+
"content": "/**\n * The headers a page's server-side `request` call sends: the session's, with\n * the loader's own layered over them. The session's cookie always wins, so a\n * loader's headers cannot act as another actor.\n */\nexport const mergedHeaders = (session: Headers, own?: HeadersInit): Headers => {\n const headers = new Headers(session)\n if (own) for (const [key, value] of new Headers(own)) if (key !== 'cookie') headers.set(key, value)\n return headers\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 2969a3dafbf201d6eb6074ecda0e8f17e341cbd1ca176100350844e74ec23623\n",
|
|
91
|
+
"type": "registry:file",
|
|
92
|
+
"target": "~/src/request-headers.ts"
|
|
93
|
+
}
|
|
94
|
+
],
|
|
95
|
+
"type": "registry:item"
|
|
96
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-storage",
|
|
4
|
+
"title": "Mithaq storage",
|
|
5
|
+
"description": "The storage adapters a project owns, S3 with an R2 preset, UploadThing for reading and deleting files already there, and a local folder, and the eight functions the storage rukn's config names: the object key, the verification of a stored file, its disposition, the sweep of orphans, and the upload flow's instructions, create step, delete step and file route.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"aws4fetch@1.0.20",
|
|
8
|
+
"file-type@22.0.1",
|
|
9
|
+
"image-dimensions@2.5.1"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/storage/s3.ts",
|
|
14
|
+
"content": "// An S3 bucket as a mithaq storage adapter, over aws4fetch, so it runs on Node, Bun and\n// Workers alike and reaches AWS S3, Cloudflare R2, MinIO and Backblaze B2 the same way.\nimport { AwsClient } from 'aws4fetch'\nimport type { StorageAdapter } from 'mithaq'\n\nexport type S3Options = {\n /** The service's address without the bucket, such as `https://s3.eu-west-1.amazonaws.com`. */\n endpoint: string\n bucket: string\n region: string\n accessKeyId: string\n secretAccessKey: string\n /**\n * The bucket folder this project owns, such as `mithaq/asas`, with no slash at either end.\n * The sweep lists nothing outside it, so a bucket other apps share keeps their objects.\n */\n prefix: string\n /** Where the bucket is read publicly, for a kayan that writes `public: true`. */\n publicURL?: string\n}\n\nconst putSeconds = 600\nconst getSeconds = 300\n\nconst pathOf = (key: string) => key.split('/').map(encodeURIComponent).join('/')\n\nconst entities: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '\"', apos: \"'\" }\nconst text = (xml: string, tag: string) => xml.match(new RegExp(`<${tag}>([\\\\s\\\\S]*?)</${tag}>`))?.[1]?.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => entities[name]!)\n\n/**\n * The S3 adapter: uploads are signed PUTs bound to the declared type and size, and downloads\n * are 302s to a signed GET that carries the project's disposition.\n */\nexport const s3 = ({ endpoint, bucket, region, accessKeyId, secretAccessKey, prefix, publicURL }: S3Options): StorageAdapter => {\n if (!prefix || prefix.startsWith('/') || prefix.endsWith('/') || prefix.split('/').some((part) => !part || part === '.' || part === '..')) {\n throw new Error(`The S3 adapter's prefix ${JSON.stringify(prefix)} names no folder of the bucket. Name one, such as \"mithaq/asas\".`)\n }\n const client = new AwsClient({ accessKeyId, secretAccessKey, service: 's3', region })\n const base = `${endpoint.replace(/\\/+$/, '')}/${bucket}`\n const urlOf = (key: string) => `${base}/${pathOf(key)}`\n const signed = async (url: string, init: { method: string; headers?: Record<string, string> }) =>\n (await client.sign(url, { ...init, aws: { signQuery: true, allHeaders: true } })).url\n return {\n prefix,\n uploadInstructions: {\n // Signing the type and size makes the bucket refuse any other body, and signing\n // `If-None-Match: *` makes it refuse a second PUT rather than replace a verified object.\n generate: async ({ filesize, mimeType, key }) => {\n const headers = { 'Content-Type': mimeType, 'Content-Length': String(filesize), 'If-None-Match': '*' }\n return { type: 'http', request: { method: 'PUT', url: await signed(`${urlOf(key)}?X-Amz-Expires=${putSeconds}`, { method: 'PUT', headers }), headers } }\n },\n },\n handleDelete: async ({ key }) => {\n const answered = await client.fetch(urlOf(key), { method: 'DELETE' })\n if (!answered.ok && answered.status !== 404) throw new Error(`S3 refused to delete an object: ${answered.status}.`)\n },\n generateURL: ({ key }) => {\n if (!publicURL) throw new Error('This S3 adapter was given no publicURL, so it has no public address for a file.')\n return `${publicURL.replace(/\\/+$/, '')}/${pathOf(key)}`\n },\n staticHandler: async (_request, { doc, disposition }) => {\n const query = new URLSearchParams({ 'X-Amz-Expires': String(getSeconds), 'response-content-disposition': disposition, 'response-content-type': doc.mimeType })\n return new Response(null, { status: 302, headers: { Location: await signed(`${urlOf(doc.key)}?${query}`, { method: 'GET' }) } })\n },\n head: async ({ key }) => {\n const answered = await client.fetch(urlOf(key), { method: 'HEAD' })\n if (answered.status === 404) return null\n if (!answered.ok) throw new Error(`S3 answered a HEAD with ${answered.status}.`)\n return { size: Number(answered.headers.get('content-length')) }\n },\n readHead: async ({ key, bytes }) => {\n const answered = await client.fetch(urlOf(key), { headers: { Range: `bytes=0-${bytes - 1}` } })\n if (!answered.ok) throw new Error(`S3 answered a ranged GET with ${answered.status}.`)\n return new Uint8Array(await answered.arrayBuffer()).subarray(0, bytes)\n },\n list: async function* () {\n let token: string | undefined\n do {\n const query = new URLSearchParams({ 'list-type': '2', prefix: `${prefix}/`, ...(token && { 'continuation-token': token }) })\n const answered = await client.fetch(`${base}?${query}`)\n if (!answered.ok) throw new Error(`S3 answered a list with ${answered.status}.`)\n const xml = await answered.text()\n for (const [, one] of xml.matchAll(/<Contents>([\\s\\S]*?)<\\/Contents>/g)) {\n yield { key: text(one!, 'Key')!, lastModified: new Date(text(one!, 'LastModified')!) }\n }\n token = text(xml, 'IsTruncated') === 'true' ? text(xml, 'NextContinuationToken') : undefined\n } while (token)\n },\n }\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 a6b1f7b3a13ef66fb811f35844279694bfb8f3d08657170fa98e882bc0f5e079\n",
|
|
15
|
+
"type": "registry:lib",
|
|
16
|
+
"target": "~/src/storage/s3.ts"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "registry/storage/r2.ts",
|
|
20
|
+
"content": "import { s3, type S3Options } from './s3.ts'\n\nexport const r2 = ({ accountId, ...rest }: Omit<S3Options, 'endpoint' | 'region'> & { accountId: string }) =>\n s3({ ...rest, endpoint: `https://${accountId}.r2.cloudflarestorage.com`, region: 'auto' })\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 3f592d20f1b51223f76dba6cc10eb9903abcf5084fe8bec1424e5f929769acff\n",
|
|
21
|
+
"type": "registry:lib",
|
|
22
|
+
"target": "~/src/storage/r2.ts"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "registry/storage/uploadthing.ts",
|
|
26
|
+
"content": "// UploadThing as a mithaq storage adapter for files already stored there: it reads, signs a\n// short-lived address and deletes by key, and never uploads, so new files go to another adapter.\nimport type { StorageAdapter } from 'mithaq'\n\nexport type UploadThingOptions = {\n /** `UPLOADTHING_TOKEN`, the base64 JSON that names the API key and the app. */\n token: string\n /** The fetch every request goes through, which a test replaces. */\n fetch?: typeof fetch\n}\n\nconst getSeconds = 300\nconst api = 'https://api.uploadthing.com/v6'\n\nexport const uploadthing = ({ token, fetch: send = fetch }: UploadThingOptions): StorageAdapter => {\n const { apiKey, appId } = JSON.parse(Buffer.from(token, 'base64').toString()) as { apiKey: string; appId: string }\n const fileURL = (key: string) => `https://${appId}.ufs.sh/f/${encodeURIComponent(key)}`\n const call = async (path: string, body: unknown) => {\n const answered = await send(`${api}/${path}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-uploadthing-api-key': apiKey },\n body: JSON.stringify(body),\n })\n if (!answered.ok) throw new Error(`UploadThing answered ${path} with ${answered.status}.`)\n return answered.json() as Promise<Record<string, unknown>>\n }\n return {\n // UploadThing chose these keys and lists none of them, so no sweep ever reaches them.\n prefix: '',\n uploadInstructions: {\n generate: async () => {\n throw new Error('The UploadThing adapter reads and deletes the files already there, and uploads none. Send new uploads to another adapter.')\n },\n },\n handleDelete: async ({ key }) => {\n await call('deleteFiles', { fileKeys: [key] })\n },\n generateURL: ({ key }) => fileURL(key),\n // UploadThing's signed address takes no disposition, so the stored type stands and an SVG\n // stored there is served to draw, not to download.\n staticHandler: async (_request, { doc }) => {\n const { url } = await call('requestFileAccess', { fileKey: doc.key, expiresIn: getSeconds })\n if (typeof url !== 'string') throw new Error('UploadThing answered requestFileAccess with no url.')\n return new Response(null, { status: 302, headers: { Location: url } })\n },\n head: async ({ key }) => {\n const answered = await send(fileURL(key), { method: 'HEAD' })\n if (answered.status === 404) return null\n if (!answered.ok) throw new Error(`UploadThing answered a HEAD with ${answered.status}.`)\n return { size: Number(answered.headers.get('content-length')) }\n },\n readHead: async ({ key, bytes }) => {\n const answered = await send(fileURL(key), { headers: { Range: `bytes=0-${bytes - 1}` } })\n if (!answered.ok) throw new Error(`UploadThing answered a ranged GET with ${answered.status}.`)\n return new Uint8Array(await answered.arrayBuffer()).subarray(0, bytes)\n },\n }\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 edb513da5b7d5ee1a405e8943b00bd25fa0b1cbf0c6c3d1a2854deaea95b8564\n",
|
|
27
|
+
"type": "registry:lib",
|
|
28
|
+
"target": "~/src/storage/uploadthing.ts"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "registry/storage/local.ts",
|
|
32
|
+
"content": "// A folder on this machine as a mithaq storage adapter, for development and CI. A folder has\n// no signed URLs, so an upload is a PUT to `<route>/<token>`, the token signed for one key,\n// type and size; the bytes become the object only when their count is exactly the size signed.\nimport { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'\nimport { createReadStream } from 'node:fs'\nimport { link, mkdir, open, readdir, rm, stat } from 'node:fs/promises'\nimport { dirname, relative, resolve, sep } from 'node:path'\nimport { Readable } from 'node:stream'\nimport type { StorageAdapter } from 'mithaq'\n\nexport type LocalOptions = {\n dir: string\n /** What the upload tokens are signed with, 32 characters at least. */\n secret: string\n /** The folder under `dir` this project's keys start with, and the one the sweep lists. */\n prefix: string\n /** The path the upload route answers under, `/api/storage/local` unless named. */\n route?: string\n}\n\nconst tokenSeconds = 600\n\ntype Token = { key: string; type: string; size: number; exp: number }\n\nconst refused = (status: number, message: string) => Response.json({ message }, { status })\n\n/**\n * The local adapter. A key that leaves `dir`, or holds an empty, `.` or `..` part, a backslash\n * or a NUL, is refused before any file is touched, whoever handed it in.\n */\nexport const local = ({ dir, secret, prefix, route = '/api/storage/local' }: LocalOptions): StorageAdapter => {\n if (secret.length < 32) throw new Error('The local storage adapter signs its uploads with a secret of 32 characters at least.')\n if (!prefix || prefix.split('/').some((part) => !part || part === '.' || part === '..')) {\n throw new Error(`The local storage adapter's prefix ${JSON.stringify(prefix)} names no folder. Name one, such as \"mithaq/asas\".`)\n }\n const root = resolve(dir)\n const pathOf = (key: string) => {\n if (typeof key !== 'string' || !key || /[\\\\\\0]/.test(key) || key.split('/').some((part) => part === '' || part === '.' || part === '..')) {\n throw new Error(`The key ${JSON.stringify(key)} names no file inside the storage folder.`)\n }\n const full = resolve(root, key)\n if (!full.startsWith(root + sep)) throw new Error(`The key ${JSON.stringify(key)} names no file inside the storage folder.`)\n return full\n }\n const sign = (body: string) => createHmac('sha256', secret).update(body).digest('base64url')\n const tokenOf = (token: Token) => {\n const body = Buffer.from(JSON.stringify(token)).toString('base64url')\n return `${body}.${sign(body)}`\n }\n const held = (token: string): Token | undefined => {\n const [body = '', signature = ''] = token.split('.')\n const given = Buffer.from(signature)\n const expected = Buffer.from(sign(body))\n if (given.length !== expected.length || !timingSafeEqual(given, expected)) return undefined\n const read = JSON.parse(Buffer.from(body, 'base64url').toString()) as Token\n return read.exp * 1000 < Date.now() ? undefined : read\n }\n return {\n prefix,\n uploadInstructions: {\n generate: async ({ filesize, mimeType, key }) => {\n pathOf(key)\n const token = tokenOf({ key, type: mimeType, size: filesize, exp: Math.floor(Date.now() / 1000) + tokenSeconds })\n return { type: 'http', request: { method: 'PUT', url: `${route}/${token}`, headers: { 'Content-Type': mimeType } } }\n },\n },\n handler: async (request) => {\n const { pathname } = new URL(request.url)\n if (request.method !== 'PUT' || !pathname.startsWith(`${route}/`)) return null\n const token = held(pathname.slice(route.length + 1))\n if (!token) return refused(403, 'The upload token is not one this server signed, or it has expired.')\n if (request.headers.get('content-type') !== token.type) return refused(400, 'The upload is not of the type that was signed.')\n let path: string\n try {\n path = pathOf(token.key)\n } catch {\n return refused(400, 'The upload names no file inside the storage folder.')\n }\n await mkdir(dirname(path), { recursive: true })\n const partial = `${path}.${randomUUID()}.part`\n const file = await open(partial, 'wx')\n let written = 0\n try {\n try {\n const reader = request.body?.getReader()\n while (reader) {\n const { done, value } = await reader.read()\n if (done) break\n written += value.byteLength\n if (written > token.size) return refused(400, 'The upload is larger than the size that was signed.')\n await file.write(value)\n }\n } finally {\n await file.close()\n }\n if (written !== token.size) return refused(400, 'The upload is not the size that was signed.')\n // A second PUT with the same token must not change an object its create already\n // verified: a link fails where the object exists, where a rename would replace it.\n try {\n await link(partial, path)\n } catch (error) {\n if ((error as { code?: unknown }).code === 'EEXIST') return refused(409, 'This upload was already sent.')\n throw error\n }\n return new Response(null, { status: 204 })\n } finally {\n await rm(partial, { force: true })\n }\n },\n handleDelete: async ({ key }) => {\n await rm(pathOf(key), { force: true })\n },\n staticHandler: async (_request, { doc }) => {\n const path = pathOf(doc.key)\n const { size } = await stat(path)\n return new Response(Readable.toWeb(createReadStream(path)) as ReadableStream, { status: 200, headers: { 'Content-Length': String(size) } })\n },\n head: async ({ key }) => {\n const found = await stat(pathOf(key)).catch(() => undefined)\n return found?.isFile() ? { size: found.size } : null\n },\n readHead: async ({ key, bytes }) => {\n const file = await open(pathOf(key))\n try {\n const buffer = new Uint8Array(bytes)\n const { bytesRead } = await file.read(buffer, 0, bytes, 0)\n return buffer.subarray(0, bytesRead)\n } finally {\n await file.close()\n }\n },\n // The prefix's folder alone, so another project sharing `dir` keeps its files.\n list: async function* () {\n const entries = await readdir(resolve(root, prefix), { recursive: true, withFileTypes: true }).catch(() => [])\n for (const entry of entries) {\n if (!entry.isFile()) continue\n const path = resolve(entry.parentPath, entry.name)\n yield { key: relative(root, path).split(sep).join('/'), lastModified: (await stat(path)).mtime }\n }\n },\n }\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 86a4471247d69e5f9d5427bb88decdb6d05a0fe5b7252ea88b47e37484dd510c\n",
|
|
33
|
+
"type": "registry:lib",
|
|
34
|
+
"target": "~/src/storage/local.ts"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "registry/storage/defaults.ts",
|
|
38
|
+
"content": "import { randomUUID } from 'node:crypto'\nimport { fileTypeFromBuffer } from 'file-type'\nimport { imageDimensionsFromData } from 'image-dimensions'\nimport {\n adapterOf,\n allowedType,\n checkReference,\n fileHeaders,\n invalid,\n readForFile,\n sanitizeFilename,\n signReference,\n type StorageConfig,\n type StorageKayan,\n} from 'mithaq'\n\n/**\n * `<prefix>/<tenant>/<kayan>/<id>/<filename>`, under the adapter's prefix, the\n * one folder its sweep lists. The id is the row's own, so two files of one name never meet.\n */\nexport const defaultKey: StorageConfig['key'] = ({ prefix, tenant, kayan, id, filename }) =>\n [...(prefix ? [prefix] : []), (tenant ?? '_').replaceAll('/', '_'), kayan, id, filename].join('/')\n\n/** The bytes `file-type` reads to tell a type, its own `reasonableDetectionSizeInBytes`. */\nconst sniffed = 4100\n\n/**\n * Checks the stored object is there, of the declared size and no larger than the kayan takes,\n * and of the declared type as its first bytes tell it, and reads an image's width and height.\n * A type the bytes cannot tell, such as text/plain, CSV or JSON, is refused; SVG is the one text type taken.\n */\nexport const defaultVerify: StorageConfig['verify'] = async ({ adapter, key, mimeType, size, mimeTypes, fileSize }) => {\n const head = await adapter.head({ key })\n if (!head) return { refused: 'No file was uploaded.' }\n if (head.size !== size) return { refused: `The file holds ${head.size} bytes, and ${size} were declared.` }\n if (head.size > fileSize) return { refused: `The file holds more than ${fileSize} bytes.` }\n const bytes = await adapter.readHead({ key, bytes: sniffed })\n const read = (await fileTypeFromBuffer(bytes))?.mime\n const svg = (!read || read === 'application/xml') && /<svg[\\s>]/i.test(new TextDecoder().decode(bytes))\n const type = svg ? 'image/svg+xml' : read\n if (type !== mimeType) return { refused: `The file is ${type ?? 'of no type a file can be told by'}, and ${mimeType} was declared.` }\n if (!allowedType(type, mimeTypes)) return { refused: `The file is ${type}, which this kayan does not hold.` }\n const measured = imageDimensionsFromData(bytes)\n return { width: measured?.width ?? null, height: measured?.height ?? null }\n}\n\n/** The types a browser draws in place and runs nothing in. Everything else is downloaded. */\nconst drawn = /^(image\\/(png|jpeg|gif|webp|avif)|application\\/pdf)$/\n\n/**\n * `inline` for a raster image and a PDF, `attachment` for every other type, an SVG among them,\n * with the uploaded name (RFC 6266: `filename` for old clients, `filename*` for the rest).\n */\nexport const defaultDisposition: StorageConfig['disposition'] = ({ filename, mimeType }) => {\n const plain = filename.replace(/[^\\x20-\\x7e]|[\"\\\\]/g, '_')\n const encoded = encodeURIComponent(filename).replace(/['()*]/g, (one) => `%${one.charCodeAt(0).toString(16).toUpperCase()}`)\n return `${drawn.test(mimeType) ? 'inline' : 'attachment'}; filename=\"${plain}\"; filename*=UTF-8''${encoded}`\n}\n\n/** How long an object may sit with no row naming it before it is swept: an upload in flight has an hour. */\nconst day = 24 * 60 * 60 * 1000\n\n/** Removes every object older than a day that no row names, or with `check` lists them and leaves them. */\nexport const defaultSweep: StorageConfig['sweep'] = async ({ adapter, named, check, now }) => {\n const orphans: string[] = []\n for await (const { key, lastModified } of adapter.list!()) {\n if (named.has(key) || now.getTime() - lastModified.getTime() < day) continue\n orphans.push(key)\n if (!check) await adapter.handleDelete({ key })\n }\n return orphans\n}\n\n/**\n * Answers `uploadInstructions` once core has checked the create permission: validates the request,\n * then returns the row's id, the reference core signs for it and the adapter's signed request.\n */\nexport const defaultUploadInstructions: StorageConfig['instructions'] = async ({ kayan, input, actor }) => {\n const asked = input as Record<string, unknown> | null\n if (\n !asked || typeof asked !== 'object' || Array.isArray(asked)\n || typeof asked.filename !== 'string' || asked.filename.length > 255\n || typeof asked.mimeType !== 'string' || !/^[\\w.+-]+\\/[\\w.+-]+$/.test(asked.mimeType)\n || !Number.isSafeInteger(asked.filesize) || (asked.filesize as number) < 1\n ) {\n throw invalid({ message: 'An upload names its filename, 255 characters at most, its mimeType, a type such as image/png, and its filesize, a whole number of bytes above 0.', key: 'error.invalid.upload_request', params: { kayan: kayan.name } })\n }\n const filename = sanitizeFilename(asked.filename as string)\n const mimeType = asked.mimeType as string\n const filesize = asked.filesize as number\n if (!filename) throw invalid({ message: 'The file has no name left once its folders and control characters are dropped.', key: 'error.invalid.upload_request', params: { kayan: kayan.name } })\n if (!allowedType(mimeType, kayan.storage.mimeTypes)) {\n throw invalid({ message: `A \"${kayan.name}\" file may not be of type \"${mimeType}\".`, key: 'error.invalid.upload_type', params: { kayan: kayan.name, type: mimeType } })\n }\n if (filesize > kayan.storage.fileSize) {\n throw invalid({ message: `A \"${kayan.name}\" file holds ${kayan.storage.fileSize} bytes at most.`, key: 'error.invalid.upload_size', params: { kayan: kayan.name, max: kayan.storage.fileSize } })\n }\n const uploadReference = signReference({ kayan, actor, id: randomUUID(), filename, type: mimeType, size: filesize })\n const request = await adapterOf({ kayan, name: uploadReference.storage }).uploadInstructions.generate({ filename, filesize, mimeType, key: uploadReference.key })\n return { ...request, file: { filename, mimeType, size: filesize, uploadReference } }\n}\n\n/**\n * The create's step: checks the reference, runs the project's `verify` and writes every file column\n * from what the server measured. It runs before the transaction so no lock is held while the object is read.\n */\nexport const defaultUploaded: StorageConfig['uploaded'] = async (ctx) => {\n const reference = await checkReference({ kayan: ctx.kayan, value: ctx.data.uploadReference, actor: ctx.actor })\n const { storage } = ctx.kayan as StorageKayan\n const adapter = adapterOf({ kayan: ctx.kayan, name: reference.storage })\n const verified = await storage.config.verify({ adapter, key: reference.key, mimeType: reference.type, size: reference.size, mimeTypes: storage.mimeTypes, fileSize: storage.fileSize })\n if ('refused' in verified) {\n throw invalid({ message: `The uploaded file was refused: ${verified.refused}`, key: 'error.invalid.upload_verify', params: { kayan: ctx.kayan.name } })\n }\n const { uploadReference: _used, ...data } = ctx.data\n return {\n ...ctx,\n data: {\n ...data,\n id: reference.id,\n key: reference.key,\n storage: reference.storage,\n filename: reference.filename,\n mimeType: reference.type,\n filesize: reference.size,\n width: verified.width ?? null,\n height: verified.height ?? null,\n },\n }\n}\n\n/**\n * Deletes the removed row's object after the commit, so a failure leaves an orphan for the sweep,\n * never a row naming no object. A kayan that marks its rows deleted keeps the object for a restore.\n */\nexport const defaultRemoveFile: StorageConfig['removed'] = async (ctx) => {\n if (ctx.kayan.softDelete) return ctx\n const row = ctx.dataBefore ?? ctx.data\n if (typeof row.key === 'string') await adapterOf({ kayan: ctx.kayan, name: row.storage }).handleDelete({ key: row.key })\n return ctx\n}\n\n/**\n * `GET /api/<kayan>/file/<id>`: reads the row through core's read gate, where every miss is the same 404,\n * then answers with the adapter's redirect or bytes under the project's `disposition`.\n */\nexport const defaultServeFile: StorageConfig['serve'] = async ({ request, kayan, id, read }) => {\n const doc = await readForFile({ kayan, id, read })\n const adapter = adapterOf({ kayan, name: doc.storage })\n return fileHeaders({\n doc,\n disposition: kayan.storage.config.disposition({ filename: doc.filename, mimeType: doc.mimeType }),\n answer: ({ disposition }) => adapter.staticHandler(request, { doc, disposition }),\n })\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 af2cd4436012c35d6e1d868b3f5cc2efec50c61983d2559609ebec55d1b4acf5\n",
|
|
39
|
+
"type": "registry:lib",
|
|
40
|
+
"target": "~/src/storage/defaults.ts"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"type": "registry:lib"
|
|
44
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-upload",
|
|
4
|
+
"title": "Mithaq upload",
|
|
5
|
+
"description": "The upload kind's control, cell and read view: a file drawn by its thumbnail or its name, chosen through the relationship's picker or uploaded in place with its progress, the server's refusal and a retry, a set as a grid moved and removed by keyboard; and the opt-in rich text upload node's view, its / menu pick and the dialog that inserts it.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"lexical@0.52.0",
|
|
8
|
+
"@lexical/react@0.52.0",
|
|
9
|
+
"@lexical/utils@0.52.0"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"button",
|
|
13
|
+
"dialog",
|
|
14
|
+
"@mithaq/mithaq-controls",
|
|
15
|
+
"@mithaq/mithaq-field-shell",
|
|
16
|
+
"@mithaq/mithaq-relationship-cell",
|
|
17
|
+
"@mithaq/mithaq-relationship-control",
|
|
18
|
+
"@mithaq/mithaq-rich-text"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
{
|
|
22
|
+
"path": "registry/upload.tsx",
|
|
23
|
+
"content": "import { use, useEffect, useRef, useState, type FormEvent, type ReactElement, type ReactNode } from 'react'\nimport { ChevronLeftIcon, ChevronRightIcon, FileIcon, ImageIcon, ImageOffIcon, UploadIcon, XIcon } from 'lucide-react'\nimport { $create, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'\nimport { $insertNodeToNearestRoot } from '@lexical/utils'\nimport { allowedType, type ClientField, type ClientKayan, type FetchClient } from 'mithaq/admin'\nimport { ScreenContext } from 'mithaq/admin/react'\nimport { UploadNode } from 'mithaq/rich-text'\nimport { Button, buttonVariants } from '@/components/ui/button'\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'\nimport { FieldShell, describedBy } from './field-shell.tsx'\nimport { changeOf, controlFor, createsIn, isForbidden, labelFor, listed, localesOf, noValue, readOnlyField, refusalKeyOf, type ControlProps, type FindApi, type FoundPage, type Translate } from './controls.tsx'\nimport type { CellProps } from './cells.tsx'\nimport { RelationshipControl, findByIds, idsOf } from './relationship-control.tsx'\nimport { rowKey, type RelationshipTitles } from './relationship-cell.tsx'\nimport type { RichTextPick } from './rich-text.tsx'\n\ntype Row = Record<string, unknown>\n\ntype UploadRequest = { method: 'PUT' | 'POST'; url: string; headers?: Record<string, string> }\ntype UploadInstructions = { request: UploadRequest; file: { uploadReference: unknown } }\n\n/** The copy key of the refusal the server would answer for a file of this type and size, or nothing. The server checks both again, and reads the bytes besides. */\nexport const refusedFile = (file: { type: string; size: number }, storage?: { mimeTypes: readonly string[]; fileSize: number }): string | undefined => {\n if (!storage) return undefined\n if (!allowedType(file.type, storage.mimeTypes)) return 'error.invalid.upload_type'\n if (file.size > storage.fileSize) return 'error.invalid.upload_size'\n return file.size < 1 ? 'error.invalid.upload_request' : undefined\n}\n\n/** The refusals about the file itself, which sending it again meets again, so no retry is offered. A failed send, a 412 or a 5xx may pass a second time. */\nconst final = new Set(['error.invalid.upload_type', 'error.invalid.upload_size', 'error.invalid.upload_verify'])\n\nconst vendorNames: Readonly<Record<string, string>> = {\n 'vnd.openxmlformats-officedocument.wordprocessingml.document': 'DOCX',\n 'vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'XLSX',\n 'vnd.openxmlformats-officedocument.presentationml.presentation': 'PPTX',\n 'vnd.ms-excel': 'XLS',\n 'msword': 'DOC',\n}\n\n/**\n * A file type as a person names it, the subtype in capitals (`image/png` is PNG), since the raw\n * type reads as a path and breaks at its slash in an Arabic sentence. A vendor type is named by\n * its extension where well known, else by the last part of its name. A wildcard such as `image/*` is kept whole.\n */\nexport const typeName = (mimeType: string): string => {\n const [type = '', subtype = ''] = mimeType.split('/')\n if (!subtype || subtype === '*') return type ? `${type}/*` : mimeType\n const bare = subtype.split('+')[0]!\n return Object.hasOwn(vendorNames, bare) ? vendorNames[bare]! : bare.replace(/^(x-|vnd\\.)/, '').split('.').at(-1)!.toUpperCase()\n}\n\n/** The types a kayan takes, each named once, in the order the kayan lists them. */\nexport const typeNames = (mimeTypes: readonly string[]): string[] => [...new Set(mimeTypes.map(typeName))]\n\n/** A size in bytes as a person reads it: bytes, kilobytes or megabytes (1024 each), every unit written out because the platform's short forms mix words with abbreviations. */\nexport const sizeOf = (bytes: number, locale: string): string => {\n const [value, unit] = bytes < 1024 ? [bytes, 'byte'] : bytes < 1024 * 1024 ? [bytes / 1024, 'kilobyte'] : [bytes / (1024 * 1024), 'megabyte']\n return new Intl.NumberFormat(locale, { style: 'unit', unit, unitDisplay: 'long', maximumFractionDigits: 1 }).format(value)\n}\n\nconst blank = (value: unknown) => value === undefined || value === null || value === ''\n\n/** The words of a refusal: the project's copy under the key the server sent, and the server's own sentence where the copy holds none. */\nconst refusalOf = (thrown: unknown, t: Translate) => {\n const message = thrown instanceof Error ? thrown.message : String(thrown)\n const key = refusalKeyOf(thrown)\n return { words: (key && t(key, message)) || message, retry: !final.has(key ?? '') }\n}\n\n/** Headers a browser sets itself and refuses from a script, which the S3 adapter signs and the browser sends anyway. */\nconst forbidden = new Set(['content-length', 'host'])\n\n/** The bytes sent where the instructions say, with progress as a fraction. Over `XMLHttpRequest` because `fetch` reports no upload progress. */\nexport const put = ({ request, file, progress }: { request: UploadRequest; file: Blob; progress: (fraction: number) => void }) =>\n new Promise<void>((resolve, reject) => {\n const sending = new XMLHttpRequest()\n sending.open(request.method, request.url)\n for (const [name, value] of Object.entries(request.headers ?? {})) if (!forbidden.has(name.toLowerCase())) sending.setRequestHeader(name, value)\n sending.upload.onprogress = (event) => {\n if (event.lengthComputable) progress(event.loaded / event.total)\n }\n sending.onload = () => {\n if (sending.status >= 200 && sending.status < 300) return resolve()\n let message = `HTTP ${sending.status}`\n try {\n const said = (JSON.parse(sending.responseText) as { message?: unknown }).message\n if (typeof said === 'string') message = said\n } catch {\n // A store's own refusal is often XML; the status is what is left to say.\n }\n reject(Object.assign(new Error(message), { status: sending.status }))\n }\n sending.onerror = () => reject(new Error('The file could not be sent.'))\n sending.send(file)\n })\n\n/** The whole upload in the three steps the server asks for: the instructions, the bytes, then the create carrying the signed reference beside the row's own fields. Answers the row the create made. */\nexport const uploadFile = async ({ client, kayan, file, data, progress, send = put }: {\n client: FetchClient\n kayan: string\n file: File\n data: Row\n progress: (fraction: number) => void\n send?: typeof put\n}): Promise<Row> => {\n const instructions = (await client({ kayan, operation: 'uploadInstructions', input: { filename: file.name, filesize: file.size, mimeType: file.type } })) as UploadInstructions\n await send({ request: instructions.request, file, progress })\n progress(1)\n return (await client({ kayan, operation: 'create', input: { ...data, uploadReference: instructions.file.uploadReference } })) as Row\n}\n\n/** A file row's words for a screen reader: its alternative text in the viewer's locale, else its name. */\nconst altOf = (row: Row, locale: string) => {\n const alt = row.alt && typeof row.alt === 'object' ? (row.alt as Record<string, unknown>)[locale] : row.alt\n return typeof alt === 'string' && alt ? alt : String(row.filename ?? '')\n}\n\n/** The file's own address, computed by the server on every read and never stored. */\nconst urlOf = (row: Row, key: 'url' | 'thumbnailURL') => (typeof row[key] === 'string' ? (row[key] as string) : undefined)\n\n// A small thumbnail takes `max-w-none` and `shrink-0`: the preflight's `max-width: 100%` makes an image count as no width when a table sizes its column.\nconst sizes = { xs: 'size-8 shrink-0 max-w-none', sm: 'size-16 shrink-0 max-w-none', lg: 'max-h-64 w-auto max-w-full' } as const\n/** The pixels a thumbnail is drawn at, so the browser lays it out before the file arrives. */\nconst pixels = { xs: 32, sm: 64 } as const\n\n/** What stands where an image cannot be drawn: the reader may not read it, or the file is gone. */\nfunction Missing({ t, size }: { t: Translate; size: keyof typeof sizes }): ReactElement {\n const words = t('admin.upload.missing', 'This file cannot be shown.') ?? 'This file cannot be shown.'\n return (\n <span data-upload-missing=\"\" title={words} className={`bg-muted text-muted-foreground inline-flex items-center justify-center gap-2 rounded-md border ${size === 'lg' ? 'p-4 text-sm' : sizes[size]}`}>\n <ImageOffIcon className=\"size-4 shrink-0\" aria-hidden />\n <span className={size === 'lg' ? undefined : 'sr-only'}>{words}</span>\n </span>\n )\n}\n\n/** One file drawn: its thumbnail where it is an image, else its name and type. The image goes through the file route off the row's computed `thumbnailURL`, so the row's own access decides who sees it. */\nexport function FileThumb({ row, t, locale, size = 'sm' }: { row: Row; t: Translate; locale: string; size?: keyof typeof sizes }): ReactElement {\n const [failed, setFailed] = useState(false)\n const thumbnail = urlOf(row, 'thumbnailURL')\n if (thumbnail && !failed) {\n return <img src={thumbnail} alt={altOf(row, locale)} loading=\"lazy\" decoding=\"async\" {...(size !== 'lg' && { width: pixels[size], height: pixels[size] })} onError={() => setFailed(true)} data-upload-thumbnail=\"\" className={`rounded-md border object-cover ${sizes[size]}`} />\n }\n if (thumbnail) return <Missing t={t} size={size} />\n return (\n <span data-upload-file=\"\" className=\"inline-flex min-w-0 items-center gap-2\">\n <FileIcon className=\"text-muted-foreground size-4 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\" dir=\"auto\">{String(row.filename ?? row.id ?? '')}</span>\n {size === 'xs' ? null : <span className=\"text-muted-foreground shrink-0 text-xs\" dir=\"ltr\">{String(row.mimeType ?? '')}</span>}\n </span>\n )\n}\n\n/**\n * A file read on a record page: the image, opening the file itself, or a\n * link that downloads it, both through the access-gated file route.\n */\nexport function FileValue({ row, t, locale }: { row: Row; t: Translate; locale: string }): ReactElement {\n const url = urlOf(row, 'url')\n if (urlOf(row, 'thumbnailURL')) {\n return (\n <a href={url} target=\"_blank\" rel=\"noopener noreferrer\" className=\"inline-block max-w-full\">\n <FileThumb row={row} t={t} locale={locale} size=\"lg\" />\n </a>\n )\n }\n return (\n <a href={url} download={typeof row.filename === 'string' ? row.filename : ''} data-upload-download=\"\" className=\"inline-flex max-w-full underline underline-offset-2\">\n <FileThumb row={row} t={t} locale={locale} />\n </a>\n )\n}\n\n/** One held id as the map read it: its row, loading until the read answers, a dash where the read failed, and a row the viewer cannot see where it was refused or left out. Never the raw id. */\nconst heldFile = ({ titles, target, id, t, locale, size }: {\n titles: RelationshipTitles | undefined\n target: string\n id: string\n t: Translate\n locale: string\n size: keyof typeof sizes\n}) => {\n const found = titles?.get(rowKey(target, id))\n if (found === null || found === undefined) return <span key={id}>{t('admin.loading', 'Loading…') ?? 'Loading…'}</span>\n if (found instanceof Error) return <span key={id}>—</span>\n if (found && typeof found === 'object') return size === 'lg' ? <FileValue key={id} row={found} t={t} locale={locale} /> : <FileThumb key={id} row={found} t={t} locale={locale} size={size} />\n return <span key={id} className=\"text-muted-foreground text-xs\">{t('admin.cannotSee', 'You can\\'t see this') ?? 'You can\\'t see this'}</span>\n}\n\nconst totalToShow = 3\n\n/** The `upload` kind's cell: a small thumbnail for an image and the name for any other file; a set draws its first three and counts the rest. */\nexport function UploadCell({ field, locale, t, titles, value }: CellProps): ReactNode {\n const ids = idsOf(value)\n if (!ids.length) return noValue\n const rest = ids.length - totalToShow\n return (\n <span className=\"inline-flex max-w-full items-center gap-1\">\n {ids.slice(0, totalToShow).map((id) => heldFile({ titles, target: field.relationTo ?? '', id, t, locale, size: 'xs' }))}\n {rest > 0 ? <span className=\"text-muted-foreground text-xs\">{(t('admin.andMore', 'and {count} more') ?? 'and {count} more').replace('{count}', () => rest.toLocaleString(locale))}</span> : null}\n </span>\n )\n}\n\n/** A record page's read of an upload: each file whole, the image or its download link. */\nexport function UploadValue({ field, locale, t, titles, value }: CellProps): ReactNode {\n const ids = idsOf(value)\n return ids.length ? <span className=\"grid gap-2\">{ids.map((id) => heldFile({ titles, target: field.relationTo ?? '', id, t, locale, size: 'lg' }))}</span> : null\n}\n\n/**\n * What the upload dialog asks beside the file: every field of the kayan a\n * create must be sent and the caller writes, its alternative text among\n * them. The file's own columns are the runner's and never asked.\n */\nexport const askedFields = (target: ClientKayan): ClientField[] =>\n target.fields.filter((field) => field.rules.required === true && !readOnlyField(field, 'create') && field.kind !== 'join')\n\n/**\n * The dialog a new file is made in: the file, the fields the create asks,\n * the send with its progress, and the server's refusal with a retry. It\n * answers the row the create made.\n */\nexport function UploadDialog({ open, onOpenChange, target, client, t, locale, locales, onDone }: {\n open: boolean\n onOpenChange: (open: boolean) => void\n target: ClientKayan\n client: FetchClient\n t: Translate\n locale: string\n locales: readonly string[]\n onDone: (row: Row) => void\n}): ReactElement {\n const [file, setFile] = useState<File | null>(null)\n const [values, setValues] = useState<Row>({})\n const [progress, setProgress] = useState<number | null>(null)\n const [refused, setRefused] = useState<{ words: string; retry: boolean }>()\n const [noFile, setNoFile] = useState(false)\n const [wrong, setWrong] = useState<{ words: Record<string, string>; locales: Readonly<Record<string, readonly string[]>> }>({ words: {}, locales: {} })\n const fields = askedFields(target)\n const busy = progress !== null\n const inputId = `upload-${target.name}-file`\n const close = () => {\n if (busy) return\n setFile(null)\n setValues({})\n setRefused(undefined)\n setNoFile(false)\n setWrong({ words: {}, locales: {} })\n onOpenChange(false)\n }\n const send = async () => {\n if (busy) return\n if (!file) return setNoFile(true)\n const early = refusedFile(file, target.storage)\n if (early) return setRefused({ words: t(early, early) ?? early, retry: false })\n const missing = { words: {} as Record<string, string>, locales: {} as Record<string, readonly string[]> }\n for (const field of fields) {\n const held = values[field.name]\n const empty = field.rules.localized === true ? locales.filter((one) => blank((held as Row | undefined)?.[one])) : blank(held) ? [''] : []\n if (!empty.length) continue\n const key = `${target.name}.${field.name}.required`\n missing.words[field.name] = t(key, key) ?? key\n if (field.rules.localized === true) missing.locales[field.name] = empty\n }\n setWrong(missing)\n setRefused(undefined)\n if (Object.keys(missing.words).length) return\n setProgress(0)\n try {\n const row = await uploadFile({ client, kayan: target.name, file, data: values, progress: setProgress })\n setProgress(null)\n setFile(null)\n setValues({})\n onDone(row)\n onOpenChange(false)\n } catch (thrown) {\n setProgress(null)\n // A schema refusal is drawn under each field it names, keyed by the path's first segment.\n const issues = (thrown as { data?: { params?: { issues?: unknown } } } | null)?.data?.params?.issues\n const words: Record<string, string> = {}\n for (const issue of Array.isArray(issues) ? (issues as { path?: unknown; message?: unknown }[]) : []) {\n const name = typeof issue?.path === 'string' ? issue.path.split('.')[0]! : ''\n if (fields.some((field) => field.name === name) && typeof issue.message === 'string') words[name] ??= t(issue.message, issue.message) ?? issue.message\n }\n if (Object.keys(words).length) return setWrong({ words, locales: localesOf(thrown) })\n setRefused(refusalOf(thrown, t))\n }\n }\n const submit = (event: FormEvent<HTMLFormElement>) => {\n // The dialog is portaled out of the document, and React still carries its\n // submit up to the page's own form, which would save the row.\n event.preventDefault()\n event.stopPropagation()\n void send()\n }\n const percent = Math.round((progress ?? 0) * 100)\n const title = t('admin.upload.new', 'Upload new') ?? 'Upload new'\n return (\n <Dialog open={open} onOpenChange={(next: boolean) => (next ? onOpenChange(true) : close())}>\n <DialogContent showCloseButton={false} data-upload-dialog={target.name}>\n <DialogHeader>\n <DialogTitle>{title}</DialogTitle>\n <DialogDescription>\n {(t('admin.upload.takes', 'Files of these types, {size} at most: {types}.') ?? '')\n .replace('{size}', () => sizeOf(target.storage?.fileSize ?? 0, locale))\n .replace('{types}', () => listed(typeNames(target.storage?.mimeTypes ?? []), locale))}\n </DialogDescription>\n </DialogHeader>\n <form className=\"grid min-w-0 gap-4\" noValidate onSubmit={submit}>\n <FieldShell id={inputId} label={t('admin.upload.file', 'File') ?? 'File'} error={noFile ? (t('admin.upload.required', 'Choose a file first.') ?? 'Choose a file first.') : undefined}>\n {/* The browser's own file box draws its button in the browser's language, so the box is kept for the keyboard and the picker and the button is drawn in the page's. */}\n <div className=\"flex min-w-0 items-center gap-3\">\n <input\n id={inputId}\n type=\"file\"\n className=\"peer sr-only\"\n accept={target.storage?.mimeTypes.join(',')}\n disabled={busy}\n aria-required\n aria-invalid={noFile || undefined}\n aria-describedby={describedBy({ id: inputId, error: noFile ? 'error' : undefined })}\n onChange={(event) => {\n const chosen = event.target.files?.[0] ?? null\n setFile(chosen)\n if (chosen) setNoFile(false)\n const early = chosen ? refusedFile(chosen, target.storage) : undefined\n setRefused(early ? { words: t(early, early) ?? early, retry: false } : undefined)\n }}\n />\n <label htmlFor={inputId} aria-hidden=\"true\" className={buttonVariants({ variant: 'outline', className: 'shrink-0 cursor-pointer peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:pointer-events-none peer-disabled:opacity-50' })}>\n {t('admin.upload.choose', 'Choose a file') ?? 'Choose a file'}\n </label>\n <span data-upload-chosen=\"\" dir=\"auto\" className=\"text-muted-foreground min-w-0 truncate text-sm\">\n {file?.name ?? t('admin.upload.noFile', 'No file chosen')}\n </span>\n </div>\n </FieldShell>\n {fields.map((field) => {\n const Control = controlFor(field)\n const id = `upload-${target.name}-${field.name}`\n const localized = field.rules.localized === true\n const held = (values[field.name] && typeof values[field.name] === 'object' ? values[field.name] : {}) as Row\n const names = new Intl.DisplayNames([locale], { type: 'language' })\n const error = wrong.words[field.name]\n const marked = (one: string | undefined) => error !== undefined && (!one || !wrong.locales[field.name]?.length || wrong.locales[field.name]!.includes(one))\n const set = (next: unknown) => {\n setValues((already) => ({ ...already, [field.name]: next }))\n setWrong(({ words: { [field.name]: _said, ...words }, locales }) => ({ words, locales }))\n }\n const control = (one: string | undefined) => (\n <Control\n key={one ?? ''}\n field={field}\n kayan={target}\n t={t}\n locale={locale}\n id={one && one !== locale ? `${id}-${one}` : id}\n value={one ? held[one] : values[field.name]}\n values={values}\n setValue={() => {}}\n onChange={(next) => set(one ? { ...held, [one]: next } : next)}\n readOnly={busy}\n error={marked(one) ? error : undefined}\n describedBy={[one ? `${id}-${one}-locale` : '', describedBy({ id, error }) ?? ''].filter(Boolean).join(' ') || undefined}\n />\n )\n const label = labelFor({ kayan: target, field, copy: { t, locale } })\n return (\n <FieldShell key={field.name} id={id} label={label} error={error}>\n {localized ? (\n // One box per locale, the viewer's own first. The viewer's box is named by the field's label, the others by label and locale, so none is unnamed.\n <div className=\"grid gap-2\">\n {[locale, ...locales.filter((one) => one !== locale)].map((one) => (\n <div key={one} className=\"grid gap-1\">\n {one === locale ? (\n <span id={`${id}-${one}-locale`} className=\"text-muted-foreground text-xs\">{names.of(one)}</span>\n ) : (\n <label id={`${id}-${one}-locale`} htmlFor={`${id}-${one}`} className=\"text-muted-foreground text-xs\">\n <span className=\"sr-only\">{label} </span>\n {names.of(one)}\n </label>\n )}\n {control(one)}\n </div>\n ))}\n </div>\n ) : control(undefined)}\n </FieldShell>\n )\n })}\n {busy ? (\n <div role=\"status\" className=\"grid gap-1 text-sm\">\n <span>{(t('admin.upload.sending', 'Uploading… {percent}%') ?? '').replace('{percent}', () => percent.toLocaleString(locale))}</span>\n <progress value={progress ?? 0} max={1} aria-label={title} className=\"h-2 w-full\" />\n </div>\n ) : null}\n {refused ? (\n <div role=\"alert\" data-upload-refused=\"\" className=\"border-destructive/50 text-destructive grid gap-2 rounded-md border px-3 py-2 text-sm\">\n <p dir=\"auto\">{refused.words}</p>\n {file && refused.retry ? (\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"justify-self-start\" onClick={() => void send()}>\n {t('admin.upload.retry', 'Try again') ?? 'Try again'}\n </Button>\n ) : null}\n </div>\n ) : null}\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" disabled={busy} onClick={close}>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</Button>\n <Button type=\"submit\" disabled={busy}>\n <UploadIcon className=\"size-4\" aria-hidden />\n {t('admin.upload.send', 'Upload') ?? 'Upload'}\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n )\n}\n\n/**\n * The `upload` kind's control: one file as its thumbnail (or name and type) over a picker and \"Upload new\".\n * A set is a grid, each file removed or moved by a keyboard-reachable button; read-only shows the files alone.\n */\nexport function UploadControl(props: ControlProps): ReactElement {\n const { api, client, field, id, locale = 'en', locales, readOnly, t, value } = props\n const change = changeOf(props)\n const screen = use(ScreenContext)\n const target = client?.find((one) => one.name === field.relationTo)\n const reads = target && api ? api[target.name] : undefined\n const held = idsOf(value)\n const many = field.hasMany === true\n const [rows, setRows] = useState<ReadonlyMap<string, Row>>(() => new Map())\n const [uploading, setUploading] = useState(false)\n const [focus, setFocus] = useState<string>()\n const asked = useRef(new Set<string>())\n const [settled, setSettled] = useState<ReadonlyMap<string, 'unseen' | 'failed'>>(() => new Map())\n\n const excluded = held.join(',')\n useEffect(() => {\n if (!reads || !screen) return\n const missing = held.filter((one) => !rows.has(one) && !asked.current.has(one))\n if (!missing.length) return\n for (const one of missing) asked.current.add(one)\n void findByIds({ reads, ids: missing, maxLimit: screen.screen.maxLimit })\n .then((docs) => {\n const found = new Set(docs.map((row) => String(row.id ?? '')))\n setSettled((already) => new Map([...already, ...missing.filter((one) => !found.has(one)).map((one) => [one, 'unseen'] as const)]))\n setRows((already) => new Map([...already, ...docs.map((row) => [String(row.id ?? ''), row] as const)]))\n })\n .catch((thrown: unknown) => {\n const state = isForbidden(thrown) ? 'unseen' : 'failed'\n setSettled((already) => new Map([...already, ...missing.map((one) => [one, state] as const)]))\n })\n }, [excluded, reads])\n\n useEffect(() => {\n if (!focus) return\n document.getElementById(focus)?.focus()\n setFocus(undefined)\n }, [focus])\n\n /** One held file: its thumbnail, or what the read settled, and never its raw id. */\n const drawn = (one: string, size: keyof typeof sizes) => {\n const row = rows.get(one)\n if (row) return <FileThumb row={row} t={t} locale={locale} size={size} />\n const state = reads && screen ? settled.get(one) : 'unseen'\n if (state === 'failed') return <span>—</span>\n return <span className=\"text-muted-foreground text-xs\">{state === 'unseen' ? (t('admin.cannotSee', 'You can\\'t see this') ?? 'You can\\'t see this') : (t('admin.loading', 'Loading…') ?? 'Loading…')}</span>\n }\n // Offered only where the access report lets this actor create a file.\n const creates = target !== undefined && createsIn({ kayan: target, report: screen?.access.collections?.[target.name] })\n const upload = !readOnly && target?.storage && screen && creates ? (\n <>\n <Button type=\"button\" variant=\"outline\" className=\"max-sm:h-11\" onClick={() => setUploading(true)} data-upload-new=\"\">\n <UploadIcon className=\"size-4\" aria-hidden />\n {t('admin.upload.new', 'Upload new') ?? 'Upload new'}\n </Button>\n <UploadDialog\n open={uploading}\n onOpenChange={setUploading}\n target={target}\n client={screen.client}\n t={t}\n locale={locale}\n locales={locales ?? [locale]}\n onDone={(row) => {\n const made = String(row.id ?? '')\n asked.current.add(made)\n setRows((already) => new Map(already).set(made, row))\n change(many ? [...held, made] : made)\n }}\n />\n </>\n ) : null\n const picker = (single: boolean) => (\n <div className=\"min-w-0 flex-1 basis-48\">\n <RelationshipControl\n {...props}\n field={{ ...field, hasMany: undefined }}\n value={single ? value : null}\n onChange={(next) => {\n if (single) return change(next)\n if (typeof next === 'string' && !held.includes(next)) change([...held, next])\n }}\n placeholder={single ? (t('admin.upload.choose', 'Choose a file') ?? 'Choose a file') : (t('admin.upload.add', 'Add a file') ?? 'Add a file')}\n drawRow={(row, label) => (\n <span className=\"flex min-w-0 items-center gap-2\">\n {urlOf(row, 'thumbnailURL') ? <FileThumb row={row} t={t} locale={locale} size=\"xs\" /> : <FileIcon className=\"text-muted-foreground size-4 shrink-0\" aria-hidden />}\n <span className=\"min-w-0 truncate\" dir=\"auto\">{label}</span>\n </span>\n )}\n />\n </div>\n )\n\n if (!many) {\n return (\n <div className=\"grid min-w-0 gap-2\" data-upload-control={field.name}>\n {held[0] ? drawn(held[0], readOnly ? 'lg' : 'sm') : null}\n {readOnly ? null : (\n <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n {picker(true)}\n {upload}\n </div>\n )}\n </div>\n )\n }\n\n // The keyboard stays where the person was: on the button that moved the file (its tile's other move once it reaches an end), or on a neighbour's remove after a removal, else the picker.\n const buttonOf = (one: string, which: 'earlier' | 'later' | 'remove') => `${id}-${one}-${which}`\n const move = (from: number, by: -1 | 1) => {\n const next = [...held]\n const [one] = next.splice(from, 1)\n next.splice(from + by, 0, one!)\n change(next)\n const end = by === -1 ? from + by === 0 : from + by === next.length - 1\n setFocus(buttonOf(one!, by === -1 ? (end ? 'later' : 'earlier') : end ? 'earlier' : 'later'))\n }\n const remove = (at: number) => {\n const next = held.filter((_, each) => each !== at)\n change(next)\n const neighbour = held[at + 1] ?? held[at - 1]\n setFocus(neighbour ? buttonOf(neighbour, 'remove') : id)\n }\n const words = (key: string, fallback: string, name: string) => (t(key, fallback) ?? fallback).replace('{name}', () => name)\n return (\n <div className=\"grid min-w-0 gap-2\" data-upload-control={field.name}>\n {held.length ? (\n <ul className=\"grid grid-cols-[repeat(auto-fill,minmax(6rem,1fr))] gap-2\">\n {held.map((one, at) => {\n const name = String(rows.get(one)?.filename ?? one)\n const earlier = words('admin.upload.moveEarlier', 'Move {name} earlier', name)\n const later = words('admin.upload.moveLater', 'Move {name} later', name)\n const removeLabel = words('admin.upload.remove', 'Remove {name}', name)\n return (\n <li key={one} data-upload-tile={one} className=\"grid min-w-0 justify-items-center gap-1 rounded-md border p-1\">\n {drawn(one, 'sm')}\n {readOnly ? null : (\n <div className=\"flex\">\n <Button id={buttonOf(one, 'earlier')} type=\"button\" variant=\"ghost\" size=\"icon-sm\" disabled={at === 0} title={earlier} aria-label={earlier} onClick={() => move(at, -1)}>\n <ChevronLeftIcon className=\"size-4 rtl:-scale-x-100\" aria-hidden />\n </Button>\n <Button id={buttonOf(one, 'remove')} type=\"button\" variant=\"ghost\" size=\"icon-sm\" title={removeLabel} aria-label={removeLabel} onClick={() => remove(at)}>\n <XIcon className=\"size-4\" aria-hidden />\n </Button>\n <Button id={buttonOf(one, 'later')} type=\"button\" variant=\"ghost\" size=\"icon-sm\" disabled={at === held.length - 1} title={later} aria-label={later} onClick={() => move(at, 1)}>\n <ChevronRightIcon className=\"size-4 rtl:-scale-x-100\" aria-hidden />\n </Button>\n </div>\n )}\n </li>\n )\n })}\n </ul>\n ) : null}\n {readOnly ? null : (\n <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n {picker(false)}\n {upload}\n </div>\n )}\n </div>\n )\n}\n\n// The rich text node is opt-in: a project lists `UploadNode` from `mithaq/rich-text` among its nodes,\n// `UploadView` among its views, `imagePick` among its picks and `UploadPlugin` as the editor's plugin.\n\n/** What the `/` menu's \"Image\" asks the editor for, which `UploadPlugin` answers with its dialog. */\nexport const INSERT_UPLOAD_COMMAND = createCommand<void>('INSERT_UPLOAD_COMMAND')\n\n/** The `/` menu's \"Image\", in each locale's own words. */\nexport const imagePick: RichTextPick = {\n key: 'upload',\n label: { en: 'Image', ar: 'صورة' },\n Icon: ImageIcon,\n keywords: ['image', 'upload', 'picture', 'photo', 'صورة'],\n onSelect: ({ editor }) => editor.dispatchCommand(INSERT_UPLOAD_COMMAND, undefined),\n}\n\n/**\n * A file inside the text, read by its id through the screen's client so the row's own access\n * decides, and drawn off the row's computed `url`. A file the viewer may not read, or of a kayan this screen holds no files of, draws the placeholder.\n */\nfunction UploadImage({ relationTo, value, format }: { relationTo: string; value: string; format: string }): ReactElement {\n const screen = use(ScreenContext)\n const t: Translate = screen?.t ?? ((_key, fallback) => fallback)\n const locale = screen?.locale ?? 'en'\n const readable = !!screen?.screen.kayan[relationTo]?.storage\n const [row, setRow] = useState<Row | null | false>(readable ? null : false)\n useEffect(() => {\n if (!screen || !readable) return setRow(false)\n let live = true\n screen.client({ kayan: relationTo, operation: 'find', input: { where: { id: { equals: value } }, limit: 1 } })\n .then((found) => live && setRow((found as FoundPage).docs[0] ?? false), () => live && setRow(false))\n return () => {\n live = false\n }\n }, [relationTo, value, readable, screen?.client])\n const align = format === 'center' ? 'justify-center' : format === 'right' || format === 'end' ? 'justify-end' : 'justify-start'\n return (\n <div data-upload-node={value} className={`my-2 flex ${align}`}>\n {row === null ? <span className=\"bg-muted size-24 animate-pulse rounded-md\" /> : row ? <FileValue row={row} t={t} locale={locale} /> : <Missing t={t} size=\"lg\" />}\n </div>\n )\n}\n\n/** The node's React half: `UploadNode`'s type and data, drawn by `UploadImage`. */\nexport class UploadView extends UploadNode {\n $config() {\n return this.config('upload', { extends: UploadNode })\n }\n\n decorate(): ReactElement {\n return <UploadImage relationTo={this.getRelationTo()} value={this.getValue()} format={this.getFormat()} />\n }\n}\n\n/**\n * The editor's half of \"Image\": a dialog holding the upload control over the screen's first kayan that holds files,\n * so a file is chosen or uploaded as a field's is; the node goes in its own block where the selection was.\n */\nexport function UploadPlugin(props: ControlProps): ReactElement | null {\n const [editor] = useLexicalComposerContext()\n const screen = use(ScreenContext)\n const [open, setOpen] = useState(false)\n useEffect(() => editor.registerCommand(INSERT_UPLOAD_COMMAND, () => (setOpen(true), true), COMMAND_PRIORITY_EDITOR), [editor])\n const target = Object.values(screen?.screen.kayan ?? {}).find((one) => one.storage)\n if (!screen || !target) return null\n const { t } = props\n const api: FindApi = { [target.name]: { find: (input) => screen.client({ kayan: target.name, operation: 'find', input }) as Promise<FoundPage> } }\n const field: ClientField = { name: 'upload', kind: 'upload', relationTo: target.name, rules: {}, component: 'upload', cell: 'upload', label: { [props.locale ?? 'en']: t('admin.upload.image', 'Image') ?? 'Image' } }\n return (\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent showCloseButton={false} data-upload-insert=\"\">\n <DialogHeader>\n <DialogTitle>{t('admin.upload.insert', 'Insert an image') ?? 'Insert an image'}</DialogTitle>\n <DialogDescription>{t('admin.upload.insertHint', 'Choose a file already here, or upload a new one.') ?? ''}</DialogDescription>\n </DialogHeader>\n <UploadControl\n {...props}\n id={`${props.id}-upload`}\n field={field}\n kayan={target}\n client={[target, ...(props.client ?? [])]}\n api={api}\n value={null}\n readOnly={false}\n onChange={(next) => {\n if (typeof next !== 'string') return\n editor.update(() => void $insertNodeToNearestRoot($create(UploadView).setUpload(target.name, next)))\n setOpen(false)\n }}\n />\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => setOpen(false)}>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 a7c10956534bbee3e398be2e6b514112d36145157e1fb4ad9ea439c5853a3854\n",
|
|
24
|
+
"type": "registry:component",
|
|
25
|
+
"target": "@components/mithaq/upload.tsx"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"type": "registry:component"
|
|
29
|
+
}
|