@drhomet/mithaq 0.1.1 → 0.2.0
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/LICENSES/openstatus-time-picker.txt +21 -0
- package/README.md +161 -0
- package/dist/asas/build.d.ts +10 -2
- package/dist/asas/build.js +69 -22
- package/dist/asas/cli/mithaq.js +35 -3
- package/dist/asas/cli/upgrade.d.ts +22 -9
- package/dist/asas/cli/upgrade.js +187 -78
- package/dist/asas/http/fetch.d.ts +21 -3
- package/dist/asas/http/fetch.js +123 -60
- package/dist/asas/http/orpc.d.ts +5 -3
- package/dist/asas/http/orpc.js +126 -108
- package/dist/asas/runner/bulk.d.ts +1 -1
- package/dist/asas/runner/bulk.js +16 -11
- package/dist/asas/runner/errors.d.ts +5 -2
- package/dist/asas/runner/errors.js +18 -2
- package/dist/asas/runner/read.d.ts +6 -2
- package/dist/asas/runner/read.js +28 -6
- package/dist/asas/runner/steps.d.ts +22 -4
- package/dist/asas/runner/steps.js +15 -9
- package/dist/asas/runner/store.d.ts +4 -0
- package/dist/asas/runner/store.js +21 -0
- package/dist/asas/runner/tenant.d.ts +2 -2
- package/dist/asas/runner/tenant.js +78 -16
- package/dist/asas/runner/write.d.ts +173 -11
- package/dist/asas/runner/write.js +274 -46
- package/dist/auth/better-auth/access.d.ts +14 -0
- package/dist/auth/better-auth/access.js +32 -0
- package/dist/auth/better-auth/generate.d.ts +3 -1
- package/dist/auth/better-auth/generate.js +26 -8
- package/dist/auth/better-auth/index.d.ts +2 -0
- package/dist/auth/better-auth/tenant.d.ts +11 -3
- package/dist/auth/better-auth/tenant.js +10 -2
- package/dist/auth/local/index.js +5 -3
- package/dist/base/fields.d.ts +3 -2
- package/dist/base/fields.js +3 -2
- package/dist/base/index.d.ts +2 -1
- package/dist/base/json-schema.d.ts +7 -0
- package/dist/base/json-schema.js +35 -0
- package/dist/base/kinds.d.ts +21 -0
- package/dist/base/kinds.js +46 -1
- package/dist/base/rules.d.ts +4 -4
- package/dist/base/rules.js +4 -3
- package/dist/drizzle/index.d.ts +9 -139
- package/dist/drizzle/index.js +143 -24
- package/dist/drizzle/kinds.d.ts +1 -0
- package/dist/drizzle/kinds.js +3 -1
- package/dist/drizzle/migrate.d.ts +26 -0
- package/dist/drizzle/migrate.js +69 -26
- package/dist/drizzle/rules.d.ts +1 -1
- package/dist/index.d.ts +11 -21
- package/dist/index.js +8 -18
- package/dist/mithaq/define.d.ts +62 -15
- package/dist/mithaq/define.js +23 -9
- package/dist/mithaq/generate-messages.js +5 -4
- package/dist/mithaq/language.d.ts +43 -4
- package/dist/mithaq/message-file.d.ts +13 -0
- package/dist/mithaq/message-file.js +29 -0
- package/dist/mithaq/messages.js +5 -0
- package/dist/mithaq/resolve.js +70 -10
- package/dist/mithaq/types.d.ts +29 -1
- package/dist/rbac/index.d.ts +2 -2
- package/dist/rbac/index.js +1 -1
- package/dist/rich-text/index.d.ts +1 -0
- package/dist/rich-text/index.js +1 -0
- package/dist/rich-text/rich-text.d.ts +2 -55
- package/dist/rich-text/rich-text.js +2 -125
- package/dist/rich-text/value.d.ts +55 -0
- package/dist/rich-text/value.js +126 -0
- package/dist/screen/client.d.ts +5 -2
- package/dist/screen/client.js +5 -2
- package/dist/screen/generate.js +48 -6
- package/dist/screen/index.d.ts +18 -1
- package/dist/screen/index.js +24 -6
- package/dist/screen/react.d.ts +2 -0
- package/dist/storage/index.d.ts +2 -3
- package/dist/storage/index.js +1 -2
- package/dist/storage/storage.d.ts +2 -0
- package/dist/storage/storage.js +9 -2
- package/dist/targets.d.ts +14 -0
- package/dist/targets.js +16 -0
- package/dist/vite.d.ts +7 -0
- package/dist/vite.js +81 -0
- package/dist/zod/index.d.ts +12 -3
- package/dist/zod/index.js +6 -5
- package/dist/zod/kinds.d.ts +3 -1
- package/dist/zod/kinds.js +11 -2
- package/dist/zod/rules.d.ts +1 -1
- package/docs/packages/mithaq/README.md +962 -0
- package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
- package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
- package/docs/packages/mithaq/src/drizzle/README.md +39 -0
- package/docs/packages/mithaq-admin/README.md +312 -0
- package/docs/templates/asas/README.md +1056 -0
- package/package.json +37 -8
- package/skills/add-field/SKILL.md +27 -0
- package/skills/add-kayan/SKILL.md +26 -0
- package/skills/eject-admin-piece/SKILL.md +28 -0
- package/skills/upgrade-mithaq/SKILL.md +35 -0
- package/r/mithaq-cells.json +0 -20
- package/r/mithaq-controls.json +0 -37
- package/r/mithaq-directional-icon.json +0 -15
- package/r/mithaq-expected-updated-at.json +0 -15
- package/r/mithaq-field-shell.json +0 -18
- package/r/mithaq-filter-operators.json +0 -15
- package/r/mithaq-form.json +0 -33
- package/r/mithaq-list-columns.json +0 -23
- package/r/mithaq-list-filters.json +0 -24
- package/r/mithaq-list-pagination.json +0 -24
- package/r/mithaq-list-query.json +0 -18
- package/r/mithaq-list-selection.json +0 -19
- package/r/mithaq-list.json +0 -33
- package/r/mithaq-nav.json +0 -24
- package/r/mithaq-nodes.json +0 -34
- package/r/mithaq-relationship-cell.json +0 -20
- package/r/mithaq-relationship-control.json +0 -20
- package/r/mithaq-rich-text.json +0 -40
- package/r/mithaq-screens-host.json +0 -96
- package/r/mithaq-storage.json +0 -44
- package/r/mithaq-upload.json +0 -29
- package/r/registry.json +0 -550
package/r/mithaq-upload.json
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
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
|
-
}
|